/**
 * Meta Marketing API, ported to run on the user's own machine.
 *
 * Lifted from `_FANGBOT/server/src/services/facebook-provider.js`, which was
 * already pure — zero database access, every call parameterised by an access
 * token. That is the whole reason this port is small: the multi-tenant coupling
 * in the production MCP server was never in the Graph calls, only in the token
 * lookup (`SELECT access_token FROM facebook_profiles WHERE user_id = $1`).
 * Here the token comes from a file this machine owns.
 *
 * Deliberately dependency-free. It runs as a child process of the local
 * gateway, outside the app's asar, where a bundled `node_modules` would not
 * resolve — and `fetch` has been in Node since 18.
 */

import { fetchPublic } from './net-guard.mjs'

const GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION || 'v24.0'
const BASE_URL = `https://graph.facebook.com/${GRAPH_VERSION}`

export class GraphError extends Error {
  constructor(message, code) {
    super(message)
    this.name = 'GraphError'
    this.code = code
  }
}

/** Meta reports an expired token as a 190 inside an otherwise ordinary error body. */
function raise(text) {
  if (text.includes('Error validating access token') || text.includes('"code":190')) {
    throw new GraphError('The Facebook token has expired — reconnect the account.', 'TOKEN_EXPIRED')
  }
  throw new GraphError(`Facebook API error: ${text}`, 'API_ERROR')
}

async function get(endpoint, token, params = {}) {
  const url = new URL(`${BASE_URL}/${endpoint}`)
  url.searchParams.set('access_token', token)
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, String(value))
  const response = await fetch(url)
  if (!response.ok) raise(await response.text())
  return response.json()
}

/**
 * Graph takes writes as query parameters on a POST, not as a JSON body — an
 * inherited quirk, and the reason objects are stringified into the URL here.
 */
async function post(endpoint, token, body = {}) {
  const url = new URL(`${BASE_URL}/${endpoint}`)
  url.searchParams.set('access_token', token)
  for (const [key, value] of Object.entries(body)) {
    if (value === undefined || value === null) continue
    url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : String(value))
  }
  const response = await fetch(url, { method: 'POST' })
  if (!response.ok) raise(await response.text())
  return response.json()
}

/** Follows `paging.next` to the end. Ad accounts and campaigns are routinely paged. */
async function all(endpoint, token, params = {}) {
  const first = await get(endpoint, token, params)
  const rows = [...(first.data ?? [])]
  let next = first.paging?.next
  while (next) {
    // `next` arrives inside a response body. Pinning it to Graph's own host
    // means a surprising or tampered payload cannot redirect paging somewhere
    // else with the access token attached.
    if (new URL(next).origin !== new URL(BASE_URL).origin) {
      throw new GraphError(`Refusing to follow paging to ${new URL(next).origin}`, 'API_ERROR')
    }
    const response = await fetch(next)
    if (!response.ok) raise(await response.text())
    const page = await response.json()
    rows.push(...(page.data ?? []))
    next = page.paging?.next
  }
  return rows
}

// ── Identity ────────────────────────────────────────────────────────────────

export const getProfile = (token) => get('me', token, { fields: 'id,name,email' })

/** Active accounts only: a disabled one accepts calls and never delivers. */
export async function getAdAccounts(token) {
  const accounts = await all('me/adaccounts', token, {
    fields: 'id,name,account_status,currency,timezone_name',
  })
  return accounts.filter((account) => account.account_status === 1)
}

// ── Campaigns, ad sets, ads ─────────────────────────────────────────────────

export const getCampaigns = (adAccountId, token) =>
  all(`${adAccountId}/campaigns`, token, {
    fields:
      'id,name,status,objective,daily_budget,lifetime_budget,budget_remaining,start_time,stop_time,created_time,updated_time',
    limit: 100,
  })

export const createCampaign = (adAccountId, token, payload) =>
  post(`${adAccountId}/campaigns`, token, payload)

