import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { FULLTEXT_INDEX_ADMIN, FULLTEXT_INDEX_PUBLIC, isPublicOnlyScope, pickFulltextIndex } from "../index.js";

/**
 * Doctrine test — pins the universal-coverage invariant for the
 * fulltext index so future schema edits cannot silently re-narrow.
 *
 * Doctrine: search is "find any node in my graph that mentions this
 * term" — there is no allowlist of search-eligible labels. A narrow
 * index (covering only `KnowledgeDocument | Section | Chunk`) silently
 * returns zero hits for Person/Organization/Task/Conversation/etc.
 * queries regardless of term.
 *
 * This test reads `platform/neo4j/schema.cypher` (the schema source of truth)
 * and `platform/ui/app/lib/graph-labels.ts` (the canvas label registry) at
 * runtime, parses both, and asserts:
 *
 *   1. The fulltext index name in `schema.cypher` matches the
 *      `FULLTEXT_INDEX_NAME` constant in the lib. Drift between the two
 *      breaks BM25 silently — the lib queries an index name that doesn't
 *      exist, hits the graceful-fallback `[]` branch, and search returns
 *      no BM25 hits.
 *   2. The label union in the index ⊇ union of:
 *        - `Object.keys(GRAPH_LABEL_COLOURS)` (canvas-registered labels)
 *        - every label declared via `CREATE CONSTRAINT|INDEX FOR (n:Label)`
 *          in `schema.cypher` (write-targeted labels)
 *      minus a small explicit excluded set (`Trashed`, `GraphPreference`)
 *      that carry no operator-meaningful textual content.
 *   3. The property union in the index ⊇ the canonical text-property list
 *      (the properties that writers across the codebase assign to nodes).
 *      New writers extending the canon must add their property here.
 *
 * Failing assertions point the next person at exactly the schema drift to
 * close — not "search is mysteriously broken."
 */

const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_PATH = resolve(__dirname, "../../../../neo4j/schema.cypher");
const LABELS_PATH = resolve(__dirname, "../../../graph-style/src/index.ts");

/**
 * Labels declared in `schema.cypher` or `GRAPH_LABEL_COLOURS` that legitimately
 * carry no searchable textual content. The doctrine test allows these to be
 * absent from the fulltext index union without failing.
 *
 *   - `Trashed`              — soft-delete flag label; nodes carrying it are
 *                               already excluded from every search via
 *                               `notTrashed()` in the lib.
 *   - `GraphPreference`      — per-(accountId, userId) UI metadata for /graph;
 *                               carries no textual content, never operator-visible
 *                               as a search hit.
 *   - `Email`                — legacy label retired by Task 321; email threads
 *                               now land on `:KnowledgeDocument {source:'email'}`.
 *                               The colour entry survives for legacy nodes; no
 *                               new :Email writes occur.
 *   - `Session`, `AnonVisitor`, `PageView`, `Click`, `ScrollMilestone`
 *                            — visitor analytics nodes (Task 357). Identity
 *                               or event roll-ups; no operator-meaningful free
 *                               text. The writer (`visitor-event.ts`) sets
 *                               only ids, timestamps, and url fields.
 *   - `Recommendation`       — visitor-graph label declared in schema but no
 *                               writer in the platform; no documented text.
 *   - `Component`            — admin-resume rehydration only (legacy pre-066
 *                               admin surface); body is a JSON-encoded
 *                               component payload, not free-text.
 *   - `CloudflareTunnel`, `CloudflareHostname`
 *                            — legacy graph projections of cloudflared state.
 *                               Task 288 retired the writer; nodes carry
 *                               connector/hostname identifiers only.
 */
const EXCLUDED_FROM_INDEX = new Set([
  "Trashed",
  "GraphPreference",
  "Email",
  "Session",
  "AnonVisitor",
  "PageView",
  "Click",
  "ScrollMilestone",
  "Recommendation",
  "Component",
  "CloudflareTunnel",
  "CloudflareHostname",
]);

