// src/lib/mcp/server.ts — builds the MCP server and registers the v1 tools
// (read-only except run_site_audit, which fetches the site and replaces its stored audit).
// Each tool reads the authenticated `orgId` from the request's AuthInfo
// (`extra.authInfo.extra.orgId`, set by the route's verifyToken) and delegates to
// the org-scoped data fns in tools.ts (unit-tested directly). No cross-org leak:
// the orgId always comes from the validated key, never from tool input.
import "server-only";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import type {
  ServerNotification,
  ServerRequest,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { getOrgMcpQuotaSnapshot } from "./quota";
import { searchSpecTopics } from "./spec-search";
import {
  auditOverview,
  getKeywordBrief,
  getKeywordReference,
  getSiteAudit,
  getSpecTopic,
  type KeywordLookup,
  listSites,
  listSpecTopics,
  runSiteAuditForOrg,
} from "./tools";

/** Identité du serveur MCP — source unique, importée par la route (serverInfo)
 *  et par buildMcpServer (tests). Ne dupliquez pas ces valeurs ailleurs. */
export const MCP_SERVER_INFO = {
  name: "seo-insight",
  version: "1.0.0",
} as const;

/** Protocol-level `instructions` advertised at initialize — a short orientation
 *  for the calling agent. Single source, passed to createMcpHandler in route.ts. */
export const MCP_INSTRUCTIONS =
  "Start with get_capabilities or list_sites. Spec tools (list/search/get_spec_topic) are static and global. run_site_audit has a 60s cooldown. Every MCP request counts toward your monthly quota, not only tool calls.";

const SCOPE_NOTE =
  "All data is scoped to the organization authenticated by the API key.";

/** Annotation preset for the read-only tools (everything except run_site_audit). */
const READ_ONLY = { readOnlyHint: true } as const;

type ToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;

function text(body: string) {
  return { content: [{ type: "text" as const, text: body }] };
}

/** Pull the validated org id off the request AuthInfo. Throws if absent — the
 *  route only reaches tools after a successful resolveMcpOrg, so this is a guard
 *  against a misconfigured transport rather than an expected path. */
function requireOrgId(extra: ToolExtra): string {
  const orgId = extra.authInfo?.extra?.orgId;
  if (typeof orgId !== "string" || !orgId) {
    throw new Error("Organization not resolved (missing auth)");
  }
  return orgId;
}

/** Register the MCP tools (read-only except run_site_audit) onto an MCP server. Org-scoping is per-call via
 *  the request AuthInfo (`extra.authInfo.extra.orgId`). Used both by the route
 *  (on the adapter-owned server) and by buildMcpServer (for tests). */
export function registerMcpTools(server: McpServer): void {
  server.registerTool(
    "list_sites",
    {
      title: "List sites",
      description: `List the organization's sites (id, name, url). ${SCOPE_NOTE}`,
      inputSchema: {},
      annotations: READ_ONLY,
    },
    async (_args, extra) => {
      const sites = await listSites(requireOrgId(extra));
      if (sites.length === 0) return text("No sites in this organization.");
      const lines = sites.map(
        (s) => `- ${s.name} (id: ${s.id}) — ${s.url ?? "no URL configured"}`,
      );
      return text(`Organization sites (${sites.length}):\n${lines.join("\n")}`);
    },
  );

  server.registerTool(
    "audit_overview",
    {
      title: "Audit overview",
      description: `Cross-site overview: for each site, the latest audit score, pass/fail/warn counts, and date. ${SCOPE_NOTE}`,
      inputSchema: {},
      annotations: READ_ONLY,
    },
    async (_args, extra) => {
      const rows = await auditOverview(requireOrgId(extra));
      if (rows.length === 0) {
        return text("No sites to audit in this organization.");
      }
      const lines = rows.map((r) => {
        if (r.ranAt === null) {
          return `- ${r.name} (id: ${r.siteId}) — never audited`;
        }
        return `- ${r.name} (id: ${r.siteId}) — score ${r.score}/100 · pass ${r.pass} / fail ${r.fail} / warn ${r.warn} · audited on ${r.ranAt}`;
      });
      return text(`Site audits (${rows.length}):\n${lines.join("\n")}`);
    },
  );

  server.registerTool(
    "get_site_audit",
    {
      title: "Get site audit",
      description: `Latest detailed audit of a site (per-item verdicts + score). The site must belong to the organization. ${SCOPE_NOTE}`,
      inputSchema: {
        siteId: z.string().describe("The site id (see list_sites)"),
      },
      annotations: READ_ONLY,
    },
    async ({ siteId }, extra) => {
      const audit = await getSiteAudit(requireOrgId(extra), siteId);
      if (audit === null) return text("Site not found in this organization.");
      if (audit === "never-audited")
        return text("This site has never been audited.");
      const header = `Audit of ${audit.name} (id: ${audit.siteId}, ${audit.url ?? "no URL configured"})\nScore: ${audit.score}/100 · audited on ${audit.ranAt}\n`;
      const items = audit.results.map(
        (r) => `- [${r.status}] ${r.itemId}${r.detail ? ` — ${r.detail}` : ""}`,
      );
      return text(
        `${header}\nResults (${audit.results.length} items):\n${items.join("\n")}`,
      );
    },
  );

  server.registerTool(
    "run_site_audit",
    {
      title: "Run site audit",
      description: `Run a fresh audit of the site (real fetch of its public surface: home, robots.txt, sitemap, security.txt, llms.txt) and replace the stored audit. If the last audit is under 60 seconds old, the existing result is returned without re-fetching (cooldown). Use it to verify fixes. Note: EVERY MCP request counts toward your monthly quota, not only this tool call. ${SCOPE_NOTE}`,
      inputSchema: {
        siteId: z.string().describe("The site id (see list_sites)"),
      },
      // Replaces the stored audit but is not destructive; not idempotent
      // (re-running past the 60s cooldown re-fetches and overwrites).
      annotations: {
        readOnlyHint: false,
        destructiveHint: false,
        idempotentHint: false,
      },
    },
    async ({ siteId }, extra) => {
      const out = await runSiteAuditForOrg(requireOrgId(extra), siteId);
      if (out.kind === "not-found")
        return text("Site not found in this organization.");
      if (out.kind === "no-url")
        return text(`Site "${out.name}" has no URL configured — cannot audit.`);
      const { detail } = out;
      const note =
        out.kind === "cooldown"
          ? "Recent audit (under 60s) — existing result returned, the site was not re-fetched.\n\n"
          : "";
      const header = `${note}Audit of ${detail.name} (id: ${detail.siteId}, ${detail.url ?? "no URL configured"})\nScore: ${detail.score}/100 · audited on ${detail.ranAt}\n`;
      const items = detail.results.map(
        (r) => `- [${r.status}] ${r.itemId}${r.detail ? ` — ${r.detail}` : ""}`,
      );
      return text(
        `${header}\nResults (${detail.results.length} items):\n${items.join("\n")}`,
      );
    },
  );

  server.registerTool(
    "list_spec_topics",
    {
      title: "List spec topics",
      description:
        "List the Website Specification checklist topics (id, title, status, category, summary). Optional category filter. The spec is static and global — identical for every organization.",
      inputSchema: {
        category: z
          .string()
          .optional()
          .describe(
            "Filter by category (e.g. foundations, seo, accessibility)",
          ),
      },
      annotations: READ_ONLY,
    },
    async ({ category }) => {
      const topics = listSpecTopics(category);
      if (topics.length === 0) {
        return text(
          category ? `No topics for category "${category}".` : "No topics.",
        );
      }
      const lines = topics.map(
        (t) => `- ${t.id} · [${t.status}] ${t.title}\n    ${t.summary}`,
      );
      return text(`Topics (${topics.length}):\n${lines.join("\n")}`);
    },
  );

  server.registerTool(
    "search_spec_topics",
    {
      title: "Search spec topics",
      description:
        "Deterministic full-text search across the spec topics (title, slug, summary, body). Use it when you don't know the slug — otherwise use get_spec_topic. Returns the best matches with status, category, summary, and excerpts. The spec is static and global — identical for every organization.",
      inputSchema: {
        query: z
          .string()
          .min(1)
          .describe(
            'Free-text query (e.g. "content security policy"). Tokens under 2 characters are ignored.',
          ),
        limit: z
          .number()
          .int()
          .min(1)
          .max(25)
          .optional()
          .describe("Maximum number of results (default 5, max 25)."),
      },
      annotations: READ_ONLY,
    },
    async ({ query, limit }) => {
      const hits = searchSpecTopics(query, limit);
      if (hits.length === 0) return text(`No topic matches "${query}".`);
      const lines = hits.map((h) => {
        const parts = [
          `- ${h.id} · [${h.status}] ${h.title} (score ${h.score})`,
          `    ${h.summary}`,
        ];
        for (const ex of h.excerpts) parts.push(`    "${ex}"`);
        return parts.join("\n");
      });
      return text(`Results (${hits.length}):\n${lines.join("\n")}`);
    },
  );

  server.registerTool(
    "get_spec_topic",
    {
      title: "Get spec topic",
      description:
        "Full markdown body of a spec topic, identified by category + slug. The spec is static and global — identical for every organization.",
      inputSchema: {
        category: z.string().describe("The category (e.g. foundations)"),
        slug: z.string().describe("The topic slug (e.g. doctype)"),
      },
      annotations: READ_ONLY,
    },
    async ({ category, slug }) => {
      const page = getSpecTopic(category, slug);
      if (!page) return text("Topic not found.");
      return text(page.body);
    },
  );

  // ── Keyword reference + brief (planning prompt generator) ──────────────────
  // Read-only, org-scoped via requireOrgId. Resolve by `id` OR `term`; at least
  // one is required (enforced in-handler — the SDK schema can't express "oneOf").
  const keywordLookupSchema = {
    id: z
      .string()
      .optional()
      .describe("The keyword id. At least one of `id`/`term` is required."),
    term: z
      .string()
      .optional()
      .describe(
        "The exact keyword term. At least one of `id`/`term` is required.",
      ),
  };

  function normalizeLookup(args: {
    id?: string;
    term?: string;
  }): KeywordLookup | null {
    const id = args.id?.trim() || undefined;
    const term = args.term?.trim() || undefined;
    if (!id && !term) return null;
    return { id, term };
  }

  server.registerTool(
    "get_keyword",
    {
      title: "Get keyword",
      description: `Reference data for a keyword (resolved by id or term): term, country, intent, primary-source metrics (volume/kd/cpc), and the top-10 SERP competitors for its country. ${SCOPE_NOTE}`,
      inputSchema: keywordLookupSchema,
      annotations: READ_ONLY,
    },
    async (args, extra) => {
      const lookup = normalizeLookup(args);
      if (!lookup) return text("Provide at least `id` or `term`.");
      const ref = await getKeywordReference(requireOrgId(extra), lookup);
      if (!ref) return text("Keyword not found in this organization.");
      return text(JSON.stringify(ref, null, 2));
    },
  );

  server.registerTool(
    "get_keyword_brief",
    {
      title: "Get keyword brief",
      description: `YTG/Lumenia brief for a keyword (resolved by id or term), for the keyword's country: weightings (topTerms, 2/3-grams), entities, questions, objectives, intent. ${SCOPE_NOTE}`,
      inputSchema: keywordLookupSchema,
      annotations: READ_ONLY,
    },
    async (args, extra) => {
      const lookup = normalizeLookup(args);
      if (!lookup) return text("Provide at least `id` or `term`.");
      const result = await getKeywordBrief(requireOrgId(extra), lookup);
      if (!result.found) {
        return text(
          result.reason === "no-keyword"
            ? "Keyword not found in this organization."
            : `No YTG brief for this keyword${result.term ? ` ("${result.term}")` : ""}.`,
        );
      }
      return text(JSON.stringify(result.brief, null, 2));
    },
  );

  server.registerTool(
    "get_capabilities",
    {
      title: "Get capabilities",
      description:
        "Start here. Returns the available seo-insight MCP tools grouped by purpose (read / action / spec / keywords), this month's MCP usage (used / limit / remaining credits), and key usage notes. All data except usage is static; usage is scoped to the authenticated organization.",
      inputSchema: {},
      annotations: READ_ONLY,
    },
    async (_args, extra) => {
      const snapshot = await getOrgMcpQuotaSnapshot(requireOrgId(extra));
      const limitLabel =
        snapshot.limit === -1 ? "unlimited" : String(snapshot.limit);
      const remainingLabel =
        snapshot.limit === -1
          ? "unlimited"
          : String(Math.max(snapshot.limit - snapshot.used, 0));
      const body = [
        "seo-insight MCP — capabilities. Start with get_capabilities or list_sites.",
        "",
        "Read (organization site data):",
        "- list_sites — the org's sites (id, name, url)",
        "- audit_overview — latest score + pass/fail/warn per site",
        "- get_site_audit — the latest detailed audit of one site",
        "",
        "Action:",
        "- run_site_audit — re-fetch a site and replace its stored audit (60s cooldown)",
        "",
        "Spec (static, global Website Specification — same for every org):",
        "- list_spec_topics — the checklist topics",
        "- search_spec_topics — full-text search when you don't know the slug",
        "- get_spec_topic — full markdown body of one topic",
        "",
        "Keywords (planning, org-scoped):",
        "- get_keyword — reference data + SERP top-10",
        "- get_keyword_brief — YTG/Lumenia brief",
        "",
        `Monthly MCP usage (${snapshot.month}): ${snapshot.used}/${limitLabel} requests used · ${remainingLabel} remaining · ${snapshot.credits} one-off credits · resets ${snapshot.resetMonth}.`,
        "",
        "Notes:",
        "- EVERY MCP request counts toward your monthly quota — not only tool calls (initialize, tools/list, etc. all count).",
        "- run_site_audit has a 60s cooldown: a re-run within 60s returns the stored audit without re-fetching.",
      ].join("\n");
      return text(body);
    },
  );
}

/** Build a standalone MCP server with the read-only tools registered. */
export function buildMcpServer(): McpServer {
  const server = new McpServer(MCP_SERVER_INFO);
  registerMcpTools(server);
  return server;
}
