#!/usr/bin/env node
/**
 * Facebook Ads, as an MCP server running on this machine.
 *
 * The local gateway spawns this over stdio and the agent calls its tools. No
 * Postgres, no Express, no multi-tenant layer — the production server needed
 * those to know *whose* token to use, and on one machine there is only one.
 *
 * ## Why no SDK
 *
 * MCP over stdio is JSON-RPC 2.0 in newline-delimited JSON. Implementing it
 * directly keeps this file dependency-free, which matters because it runs as a
 * child process outside the app's asar, where a bundled `node_modules` would
 * not resolve. The protocol below was captured from a live session with
 * `@modelcontextprotocol/server-filesystem` rather than written from memory.
 *
 * ## Where the token lives
 *
 * `FANGBOT_FB_CREDENTIALS` points at a JSON file the app writes with mode 0600.
 * It is deliberately not passed as an environment variable and deliberately not
 * stored in `openclaw.json`: the config is world-readable and gets copied into
 * bug reports.
 */
import { readFileSync, writeFileSync } from 'node:fs'
import * as graph from './graph.mjs'

const PROTOCOL_VERSION = '2025-06-18'
const CREDENTIALS_PATH = process.env.FANGBOT_FB_CREDENTIALS

function readCredentials() {
  if (!CREDENTIALS_PATH) {
    throw new Error('No credentials path configured — reconnect Facebook in Settings → Connectors.')
  }
  let raw
  try {
    raw = readFileSync(CREDENTIALS_PATH, 'utf8')
  } catch {
    throw new Error('Facebook is not connected on this machine yet.')
  }
  const parsed = JSON.parse(raw)
  if (!parsed.accessToken) throw new Error('Facebook is not connected on this machine yet.')
  return parsed
}

/** Persists the chosen ad account beside the token, so the choice survives a restart. */
function patchCredentials(patch) {
  const current = readCredentials()
  const next = { ...current, ...patch }
  writeFileSync(CREDENTIALS_PATH, JSON.stringify(next, null, 2), { mode: 0o600 })
  return next
}

function requireAccount(credentials, override) {
  const id = override ?? credentials.adAccountId
  if (!id) {
    throw new Error('No ad account selected — call fb_select_ad_account first.')
  }
  return id
}

// ── Tools ───────────────────────────────────────────────────────────────────

const str = (description) => ({ type: 'string', description })
const obj = (properties, required = []) => ({ type: 'object', properties, required })

