/**
 * Deterministic store + query for the lived-specifics bank (Fix #2).
 *
 * Pure where it matters: id derivation, dedupe, and topic query are
 * side-effect-free and fully testable. Only loadBank/saveBank touch the
 * filesystem. No AI, no network — the bank is reliable inventory, not a
 * creatively-interpreted blob. The elicitation that POPULATES the bank lives in
 * the command layer (a guided interview that refuses vague answers); this module
 * only stores, dedupes, and serves what the operator actually supplied.
 */

import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
import { createHash } from "node:crypto";

import { SCHEMA_VERSION } from "./types.js";
import type {
  Bank,
  Provenance,
  QueryHit,
  Specific,
  SpecificStatus,
  SpecificType,
  Verification,
} from "./types.js";

export { SCHEMA_VERSION } from "./types.js";

/** What a caller supplies to addSpecific — the id is derived, never passed in. */
export interface SpecificInput {
  type: SpecificType;
  /** The operator's raw material, verbatim. */
  content: string;
  topicTags: string[];
  provenance: Provenance;
  /** Defaults to "unverified" (numbers stay guilty-until-checked — regel 6/7). */
  verification?: Verification;
  /** Defaults to "active". */
  status?: SpecificStatus;
}

export interface AddResult {
  bank: Bank;
  /** true iff a new specific was appended (false = duplicate content). */
  added: boolean;
  /** true iff an existing duplicate gained new topic tags via union. */
  merged: boolean;
}

/** Lowercase + trim + collapse all whitespace runs to a single space. */
export function normalizeContent(content: string): string {
  return content.trim().toLowerCase().replace(/\s+/g, " ");
}

/** Stable id = first 12 hex of sha256(normalizeContent) — also the dedupe key. */
export function specificId(content: string): string {
  return createHash("sha256").update(normalizeContent(content)).digest("hex").slice(0, 12);
}

export function emptyBank(): Bank {
  return { schemaVersion: SCHEMA_VERSION, specifics: [] };
}

/** Read the bank; a missing file is an empty bank (never throws on absence). */
export function loadBank(path: string): Bank {
  if (!existsSync(path)) return emptyBank();
  const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<Bank>;
  return {
    schemaVersion: parsed.schemaVersion ?? SCHEMA_VERSION,
    specifics: Array.isArray(parsed.specifics) ? parsed.specifics : [],
  };
}

/** Write the bank as pretty JSON, creating the parent dir if needed. */
export function saveBank(path: string, bank: Bank): void {
  mkdirSync(dirname(path), { recursive: true });
  writeFileSync(path, JSON.stringify(bank, null, 2) + "\n", "utf8");
}

/** Union of two tag lists, order-stable on the first list, case-insensitive dedupe. */
function unionTags(existing: string[], incoming: string[]): { tags: string[]; changed: boolean } {
  const seen = new Set(existing.map((t) => t.toLowerCase()));
  const tags = [...existing];
  let changed = false;
  for (const t of incoming) {
    if (!seen.has(t.toLowerCase())) {
      seen.add(t.toLowerCase());
      tags.push(t);
      changed = true;
    }
  }
  return { tags, changed };
}

/**
 * Add a specific, deduping on normalized content. A duplicate does not append a
 * second entry — instead its topic tags are unioned in, so the same real
 * material re-surfaced under a new edition's topic enriches the existing record.
 * The operator's `content` is stored VERBATIM; only the id is normalized.
 */
export function addSpecific(bank: Bank, input: SpecificInput): AddResult {
  const id = specificId(input.content);
  const existing = bank.specifics.find((s) => s.id === id);
  if (existing) {
    const { tags, changed } = unionTags(existing.topicTags, input.topicTags);
    existing.topicTags = tags;
    return { bank, added: false, merged: changed };
  }
  const specific: Specific = {
    id,
    type: input.type,
    content: input.content,
    topicTags: [...input.topicTags],
    provenance: input.provenance,
    verification: input.verification ?? "unverified",
    status: input.status ?? "active",
  };
  bank.specifics.push(specific);
  return { bank, added: true, merged: false };
}

/**
 * Active specifics whose tags overlap the query, ranked by overlap (desc) then
 * recency (capturedAt desc). Tag matching is case-insensitive. Non-matches and
 * archived material are excluded.
 */
export function queryByTopic(bank: Bank, tags: string[]): QueryHit[] {
  const wanted = tags.map((t) => t.toLowerCase());
  const hits: QueryHit[] = [];
  for (const specific of bank.specifics) {
    if (specific.status !== "active") continue;
    const have = new Set(specific.topicTags.map((t) => t.toLowerCase()));
    const tagOverlap = wanted.reduce((n, t) => (have.has(t) ? n + 1 : n), 0);
    if (tagOverlap > 0) hits.push({ specific, tagOverlap });
  }
  hits.sort(
    (a, b) =>
      b.tagOverlap - a.tagOverlap ||
      b.specific.provenance.capturedAt.localeCompare(a.specific.provenance.capturedAt),
  );
  return hits;
}

/** What a caller supplies to recordUsage. */
export interface UsageInput {
  /** Bank ids the edition actually consumed — see `boundSpecificIds` in binding.ts. */
  ids: string[];
  /** The edition the material was published in, e.g. "seres/05". */
  editionId: string;
}

export interface UsageResult {
  bank: Bank;
  /** ids that gained this edition now. */
  recorded: string[];
  /** ids already carrying it — a re-lock after a pivot is idempotent. */
  alreadyRecorded: string[];
  /** ids absent from the bank; the caller decides how loud to be. */
  unknown: string[];
}

/**
 * Stamp "used in edition NN" on the specifics an edition consumed. Called at
 * LOCK (Step 8) rather than at binding (Step 2.5), so an abandoned draft never
 * pollutes the history — `usedIn` means published, not considered.
 * Archived material still records: the use is historical fact.
 */
export function recordUsage(bank: Bank, input: UsageInput): UsageResult {
  const editionId = input.editionId.trim();
  if (editionId.length === 0) {
    throw new Error("recordUsage: editionId must be non-blank — an unattributable use is worse than none");
  }
  const byId = new Map<string, Specific>(bank.specifics.map((s) => [s.id, s]));
  const recorded: string[] = [];
  const alreadyRecorded: string[] = [];
  const unknown: string[] = [];
  for (const id of input.ids) {
    const specific = byId.get(id);
    if (!specific) {
      unknown.push(id);
      continue;
    }
    const usedIn = specific.usedIn ?? [];
    if (usedIn.includes(editionId)) {
      alreadyRecorded.push(id);
      continue;
    }
    specific.usedIn = [...usedIn, editionId];
    recorded.push(id);
  }
  return { bank, recorded, alreadyRecorded, unknown };
}

/**
 * Default bank path under the per-user data dir (M0 data-path convention), so the
 * bank survives plugin upgrades/reinstalls. `LINKEDIN_STUDIO_DATA` overrides the
 * root; otherwise `~/.claude/linkedin-studio`.
 */
export function defaultBankPath(): string {
  const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio");
  return join(root, "specifics-bank", "specifics-bank.json");
}
