import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

/**
 * Doctrine test — pins the per-identity-label vector-index coverage
 * invariant so future identity labels added to schema-base.md cannot
 * silently drift into BM25-only semantic ranking (Task 483).
 *
 * Pre-Task-483 the :Organization label carried `embedding` from the
 * ingest writer but no vector index, so hybrid() fell through to
 * BM25-only for Organization-only queries (Task 478 made the
 * fallback honest). Without a coverage gate, the next analogous gap
 * — a new identity label written with embeddings but no index —
 * would silently degrade the same way.
 *
 * Doctrine: every identity label declared in schema-base.md's
 * `## Node Types` table is either (a) covered by a
 * `CREATE VECTOR INDEX … FOR (n:<Label>) ON (n.embedding)` declaration
 * in schema.cypher, or (b) explicitly listed in
 * `BM25_ONLY_IDENTITY_LABELS` below with a one-line rationale. A new
 * identity label that satisfies neither condition fails this test
 * and points the author at the decision they have to make.
 */

const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_CYPHER_PATH = resolve(__dirname, "../../../../neo4j/schema.cypher");
const SCHEMA_BASE_PATH = resolve(
  __dirname,
  "../../../../plugins/memory/references/schema-base.md",
);

/**
 * Identity labels documented in schema-base.md that legitimately rank
 * via BM25 only — no vector index is declared, and none is intended.
 *
 * The rationale per label is the doctrinal record: it answers the
 * question a future reader will ask ("why doesn't <Label> get
 * semantic ranking?") without forcing them to re-derive it from
 * scratch. Removing a label from this set without adding the
 * corresponding `CREATE VECTOR INDEX` (or vice versa) fails the
 * test below.
 */
const BM25_ONLY_IDENTITY_LABELS: ReadonlyMap<string, string> = new Map([
  // Structured business records — looked up by deterministic key
  // (orderNumber / confirmationNumber / address), not by semantic
  // similarity. No `body`-like free-text writer.
  ["Order", "structured business record; deterministic key lookup"],
  ["Invoice", "structured business record; deterministic key lookup"],
  ["PostalAddress", "structured fields only; no free-text writer"],
  ["OpeningHoursSpecification", "structured fields only"],
  // Identity nodes accessed via composite key, never semantic search.
  ["UserProfile", "always queried by (accountId, userId)"],
  ["AccessGrant", "always queried by (contactValue, agentSlug, accountId) or magicToken"],
  // CDM-only labels — declared in schema-base for vertical extension
  // hooks; no writer in the current platform.
  ["Lead", "CDM-only declaration; no platform writer"],
  ["Case", "CDM-only declaration; no platform writer"],
  // Catalogue / branding nodes — small fixed populations, accessed by
  // accountId + purpose / name, not by semantic similarity.
  ["FAQPage", "container node; child :Question nodes carry semantic content"],
  ["ImageObject", "binary asset; alt-text not currently indexed"],
  // Legacy / retired writers (kept for read compatibility).
  ["CloudflareTunnel", "legacy projection (Task 288); writer retired"],
  ["CloudflareHostname", "legacy projection (Task 288); writer retired"],
  ["WhatsAppConversation", "legacy; superseded by :ConversationArchive (Task 397)"],
  ["WhatsAppMessage", "legacy; superseded by :ConversationArchive (Task 397)"],
  // Authored-content history — looked up by accountId + datePublished
  // / runAt; semantic search lives on the parent or downstream report.
  ["SocialPost", "authored-content record; lookup by accountId + datePublished"],
  ["AEOAudit", "audit-history record; lookup by accountId + runAt"],
  // Voice-mirror records — keyed by (accountId, adminUserId).
  ["VoiceProfile", "always queried by (accountId, adminUserId)"],
  ["VoiceEdit", "always queried by parent VoiceProfile"],
]);

interface VectorIndexDeclaration {
  name: string;
  label: string;
}

/**
 * Extract every `CREATE VECTOR INDEX <name> IF NOT EXISTS FOR (<alias>:<Label>) ON (<alias>.embedding)`
 * declaration from schema.cypher. The alias and label vary across
 * declarations (`p:Person`, `b:LocalBusiness`, `o:Organization` …),
 * so the regex captures both and we keep only the label.
 *
 * Line comments are stripped first so a commented-out historical
 * declaration cannot inject a phantom label.
 */