const TOOLS = [
  {
    name: 'fb_connection_status',
    description: 'Whether Facebook is connected on this machine, and which ad account is selected.',
    inputSchema: obj({}),
    run: async () => {
      const credentials = readCredentials()
      const profile = await graph.getProfile(credentials.accessToken)
      return {
        connected: true,
        account: profile.name,
        adAccountId: credentials.adAccountId ?? null,
        adAccountName: credentials.adAccountName ?? null,
      }
    },
  },
  {
    name: 'fb_list_ad_accounts',
    description: 'Active ad accounts this Facebook user can spend from.',
    inputSchema: obj({}),
    run: async () => graph.getAdAccounts(readCredentials().accessToken),
  },
  {
    name: 'fb_select_ad_account',
    description: 'Choose the ad account every later call operates on.',
    inputSchema: obj({ adAccountId: str('e.g. act_1234567890') }, ['adAccountId']),
    run: async ({ adAccountId }) => {
      const credentials = readCredentials()
      const accounts = await graph.getAdAccounts(credentials.accessToken)
      const match = accounts.find((account) => account.id === adAccountId)
      if (!match) throw new Error(`${adAccountId} is not an active ad account on this login.`)
      patchCredentials({ adAccountId: match.id, adAccountName: match.name })
      return { selected: match.id, name: match.name }
    },
  },
  {
    name: 'fb_list_campaigns',
    description: 'Campaigns in the selected ad account.',
    inputSchema: obj({ adAccountId: str('Overrides the selected account.') }),
    run: async ({ adAccountId }) => {
      const credentials = readCredentials()
      return graph.getCampaigns(requireAccount(credentials, adAccountId), credentials.accessToken)
    },
  },
  {
    name: 'fb_create_campaign',
    description:
      'Create a campaign. Always created PAUSED — nothing spends until a human activates it.',
    inputSchema: obj(
      {
        name: str('Campaign name'),
        objective: str('e.g. OUTCOME_TRAFFIC, OUTCOME_SALES'),
        dailyBudget: { type: 'number', description: 'In the account currency, e.g. 20 for $20' },
        specialAdCategories: {
          type: 'array',
          items: { type: 'string' },
          description: 'Required by Meta for credit, employment, housing, and politics.',
        },
      },
      ['name', 'objective'],
    ),
    run: async ({ name, objective, dailyBudget, specialAdCategories }) => {
      const credentials = readCredentials()
      return graph.createCampaign(requireAccount(credentials), credentials.accessToken, {
        name,
        objective,
        // Minor units — sending 20 for "$20 a day" buys twenty cents of delivery,
        // which looks like a campaign that simply never spends.
        ...(dailyBudget ? { daily_budget: String(Math.round(dailyBudget * 100)) } : {}),
        special_ad_categories: specialAdCategories ?? [],
        status: 'PAUSED',
      })
    },
  },
  {
    name: 'fb_create_ad_set',
    description: 'Create an ad set under a campaign. Created PAUSED.',
    inputSchema: obj(
      {
        campaignId: str('Campaign to attach to'),
        name: str('Ad set name'),
        dailyBudget: { type: 'number', description: 'In the account currency' },
        optimizationGoal: str('e.g. LINK_CLICKS'),
        countries: { type: 'array', items: { type: 'string' }, description: 'e.g. ["VN"]' },
        ageMin: { type: 'number' },
        ageMax: { type: 'number' },
      },
      ['campaignId', 'name', 'countries'],
    ),
    run: async ({ campaignId, name, dailyBudget, optimizationGoal, countries, ageMin, ageMax }) => {
      const credentials = readCredentials()
      return graph.createAdSet(requireAccount(credentials), credentials.accessToken, {
        campaign_id: campaignId,
        name,
        ...(dailyBudget ? { daily_budget: String(Math.round(dailyBudget * 100)) } : {}),
        optimization_goal: optimizationGoal ?? 'LINK_CLICKS',
        billing_event: 'IMPRESSIONS',
        targeting: {
          geo_locations: { countries },
          age_min: ageMin ?? 18,
          age_max: ageMax ?? 65,
        },
        status: 'PAUSED',
      })
    },
  },
  {
    name: 'fb_list_ad_sets',
    description: 'Ad sets in the selected account, optionally filtered to one campaign.',
    inputSchema: obj({ campaignId: str('Optional campaign filter') }),
    run: async ({ campaignId }) => {
      const credentials = readCredentials()
      return graph.getAdSets(requireAccount(credentials), credentials.accessToken, campaignId)
    },
  },
  {
    name: 'fb_upload_image',
    description: 'Upload an image for use as ad creative. Returns its hash.',
    inputSchema: obj({ imageUrl: str('Public URL of the image') }, ['imageUrl']),
    run: async ({ imageUrl }) => {
      const credentials = readCredentials()
      return graph.uploadImage(requireAccount(credentials), credentials.accessToken, imageUrl)
    },
  },
  {
    name: 'fb_upload_video',
    description:
      'Upload a video for use as ad creative. Returns its id. A video ad also needs a thumbnail — upload one with fb_upload_image and pass its hash to fb_create_ad.',
    inputSchema: obj({ videoUrl: str('Public URL of the video'), title: str('Optional') }, [
      'videoUrl',
    ]),
    run: async ({ videoUrl, title }) => {
      const credentials = readCredentials()
      return graph.uploadVideo(requireAccount(credentials), credentials.accessToken, videoUrl, title)
    },
  },
  {
    name: 'fb_create_ad',
    description:
      'Create an ad under an ad set, with its creative. Always created PAUSED — nothing is delivered until a human activates it. Upload the media first: image_hash for a single image, video_id plus thumbnail_hash for video.',
    inputSchema: obj(
      {
        name: str('Ad name'),
        adSetId: str('Ad set to attach to'),
        pageId: str('Page to publish as — see fb_list_pages'),
        format: str('single_image (default), single_video, or carousel'),
        message: str('Primary text shown above the creative'),
        headline: str('Headline'),
        description: str('Link description'),
        link: str('Destination URL'),
        callToAction: str('e.g. LEARN_MORE, SHOP_NOW'),
        imageHash: str('From fb_upload_image, for single_image'),
        videoId: str('From fb_upload_video, for single_video'),
        thumbnailHash: str('From fb_upload_image — REQUIRED for single_video'),
      },
      ['name', 'adSetId', 'pageId'],
    ),
    run: async (input) => {
      const credentials = readCredentials()
      const adAccountId = requireAccount(credentials)
      // Same builder the screen uses, so an ad the agent makes and an ad the
      // user makes are assembled identically.
      const creative = graph.buildAdCreative({
        name: input.name,
        page_id: input.pageId,
        format: input.format,
        message: input.message,
        headline: input.headline,
        description: input.description,
        link: input.link,
        call_to_action: input.callToAction,
        image_hash: input.imageHash,
        video_id: input.videoId,
        thumbnail_hash: input.thumbnailHash,
      })
      return graph.createAd(adAccountId, credentials.accessToken, {
        name: input.name,
        adset_id: input.adSetId,
        creative,
        status: 'PAUSED',
      })
    },
  },
  {
    name: 'fb_list_pages',
    description: 'Facebook Pages this login can publish as. An ad must publish as a Page.',
    inputSchema: obj({}),
    run: async () => graph.getPages(readCredentials().accessToken),
  },
  {
    name: 'fb_list_pixels',
    description: 'Pixels on the selected ad account.',
    inputSchema: obj({}),
    run: async () => {
      const credentials = readCredentials()
      return graph.getPixels(requireAccount(credentials), credentials.accessToken)
    },
  },
  {
    name: 'fb_get_insights',
    description: 'Performance for a campaign, ad set, ad, or the whole account.',
    inputSchema: obj({
      objectId: str('Defaults to the selected ad account'),
      datePreset: str('today, yesterday, last_7d, last_30d …'),
      level: str('account, campaign, adset, or ad'),
    }),
    run: async ({ objectId, datePreset, level }) => {
      const credentials = readCredentials()
      return graph.getInsights(objectId ?? requireAccount(credentials), credentials.accessToken, {
        datePreset,
        level,
      })
    },
  },
  {
    name: 'fb_update_status',
    description:
      'Activate or pause a campaign, ad set or ad. Activating starts real spending.',
    inputSchema: obj(
      { objectId: str('Campaign, ad set or ad id'), status: str('ACTIVE or PAUSED') },
      ['objectId', 'status'],
    ),
    run: async ({ objectId, status }) => {
      const credentials = readCredentials()
      const normalised = String(status).toUpperCase()
      if (normalised !== 'ACTIVE' && normalised !== 'PAUSED') {
        throw new Error('status must be ACTIVE or PAUSED.')
      }
      return graph.updateStatus(objectId, credentials.accessToken, normalised)
    },
  },
]

