// src/lib/mcp/tools.ts — the read-only MCP tool implementations, as plain async
// functions taking the authenticated `orgId` + input. The transport-level server
// (server.ts) registers thin wrappers around these; tests drive them directly.
// EVERY function is org-scoped: nothing crosses org boundaries.
import "server-only";
import { and, desc, eq, inArray } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { keywords, siteAudits, sites } from "@/lib/db/domain-schema";
import type { SerpCompetitor, SerpSnapshot } from "@/lib/keywords/serp-types";
import type {
  KeywordSource,
  SearchIntent,
  SourceMetrics,
} from "@/lib/keywords/types";
import type { KeywordBrief } from "@/lib/keywords/ytg-types";
import { getProjectsForOrg, getSiteById } from "@/lib/sites/queries";
import { persistSiteAudit } from "@/lib/spec/audit/persist";
import { type AuditResultRow, runSiteAudit } from "@/lib/spec/audit/run";
import { fetchSiteSnapshot } from "@/lib/spec/audit/snapshot";
import { CHECKLIST } from "@/lib/spec/checklist-data";
import { getSpecPage } from "@/lib/spec/spec-content";

/** A site of the org, as exposed by `list_sites`. */
export type McpSite = { id: string; name: string; url: string | null };

/** `list_sites` — the org's sites (id, name, url). */
export async function listSites(orgId: string): Promise<McpSite[]> {
  const projects = await getProjectsForOrg(orgId);
  return projects.map((s) => ({ id: s.id, name: s.name, url: s.url || null }));
}

/** One row of the cross-site overview. */
export type AuditOverviewRow = {
  siteId: string;
  name: string;
  url: string | null;
  score: number | null;
  ranAt: string | null;
  pass: number;
  fail: number;
  warn: number;
};

/** Latest `site_audits` row for a site, or null. */
async function latestAuditRow(siteId: string) {
  const [row] = await db
    .select()
    .from(siteAudits)
    .where(eq(siteAudits.siteId, siteId))
    .orderBy(desc(siteAudits.ranAt))
    .limit(1);
  return row ?? null;
}

/**
 * `audit_overview` — per org site: latest score, pass/fail/warn, ranAt.
 * One query for the audits (scalar columns only — `results` jsonb is never
 * loaded here) instead of one lookup per site.
 */
export async function auditOverview(
  orgId: string,
): Promise<AuditOverviewRow[]> {
  const projects = await getProjectsForOrg(orgId);
  if (projects.length === 0) return [];

  const rows = await db
    .select({
      siteId: siteAudits.siteId,
      score: siteAudits.score,
      ranAt: siteAudits.ranAt,
      passCount: siteAudits.passCount,
      failCount: siteAudits.failCount,
      warnCount: siteAudits.warnCount,
    })
    .from(siteAudits)
    .where(
      inArray(
        siteAudits.siteId,
        projects.map((site) => site.id),
      ),
    );
  const bySiteId = new Map(rows.map((row) => [row.siteId, row]));

  return projects.map((site): AuditOverviewRow => {
    const row = bySiteId.get(site.id);
    return {
      siteId: site.id,
      name: site.name,
      url: site.url || null,
      score: row?.score ?? null,
      ranAt: row?.ranAt.toISOString() ?? null,
      pass: row?.passCount ?? 0,
      fail: row?.failCount ?? 0,
      warn: row?.warnCount ?? 0,
    };
  });
}

export type SiteAuditDetail = {
  siteId: string;
  name: string;
  url: string | null;
  score: number;
  ranAt: string;
  results: AuditResultRow[];
};

/**
 * `get_site_audit` — the latest detailed audit of a site. Verifies the site
 * belongs to `orgId` first (returns null = "site introuvable" for cross-org or
 * unknown ids). Returns null too when the site exists but has never been audited.
 */
export async function getSiteAudit(
  orgId: string,
  siteId: string,
): Promise<SiteAuditDetail | null | "never-audited"> {
  const site = await getSiteById(siteId, orgId);
  if (!site) return null; // cross-org or unknown → caller reports "site introuvable"
  const row = await latestAuditRow(siteId);
  if (!row) return "never-audited";
  return {
    siteId: site.id,
    name: site.name,
    url: site.url || null,
    score: row.score,
    ranAt: row.ranAt.toISOString(),
    results: row.results,
  };
}