// Labels that carry searchable text but are required in the ADMIN index only,
// never the PUBLIC one. Distinct from EXCLUDED_FROM_INDEX (no searchable text
// at all): these DO hold text and stay enforced in the admin union, but their
// content must not be fulltext-matchable through the public-scope index.
//   - FileArtifact (Task 514): physical files on disk, surfaced through the
//     admin `/data` search box. Indexed in entity_search_admin; deliberately
//     absent from entity_search_public so a public-scope read never ranks on
//     an admin's on-disk file contents.
const ADMIN_ONLY_LABELS = new Set(["FileArtifact"]);

/**
 * Canonical text-property list — every property the platform's writers
 * assign that the operator might search for. Extending the canon in a new
 * writer means extending this list AND the schema's `ON EACH [...]` clause.
 * Discovered via codebase sweep across `memory-write`,
 * `memory-archive-write`, `memory-ingest`, `email-store`, `neo4j-store`,
 * `workflow-create`, `tool-call-writer`.
 */
const CANONICAL_TEXT_PROPERTIES = [
  // Generic
  "name", "title", "summary", "body", "content", "text", "description",
  "headline", "abstract", "note", "label", "value", "message", "preview",
  "tagline",
  // Person
  "firstName", "lastName", "givenName", "familyName", "email",
  // Person denormalised current-job title, written by
  // `memory-archive-write` linkedin-connections handler from
  // `[:WORKS_FOR].title` so BM25 reaches it. Distinct from generic
  // `title` (above) which means something different per label.
  "currentTitle",
  // Email
  "subject", "bodyPreview", "fromName", "fromAddress", "screeningReason",
  // EmailAccount
  "agentAddress",
  // Credential
  "authority",
  // AccessGrant
  "contactValue",
  // ToolCall
  "toolName",
  // Agent — public-agent projection. `displayName` mirrors
  // config.json; `slug` is the directory-name identifier; `role` is the
  // discriminator carried on the four owned :KnowledgeDocument projections
  // ('identity' | 'soul' | 'knowledge' | 'knowledge-summary'). Adding any
  // new agent-side text property to the projector requires extending both
  // the schema's ON EACH list and this canon, otherwise BM25 silently
  // misses operator queries that match the new field.
  "displayName", "slug", "role",
];

interface IndexDeclaration {
  name: string;
  labels: Set<string>;
  properties: Set<string>;
}

/**
 * Extract the `CREATE FULLTEXT INDEX entity_search ... ;` block from
 * `schema.cypher` and parse its label and property sets.
 *
 * Uses a paren-balanced scan from the matched `CREATE FULLTEXT INDEX <name>`
 * to the next semicolon — survives whitespace/comment/line-break formatting
 * drift that a single monolithic regex would not. Failing here means the
 * declaration's shape diverged from `CREATE FULLTEXT INDEX <name> IF NOT EXISTS
 * FOR (n:A|B|...) ON EACH [n.x, n.y, ...];` in a way the parser does not
 * recognise — extend this function before the test.
 */
function parseFulltextIndex(rawCypher: string, indexName: string): IndexDeclaration {
  // Strip line comments first so a commented-out historical declaration
  // does not fool the extractor into parsing the comment instead of the
  // live declaration.
  const cypher = stripLineComments(rawCypher);
  const declStart = cypher.search(
    new RegExp(`CREATE\\s+FULLTEXT\\s+INDEX\\s+${indexName}\\b`, "m"),
  );
  if (declStart < 0) {
    throw new Error(
      `parseFulltextIndex: could not find 'CREATE FULLTEXT INDEX ${indexName}' in schema.cypher. ` +
      `Drift between schema and FULLTEXT_INDEX_NAME constant in graph-search/src/index.ts.`,
    );
  }
  const declEnd = cypher.indexOf(";", declStart);
  if (declEnd < 0) {
    throw new Error(
      `parseFulltextIndex: no terminating ';' after 'CREATE FULLTEXT INDEX ${indexName}' — schema.cypher is malformed.`,
    );
  }
  const block = cypher.slice(declStart, declEnd);

  const forMatch = block.match(/FOR\s+\(n:([^\)]+)\)/);
  if (!forMatch) {
    throw new Error(
      `parseFulltextIndex: could not extract FOR (n:...) clause from index ${indexName}. Block: ${block.slice(0, 200)}...`,
    );
  }
  const labels = new Set(
    forMatch[1]
      .split("|")
      .map((s) => s.trim())
      .filter((s) => s.length > 0),
  );

  const onEachMatch = block.match(/ON\s+EACH\s+\[([^\]]+)\]/);
  if (!onEachMatch) {
    throw new Error(
      `parseFulltextIndex: could not extract ON EACH [...] clause from index ${indexName}. Block: ${block.slice(0, 200)}...`,
    );
  }
  const properties = new Set(
    onEachMatch[1]
      .split(",")
      .map((s) => s.trim().replace(/^n\./, ""))
      .filter((s) => s.length > 0),
  );

  return { name: indexName, labels, properties };
}

