#!/usr/bin/env node
/**
 * Social Comment Responder, as an MCP server running on this machine.
 *
 * The local gateway spawns this over stdio and the agent calls its tools. The
 * server version is an Express router on a public vhost, so every route there
 * begins by proving who is calling — a JWT cookie for the dashboard or a signed
 * `x-user-id` for the sandbox, and an unsigned header is rejected outright.
 * None of that survives the port, and not because it was trimmed for size: the
 * gateway spawns this as a child process over a pipe, there is no socket to
 * reach it on, and there is one person on the machine. An auth check here would
 * be asking the user to prove they are themselves.
 *
 * ## 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. Same reasoning — and the same protocol shape — as `mcp-facebook`.
 *
 * ## What this server does NOT do
 *
 * It does not decide anything. The agent has full authority over what it replies
 * and whether to reply at all; this server records what happened. The one rule
 * it enforces is idempotency, and that is bookkeeping rather than judgement —
 * see `store.mjs`.
 */
import * as store from './store.mjs'

const PROTOCOL_VERSION = '2025-06-18'

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

const PLATFORM = {
  type: 'string',
  enum: ['facebook', 'instagram'],
  description: 'Which platform the comment is on',
}

const COMMENT = obj(
  {
    platform: PLATFORM,
    external_id: str("The platform's own comment id, read off the page"),
    source_type: str('page_post | ad | ig_media (default page_post)'),
    source_ref: str('Permalink of the post or ad the comment sits on'),
    permalink: str('Permalink of the comment itself'),
    author_name: str('Display name of whoever wrote it'),
    author_ref: str('Profile link or id of the author'),
    body: str('The comment text, verbatim'),
    posted_at: str('ISO timestamp of when it was posted, if the page shows one'),
  },
  ['platform', 'external_id'],
)

/**
 * One `markSeen` result, shaped for the agent.
 *
 * The store hands back `body` and `posted_at` so the backlog policy can read
 * them; echoing the comment text the agent just sent us back at it would double
 * the payload of every sweep for nothing. `answerable` only appears on entries
 * that are still work, because on a handled comment it would read as a second,
 * contradictory verdict.
 */
function forAgent(entry, answerable) {
  if (entry.error) return { index: entry.index, error: entry.error }
  const out = { index: entry.index, external_id: entry.external_id, handled: entry.handled }
  if (entry.self_reply) out.self_reply = true
  else out.status = entry.status
  if (entry.handled === false) out.answerable = answerable.has(entry.external_id)
  return out
}

const TOOLS = [
  {
    name: 'social_status',
    description:
      'Queue counters for the comment responder. Says nothing about WHICH Page — the browser is already signed in, so the live session is the source of truth for that.',
    inputSchema: obj({}, []),
    run: () => ({ ok: true, queue: store.queueCounts() }),
  },
  {
    name: 'social_mark_seen',
    description:
      'Record every comment visible in the inbox and find out which still need work. Call this ONCE at the start of a sweep, BEFORE replying to anything. Entries returned with handled=false are your work list; everything else was dealt with by an earlier sweep and must be left alone.',
    inputSchema: obj({ comments: { type: 'array', items: COMMENT } }, ['comments']),
    run: ({ comments }) => {
      if (!Array.isArray(comments)) throw new Error('comments must be an array')
      const seen = store.markSeen(comments)

      // Everything is recorded above; only the workable subset is offered as
      // work. `selectAnswerable` is what keeps a laptop that was closed for a
      // week from answering a week of backlog in one burst.
      const workable = seen.filter((r) => r.handled === false && !r.error)
      const answerable = new Set(
        store
          .selectAnswerable(
            workable.map((r) => ({
              externalId: r.external_id,
              postedAt: r.posted_at,
              body: r.body,
            })),
          )
          .map((c) => c.externalId),
      )

      return { ok: true, comments: seen.map((entry) => forAgent(entry, answerable)) }
    },
  },
  {
    name: 'social_log_outcome',
    description:
      "Log what you did with one comment, after you have already acted. outcome is 'replied' (you posted it and saw it land), 'skipped' (you decided not to), or 'failed' (you tried and could not). Calling twice for the same comment is a no-op, not a double count.",
    inputSchema: obj(
      {
        platform: PLATFORM,
        external_id: str("The platform's own comment id"),
        outcome: { type: 'string', enum: ['replied', 'skipped', 'failed'] },
        intent: str(
          "How you read the comment, in your own words and the shopper's own language — e.g. hỏi giá, hỏi size, khen, giao chậm, spam. There is no fixed list.",
        ),
        sentiment: str('positive | neutral | negative, or your own reading'),
        reply_body: str('The reply you posted, verbatim (required when outcome=replied)'),
        reply_external_id: str('The id of your reply, if the page exposes one'),
        error: str('What you observed, when outcome=failed'),
        body: str('The original comment text, if you have it'),
      },
      ['platform', 'external_id', 'outcome'],
    ),
    run: (args) => {
      const result = store.logOutcome({
        platform: args.platform,
        externalId: args.external_id,
        outcome: args.outcome,
        intent: args.intent,
        sentiment: args.sentiment,
        replyBody: args.reply_body,
        replyExternalId: args.reply_external_id,
        error: args.error,
        body: args.body,
      })
      if (result?.error) throw new Error(result.error)
      // null means an earlier sweep already logged this one. Said plainly so the
      // agent does not read silence as failure and retry the reply.
      return result ?? { ok: true, already_logged: true }
    },
  },
  {
    name: 'social_record_engagement',
    description:
      'Record how a reply you posted actually performed, measured later — hours or days after the reply. This is the feedback loop: it is what makes next week\'s replies better than this week\'s.',
    inputSchema: obj(
      {
        platform: PLATFORM,
        external_id: str('The id of the ORIGINAL comment, not of your reply'),
        likes: { type: 'number', description: 'Likes on your reply' },
        author_replied: { type: 'boolean', description: 'Did the shopper write back?' },
        hidden_or_deleted: {
          type: 'boolean',
          description: 'Was your reply hidden or deleted? This is the signal that it backfired.',
        },
      },
      ['platform', 'external_id'],
    ),
    run: (args) => {
      const result = store.recordEngagement({
        platform: args.platform,
        externalId: args.external_id,
        likes: args.likes,
        authorReplied: args.author_replied,
        hiddenOrDeleted: args.hidden_or_deleted,
      })
      if (result?.error) throw new Error(result.error)
      return result ?? { ok: true, unknown_comment: true }
    },
  },
  {
    name: 'social_stats',
    description:
      'Aggregate view by intent and sentiment — what shoppers ask about, what you replied, and how those replies landed. Read this before a sweep to learn from what already worked.',
    inputSchema: obj({ days: { type: 'number', description: 'Look-back window (default 30)' } }, []),
    run: ({ days }) => ({ ok: true, stats: store.intentStats({ days }) }),
  },
]

// ── 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-social-comments', 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 what went wrong 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))