export type RunSiteAuditOutcome =
  | { kind: "not-found" }
  | { kind: "no-url"; name: string }
  | { kind: "ran"; detail: SiteAuditDetail }
  | { kind: "cooldown"; detail: SiteAuditDetail };

/** Fenêtre pendant laquelle un re-run renvoie l'audit existant (anti-boucle agent). */
export const RUN_AUDIT_COOLDOWN_MS = 60_000;

/**
 * `run_site_audit` — audite le site (fetch réel de sa surface publique) et
 * remplace l'audit stocké (une ligne par site). Si le dernier audit date de
 * moins de RUN_AUDIT_COOLDOWN_MS, renvoie l'existant sans re-fetcher : un
 * agent qui boucle ne martèle ni le site cible ni la base.
 */
export async function runSiteAuditForOrg(
  orgId: string,
  siteId: string,
  fetchImpl: typeof fetch = fetch,
): Promise<RunSiteAuditOutcome> {
  const site = await getSiteById(siteId, orgId);
  if (!site) return { kind: "not-found" };
  if (!site.url) return { kind: "no-url", name: site.name };

  const toDetail = (row: {
    score: number;
    ranAt: Date;
    results: SiteAuditDetail["results"];
  }): SiteAuditDetail => ({
    siteId: site.id,
    name: site.name,
    url: site.url || null,
    score: row.score,
    ranAt: row.ranAt.toISOString(),
    results: row.results,
  });

  const existing = await latestAuditRow(siteId);
  if (
    existing &&
    Date.now() - existing.ranAt.getTime() < RUN_AUDIT_COOLDOWN_MS
  ) {
    return { kind: "cooldown", detail: toDetail(existing) };
  }

  const snapshot = await fetchSiteSnapshot(site.url, fetchImpl);
  const result = runSiteAudit(snapshot, {
    targetCountries: site.targetCountries ?? [],
  });
  await persistSiteAudit(siteId, result);
  const row = await latestAuditRow(siteId);
  if (!row) throw new Error("audit row missing after persist"); // impossible
  return { kind: "ran", detail: toDetail(row) };
}

export type SpecTopic = {
  id: string;
  title: string;
  status: string;
  category: string;
  summary: string;
};

/**
 * `list_spec_topics` — the checklist topics (id, title, status, category,
 * summary). The spec is static/global (not org data); `category` filters them.
 */
export function listSpecTopics(category?: string): SpecTopic[] {
  const topics: SpecTopic[] = [];
  for (const cat of CHECKLIST) {
    if (category && cat.id !== category) continue;
    for (const item of cat.items) {
      topics.push({
        id: item.id,
        title: item.title,
        status: item.status,
        category: item.category,
        summary: item.description,
      });
    }
  }
  return topics;
}

export type SpecTopicPage = { title: string; body: string } | null;

/** `get_spec_topic` — full markdown body of a cloned spec page, or null. */
export function getSpecTopic(category: string, slug: string): SpecTopicPage {
  const page = getSpecPage(category, slug);
  if (!page) return null;
  return { title: page.frontmatter.title, body: page.body };
}

// ── Keyword reference + brief lookups (planning prompt generator) ────────────
// Read-only, org-scoped: a keyword is only ever resolved through the join
// keywords→sites WHERE sites.org_id = :orgId, so a key can never reach another
// org's keyword (whether resolving by id or by term).

/** Lookup input — at least one of `id`/`term` must be provided. */
export interface KeywordLookup {
  id?: string;
  term?: string;
}

/** The minimal keyword row the lookup needs (a lean projection, no heavy jsonb
 *  beyond what the two tools surface). Exported for the pure resolver + tests. */
export interface KeywordLookupRow {
  id: string;
  siteId: string;
  term: string;
  lang: string;
  country: string;
  intent: SearchIntent[];
  primarySource: KeywordSource;
  metrics: Partial<Record<KeywordSource, SourceMetrics>>;
  briefs: Record<string, KeywordBrief>;
  serp: Record<string, SerpSnapshot>;
}