/**
 * Strip Cypher `// line comments` before regex-parsing so an example or
 * migration note inside a comment block — e.g. `// previously: CREATE
 * INDEX FOR (g:Goal) ON (g.embedding)` — does not inject a phantom label
 * into the schema-declared set. Block comments `/* ... *​/` are not
 * stripped because schema.cypher does not use them today.
 */
function stripLineComments(cypher: string): string {
  return cypher.replace(/^\s*\/\/.*$/gm, "");
}

/**
 * Extract every label declared in `schema.cypher` via `FOR (alias:Label)`.
 * Each `CREATE CONSTRAINT|INDEX|VECTOR INDEX|FULLTEXT INDEX` clause that
 * targets a label produces one entry. The fulltext index itself uses
 * `FOR (n:A|B|...)` — we extract the union of all alternatives.
 */
function parseSchemaDeclaredLabels(cypher: string): Set<string> {
  const labels = new Set<string>();
  const stripped = stripLineComments(cypher);
  const matches = stripped.matchAll(/FOR\s+\(\w+:([^\)]+)\)/g);
  for (const m of matches) {
    for (const label of m[1].split("|")) {
      const trimmed = label.trim();
      // Filter out anything that doesn't look like a Neo4j label (defensive
      // against future schema patterns that might inject non-label tokens).
      if (/^[A-Z][A-Za-z0-9]*$/.test(trimmed)) {
        labels.add(trimmed);
      }
    }
  }
  return labels;
}

/**
 * Extract `Object.keys(GRAPH_LABEL_COLOURS)` from `graph-labels.ts` by
 * matching the export's object-literal keys. Tolerates trailing commas,
 * inline comments between keys, and leading whitespace.
 */
function parseGraphLabelColours(source: string): Set<string> {
  const exportMatch = source.match(
    /export\s+const\s+GRAPH_LABEL_COLOURS\s*:\s*[^=]+=\s*\{([\s\S]+?)\n\}/m,
  );
  if (!exportMatch) {
    throw new Error(
      "parseGraphLabelColours: could not find 'export const GRAPH_LABEL_COLOURS' object literal in graph-labels.ts.",
    );
  }
  const labels = new Set<string>();
  // Match keys that are bare identifiers (camelCase or PascalCase). Skip
  // commented-out lines and string-quoted keys (none today, but defensive).
  for (const m of exportMatch[1].matchAll(/^\s*([A-Z][A-Za-z0-9]*)\s*:/gm)) {
    labels.add(m[1]);
  }
  return labels;
}