// ── JSON-RPC over stdio ─────────────────────────────────────────────────────

const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`)
const reply = (id, result) => write({ jsonrpc: '2.0', id, result })
const fail = (id, code, message) => write({ jsonrpc: '2.0', id, error: { code, message } })

async function handle(request) {
  const { id, method, params } = request

  if (method === 'initialize') {
    return reply(id, {
      protocolVersion: PROTOCOL_VERSION,
      capabilities: { tools: {} },
      serverInfo: { name: 'fangbot-facebook-ads', version: '1.0.0' },
    })
  }

  // Notifications carry no id and must not be answered.
  if (id === undefined) return

  if (method === 'tools/list') {
    return reply(id, {
      tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
    })
  }

  if (method === 'tools/call') {
    const tool = TOOLS.find((candidate) => candidate.name === params?.name)
    if (!tool) return fail(id, -32602, `Unknown tool: ${params?.name}`)
    try {
      const result = await tool.run(params.arguments ?? {})
      return reply(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] })
    } catch (error) {
      // Reported as a tool result rather than a protocol error: the model should
      // read "the token expired" and say so, not see the whole call fail.
      return reply(id, {
        content: [{ type: 'text', text: `Error: ${error.message}` }],
        isError: true,
      })
    }
  }

  return fail(id, -32601, `Unknown method: ${method}`)
}

let buffer = ''
process.stdin.on('data', (chunk) => {
  buffer += chunk
  const lines = buffer.split('\n')
  buffer = lines.pop() ?? ''
  for (const line of lines) {
    if (!line.trim()) continue
    let request
    try {
      request = JSON.parse(line)
    } catch {
      continue // a partial or malformed frame is not worth killing the server over
    }
    void handle(request).catch((error) => {
      if (request.id !== undefined) fail(request.id, -32603, error.message)
    })
  }
})

// The gateway owns this process; when it closes the pipe, there is nothing left to serve.
process.stdin.on('end', () => process.exit(0))