function parseVectorIndexes(cypher: string): VectorIndexDeclaration[] {
  const stripped = cypher.replace(/^\s*\/\/.*$/gm, "");
  const decls: VectorIndexDeclaration[] = [];
  const re = /CREATE\s+VECTOR\s+INDEX\s+(\w+)\s+IF\s+NOT\s+EXISTS\s+FOR\s+\(\w+:(\w+)\)/g;
  for (const m of stripped.matchAll(re)) {
    decls.push({ name: m[1], label: m[2] });
  }
  return decls;
}

/**
 * Extract every documented identity label from schema-base.md's
 * `## Node Types` table — column 2 of each table row, stripped of
 * backticks and any trailing `{property:'value'}` qualifier.
 *
 * Parser scope: the first markdown table that appears under
 * `## Node Types`. Stops at the first non-table line (blank line or
 * a paragraph), so explanatory prose under the table cannot leak
 * pseudo-labels.
 */
function parseIdentityLabels(md: string): Set<string> {
  const heading = md.indexOf("## Node Types");
  if (heading < 0) {
    throw new Error(
      "parseIdentityLabels: could not find '## Node Types' heading in schema-base.md. " +
        "Schema-base layout drifted; update the doctrine test.",
    );
  }
  const after = md.slice(heading);
  const lines = after.split("\n");
  const labels = new Set<string>();
  let inTable = false;
  for (const line of lines) {
    if (line.startsWith("|")) {
      // Header row + separator row both contain `|`; skip them.
      if (line.includes("Entity") && line.includes("Neo4j Label")) {
        inTable = true;
        continue;
      }
      if (/^\|\s*-+/.test(line)) continue;
      if (!inTable) continue;
      const cols = line.split("|").map((c) => c.trim());
      // `| Entity | Neo4j Label | ... |` → cols = ["", "Entity", "Neo4j Label", ..., ""]
      const labelCell = cols[2];
      if (!labelCell) continue;
      // Strip backticks and trailing `{...}` property qualifier.
      const cleaned = labelCell.replace(/`/g, "").replace(/\s*\{.*\}\s*$/, "").trim();
      // Defensive: only accept PascalCase identifiers. Anything else
      // (multi-label decoration, prose, etc.) is skipped — the test's
      // job is to gate identity-label coverage, not to police table
      // formatting.
      if (/^[A-Z][A-Za-z0-9]*$/.test(cleaned)) labels.add(cleaned);
    } else if (inTable && line.trim().length === 0) {
      // Blank line ends the table.
      break;
    }
  }
  return labels;
}

describe("vector-index coverage — every identity label is vector-indexed or explicitly BM25-only (Task 483)", () => {
  const cypher = readFileSync(SCHEMA_CYPHER_PATH, "utf-8");
  const schemaBase = readFileSync(SCHEMA_BASE_PATH, "utf-8");

  const declarations = parseVectorIndexes(cypher);
  const vectorIndexed = new Set(declarations.map((d) => d.label));
  const identityLabels = parseIdentityLabels(schemaBase);

  it("parsers extracted non-trivial sets — sanity floor", () => {
    expect(declarations.length).toBeGreaterThanOrEqual(15);
    expect(identityLabels.size).toBeGreaterThanOrEqual(20);
  });

  it("every identity label in schema-base.md is either vector-indexed or in BM25_ONLY_IDENTITY_LABELS", () => {
    const uncovered: string[] = [];
    for (const label of identityLabels) {
      if (vectorIndexed.has(label)) continue;
      if (BM25_ONLY_IDENTITY_LABELS.has(label)) continue;
      uncovered.push(label);
    }
    // Failure here means a new identity label was added to schema-base.md
    // without either declaring a vector index in schema.cypher or adding
    // it to BM25_ONLY_IDENTITY_LABELS with a one-line rationale. Pick
    // one — the silent gap is the failure mode.
    expect(uncovered).toEqual([]);
  });

  it("BM25_ONLY_IDENTITY_LABELS does not overlap with vector-indexed labels", () => {
    const overlap: string[] = [];
    for (const label of BM25_ONLY_IDENTITY_LABELS.keys()) {
      if (vectorIndexed.has(label)) overlap.push(label);
    }
    // A label cannot legitimately appear in both sets: the allowlist
    // documents the *absence* of a vector index. If the index was
    // added later, remove the label from the allowlist.
    expect(overlap).toEqual([]);
  });

  it(":Organization carries a vector index (Task 483 acceptance)", () => {
    // Pin the load-bearing assertion of this task explicitly so a
    // regression that re-removed the declaration would fail with a
    // pointed message rather than as a generic coverage gap.
    expect(vectorIndexed.has("Organization")).toBe(true);
    const orgDecl = declarations.find((d) => d.label === "Organization");
    expect(orgDecl?.name).toBe("organization_embedding");
  });
});