describe("entity_search fulltext indexes — universal-coverage + Task 416 field-split doctrine", () => {
  const schemaCypher = readFileSync(SCHEMA_PATH, "utf-8");
  const labelsSource = readFileSync(LABELS_PATH, "utf-8");

  const adminDecl = parseFulltextIndex(schemaCypher, FULLTEXT_INDEX_ADMIN);
  const publicDecl = parseFulltextIndex(schemaCypher, FULLTEXT_INDEX_PUBLIC);
  const schemaDeclaredLabels = parseSchemaDeclaredLabels(schemaCypher);
  const colourLabels = parseGraphLabelColours(labelsSource);

  const requiredLabels = new Set<string>();
  for (const l of colourLabels) if (!EXCLUDED_FROM_INDEX.has(l)) requiredLabels.add(l);
  for (const l of schemaDeclaredLabels) if (!EXCLUDED_FROM_INDEX.has(l)) requiredLabels.add(l);

  // The single `entity_search` index that the Task 416 split replaces must
  // be explicitly dropped, not left behind on existing deployments —
  // otherwise it keeps populating from writes and consumes disk for no
  // reader.
  it("schema drops the single `entity_search` index that the split replaces", () => {
    // Neo4j's `DROP INDEX <name> IF EXISTS` is universal across index types
    // (fulltext, vector, btree); there is no `DROP FULLTEXT INDEX` variant.
    expect(schemaCypher).toMatch(/DROP\s+INDEX\s+entity_search\s+IF\s+EXISTS\b/);
  });

  for (const [label, decl, expectedField, forbiddenField] of [
    ["admin", adminDecl, "compiledTruth", "compiledTruthPublic"],
    ["public", publicDecl, "compiledTruthPublic", "compiledTruth"],
  ] as const) {
    describe(`${label} index (${decl.name})`, () => {
      it("name matches the lib constant", () => {
        expect(decl.name).toBe(label === "admin" ? FULLTEXT_INDEX_ADMIN : FULLTEXT_INDEX_PUBLIC);
      });

      // Task 421 closed the pre-existing schema drift: the index union now
      // covers every operator-meaningful label registered in
      // GRAPH_LABEL_COLOURS and every schema-declared label, with the
      // exclusion list above enumerating the labels that legitimately
      // carry no searchable text.
      it("labels ⊇ Object.keys(GRAPH_LABEL_COLOURS) − {excluded} (Task 421)", () => {
        const missing = [...colourLabels]
          .filter((l) => !EXCLUDED_FROM_INDEX.has(l))
          .filter((l) => (label === "public" ? !ADMIN_ONLY_LABELS.has(l) : true))
          .filter((l) => !decl.labels.has(l));
        expect(missing).toEqual([]);
      });

      it("labels ⊇ every schema-declared label − {excluded} (Task 421)", () => {
        const missing = [...schemaDeclaredLabels]
          .filter((l) => !EXCLUDED_FROM_INDEX.has(l))
          .filter((l) => (label === "public" ? !ADMIN_ONLY_LABELS.has(l) : true))
          .filter((l) => !decl.labels.has(l));
        expect(missing).toEqual([]);
      });

      it("properties ⊇ canonical text-property list", () => {
        const missing = CANONICAL_TEXT_PROPERTIES.filter(
          (p) => !decl.properties.has(p),
        );
        expect(missing).toEqual([]);
      });

      it(`carries ${expectedField} and not ${forbiddenField} (Task 416 split)`, () => {
        expect(decl.properties.has(expectedField)).toBe(true);
        expect(decl.properties.has(forbiddenField)).toBe(false);
      });

      it("sanity floor on parsed size", () => {
        expect(decl.labels.size).toBeGreaterThanOrEqual(30);
        expect(decl.properties.size).toBeGreaterThanOrEqual(20);
      });
    });
  }

  it("required-label set is non-trivial — sanity check that the parsers ran", () => {
    expect(requiredLabels.size).toBeGreaterThanOrEqual(30);
  });
});

describe("Task 416 index picker", () => {
  it("returns the public index for the exact public-only scope", () => {
    expect(pickFulltextIndex(["public"])).toBe(FULLTEXT_INDEX_PUBLIC);
    expect(isPublicOnlyScope(["public"])).toBe(true);
  });

  it("returns the admin index for every other shape", () => {
    expect(pickFulltextIndex(undefined)).toBe(FULLTEXT_INDEX_ADMIN);
    expect(pickFulltextIndex([])).toBe(FULLTEXT_INDEX_ADMIN);
    expect(pickFulltextIndex(["shared"])).toBe(FULLTEXT_INDEX_ADMIN);
    expect(pickFulltextIndex(["public", "shared"])).toBe(FULLTEXT_INDEX_ADMIN);
    expect(pickFulltextIndex(["admin"])).toBe(FULLTEXT_INDEX_ADMIN);
    for (const shape of [undefined, [], ["shared"], ["public", "shared"], ["admin"]] as const) {
      expect(isPublicOnlyScope(shape)).toBe(false);
    }
  });
});