export const getAdSets = (adAccountId, token, campaignId) =>
  all(campaignId ? `${campaignId}/adsets` : `${adAccountId}/adsets`, token, {
    fields:
      'id,name,status,daily_budget,lifetime_budget,start_time,end_time,targeting,optimization_goal,billing_event,bid_amount',
    limit: 100,
  })

export const createAdSet = (adAccountId, token, payload) =>
  post(`${adAccountId}/adsets`, token, payload)

export const createAd = (adAccountId, token, payload) => post(`${adAccountId}/ads`, token, payload)

export const updateStatus = (objectId, token, status) => post(objectId, token, { status })

// ── Creative assets ─────────────────────────────────────────────────────────

/**
 * Images upload as base64 in a JSON body — the one Graph write that is not
 * query params.
 *
 * `imageUrl` is chosen by the agent, and this process runs on the user's own
 * machine, so the fetch goes through `fetchPublic`: without it, asking for
 * `http://127.0.0.1:18789/…` would read the user's own gateway and upload the
 * answer to Meta as ad creative.
 */
export async function uploadImage(adAccountId, token, imageUrl) {
  let source
  try {
    source = await fetchPublic(imageUrl)
  } catch (error) {
    throw new GraphError(error.message, 'BLOCKED')
  }
  if (!source.ok) throw new GraphError(`Could not fetch the image: ${source.status}`, 'FETCH_FAILED')
  const bytes = Buffer.from(await source.arrayBuffer()).toString('base64')

  const response = await fetch(`${BASE_URL}/${adAccountId}/adimages?access_token=${token}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ bytes }),
  })
  if (!response.ok) raise(await response.text())

  const result = await response.json()
  const hash = result.images?.bytes?.hash
  if (!hash) throw new GraphError('Facebook accepted the upload but returned no hash.', 'API_ERROR')
  return { hash, url: result.images.bytes.url }
}

/**
 * Chunked, resumable upload of a video for use as ad creative.
 *
 * Meta has no "upload from URL" for ad videos, so the bytes come here first.
 * Same SSRF guard as `uploadImage` and for the same reason — the URL is chosen
 * by the agent and this runs on the user's own machine.
 *
 * Three phases, and **every one of them posts to `{ad_account}/advideos`**,
 * never to the video node. Posting the transfer to the video node makes Meta
 * treat it as publishing to a Page and reject the whole upload with
 * "(#200) The permission(s) pages_manage_posts are not available" — an error
 * about Pages that has nothing to do with what is being uploaded. The server
 * implementation learned this the hard way
 * (`server/src/services/facebook-provider.js:305-313`); the comment is carried
 * over so it is not learned twice.
 */
export async function uploadVideo(adAccountId, token, videoUrl, title, description) {
  let source
  try {
    source = await fetchPublic(videoUrl)
  } catch (error) {
    throw new GraphError(error.message, 'BLOCKED')
  }
  if (!source.ok) throw new GraphError(`Could not fetch the video: ${source.status}`, 'FETCH_FAILED')

  const bytes = Buffer.from(await source.arrayBuffer())
  const fileName = videoUrl.split('/').pop()?.split('?')[0] || 'video.mp4'
  const endpoint = `${BASE_URL}/${adAccountId}/advideos`

  // Phase 1 — open a session.
  const session = await post(`${adAccountId}/advideos`, token, {
    upload_phase: 'start',
    file_name: fileName,
    file_size: bytes.length,
  })
  const videoId = session.video_id
  const sessionId = session.upload_session_id
  if (!videoId || !sessionId) {
    throw new GraphError('Meta did not return a video upload session.', 'API_ERROR')
  }

  // Phase 2 — transfer. Meta drives the chunking: each response names the next
  // offsets, and the transfer is done once they stop advancing.
  let start = Number(session.start_offset || 0)
  let end = Number(session.end_offset || bytes.length)

  while (start < end) {
    const form = new FormData()
    form.append('access_token', token)
    form.append('upload_phase', 'transfer')
    form.append('upload_session_id', sessionId)
    form.append('start_offset', String(start))
    form.append(
      'video_file_chunk',
      new Blob([bytes.subarray(start, end)], { type: 'video/mp4' }),
      fileName,
    )

    const response = await fetch(endpoint, { method: 'POST', body: form })
    const text = await response.text()
    if (!response.ok) raise(text)

    let json
    try {
      json = JSON.parse(text)
    } catch {
      throw new GraphError(`Unparseable transfer response: ${text.slice(0, 200)}`, 'API_ERROR')
    }

    const nextStart = Number(json.start_offset)
    // A response that does not advance would spin here forever against Meta.
    if (!Number.isFinite(nextStart) || nextStart <= start) break
    start = nextStart
    const nextEnd = Number(json.end_offset)
    end = Number.isFinite(nextEnd) ? nextEnd : bytes.length
  }

  // Phase 3 — finalise.
  const finish = new FormData()
  finish.append('access_token', token)
  finish.append('upload_phase', 'finish')
  finish.append('upload_session_id', sessionId)
  finish.append('title', title || fileName)
  if (description) finish.append('description', description)

  const finished = await fetch(endpoint, { method: 'POST', body: finish })
  if (!finished.ok) raise(await finished.text())

  return { id: videoId }
}

/**
 * Builds the `creative` an ad is created with.
 *
 * Pure and exported so the screen and the agent's MCP tool build the same
 * object rather than each assembling their own `object_story_spec` — the shape
 * is fiddly, format-dependent, and rejected as a whole when one nested field is
 * wrong.
 *
 * Input is Graph's own vocabulary (`adset_id`, `image_hash`, …) end to end, so
 * there is no renaming layer between the form and the API to drift.
 */
export function buildAdCreative(input) {
  const format = input.format || 'single_image'
  const callToAction = {
    type: input.call_to_action || 'LEARN_MORE',
    value: { link: input.link || '' },
  }

  let mediaSpec
  if (format === 'single_video') {
    mediaSpec = {
      video_data: {
        message: input.message || '',
        video_id: input.video_id,
        // Meta REQUIRES a thumbnail on a video ad and rejects the creative
        // without one, so an absent hash is worth surfacing rather than
        // defaulting to empty.
        image_hash: input.thumbnail_hash,
        title: input.headline || input.name,
        link_description: input.description || '',
        call_to_action: callToAction,
      },
    }
  } else if (format === 'carousel') {
    mediaSpec = {
      link_data: {
        message: input.message || '',
        link: input.link || '',
        child_attachments: input.child_attachments || [],
        multi_share_optimized: true,
      },
    }
  } else {
    mediaSpec = {
      link_data: {
        message: input.message || '',
        name: input.headline || '',
        description: input.description || '',
        link: input.link || '',
        image_hash: input.image_hash || '',
        call_to_action: callToAction,
      },
    }
  }

  const creative = {
    name: `${input.name} - Creative`,
    object_story_spec: { page_id: input.page_id, ...mediaSpec },
  }
  if (input.url_tags) creative.url_tags = input.url_tags
  return creative
}

// ── Pages, pixels, insights ─────────────────────────────────────────────────

/** `me/accounts` rather than `promote_pages`: the latter needs app review. */
export const getPages = (token) => all('me/accounts', token, { fields: 'id,name', limit: 100 })

export const getPixels = (adAccountId, token) =>
  all(`${adAccountId}/adspixels`, token, { fields: 'id,name,status' })

export function getInsights(objectId, token, options = {}) {
  const params = {
    fields:
      options.fields || 'impressions,clicks,spend,cpc,cpm,ctr,reach,frequency,actions',
  }
  if (options.level) params.level = options.level
  if (options.since && options.until) {
    params.time_range = JSON.stringify({ since: options.since, until: options.until })
  } else {
    params.date_preset = options.datePreset || 'last_7d'
  }
  return get(`${objectId}/insights`, token, params)
}