/** Reference data returned by `getKeyword`. */
export interface KeywordReference {
  id: string;
  term: string;
  lang: string;
  country: string;
  intent: SearchIntent[];
  primarySource: KeywordSource;
  /** vol/kd/cpc (+growth) of the primary source, or null if that source has no
   *  metrics on this keyword. */
  metrics: SourceMetrics | null;
  /** Top-10 SERP competitors for the keyword's country, or [] if none imported. */
  serp: SerpCompetitor[];
}

/**
 * Pure resolver: pick the matching row for a lookup. Mirrors the SQL `WHERE`
 * (id wins over term; term matches the literal value). Extracted so the
 * org-scoping + resolution logic is unit-testable without a DB (the candidate
 * set passed in is ALREADY org-filtered by the caller's query). Returns null
 * when nothing matches.
 */
export function resolveKeywordRow<T extends { id: string; term: string }>(
  rows: T[],
  lookup: KeywordLookup,
): T | null {
  if (lookup.id) {
    return rows.find((r) => r.id === lookup.id) ?? null;
  }
  if (lookup.term) {
    return rows.find((r) => r.term === lookup.term) ?? null;
  }
  return null;
}

/** Project a resolved row to the `getKeyword` reference shape. */
export function toKeywordReference(row: KeywordLookupRow): KeywordReference {
  return {
    id: row.id,
    term: row.term,
    lang: row.lang,
    country: row.country,
    intent: row.intent,
    primarySource: row.primarySource,
    metrics: row.metrics[row.primarySource] ?? null,
    serp: row.serp[row.country]?.competitors ?? [],
  };
}

const keywordLookupSelect = {
  id: keywords.id,
  siteId: keywords.siteId,
  term: keywords.term,
  lang: keywords.lang,
  country: keywords.country,
  intent: keywords.intent,
  primarySource: keywords.primarySource,
  metrics: keywords.metrics,
  briefs: keywords.briefs,
  serp: keywords.serp,
} as const;

/**
 * Fetch the org-scoped candidate rows for a lookup, then resolve. The DB query
 * joins keywords→sites on `sites.org_id = :orgId`, so cross-org ids/terms never
 * enter the candidate set. Returns null on no match or empty lookup.
 */
async function findKeyword(
  orgId: string,
  lookup: KeywordLookup,
): Promise<KeywordLookupRow | null> {
  if (!lookup.id && !lookup.term) return null;
  const cond = lookup.id
    ? eq(keywords.id, lookup.id)
    : eq(keywords.term, lookup.term as string);
  const rows = await db
    .select(keywordLookupSelect)
    .from(keywords)
    .innerJoin(sites, eq(keywords.siteId, sites.id))
    .where(and(eq(sites.orgId, orgId), cond))
    .limit(5);
  return resolveKeywordRow(rows, lookup);
}

/**
 * `getKeyword` — reference data for a keyword in the caller's org: term,
 * country, intent, primary-source metrics (vol/kd/cpc), and the SERP top-10 for
 * its country. Resolves by id or by term. Returns null when not found (or when
 * the keyword belongs to another org — same null path).
 */
export async function getKeywordReference(
  orgId: string,
  lookup: KeywordLookup,
): Promise<KeywordReference | null> {
  const row = await findKeyword(orgId, lookup);
  return row ? toKeywordReference(row) : null;
}

/** Result of `get_keyword_brief`: the YTG brief, or a discriminated "no brief" reason. */
export type KeywordBriefResult =
  | { found: true; brief: KeywordBrief }
  | { found: false; reason: "no-keyword" | "no-brief"; term?: string };

/**
 * `get_keyword_brief` — the `KeywordBrief` (YTG grammages) for the keyword's
 * country. Org-scoped via the same join as `get_keyword`. Returns `{ found:false }` with
 * a reason when the keyword or its country brief is absent.
 */
export async function getKeywordBrief(
  orgId: string,
  lookup: KeywordLookup,
): Promise<KeywordBriefResult> {
  const row = await findKeyword(orgId, lookup);
  if (!row) return { found: false, reason: "no-keyword" };
  const brief = row.briefs[row.country];
  if (!brief) return { found: false, reason: "no-brief", term: row.term };
  return { found: true, brief };
}
