/**
 * Deterministic store + reuse check for the series distillate (N11 — C-5).
 *
 * Pure where it matters: normalization, similarity and the skeleton check are
 * side-effect-free and fully testable. Only load/save touch the filesystem.
 *
 * Similarity is CHARACTER TRIGRAM Jaccard, not word overlap. The plugin is
 * language-general, and the languages it is actually used in are inflection-
 * heavy: "migrere"/"migreringen" and "pipeline"/"pipelinen" are the same unit to
 * a reader but different word tokens. Calibrated against real paraphrase pairs
 * (see tests/distillate.test.ts): retellings score 0.44–0.55, unrelated material
 * 0.04–0.06, and same-topic-different-story 0.24 — so the default threshold sits
 * in the gap at 0.40. Word-Jaccard scored a shortened hook paraphrase at 0.11
 * and would have let it through.
 */

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

import { SCHEMA_VERSION } from "./types.js";
import type {
  DistillateEntry,
  NarrativeKind,
  ReuseHit,
  ReuseReport,
  SeriesDistillate,
  SkeletonCandidate,
} from "./types.js";

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

/** Similarity at or above this counts as re-use. Empirically calibrated — see the header. */
export const DEFAULT_THRESHOLD = 0.4;

const KINDS: NarrativeKind[] = ["anecdote", "argument", "hook"];

/** Lowercase, strip everything that is not a letter or digit, collapse whitespace. */
export function normalizeUnit(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^\p{L}\p{N}]+/gu, " ")
    .trim()
    .replace(/\s+/g, " ");
}

/** Character trigrams over the normalized text, space-padded so short units still overlap. */
function trigrams(text: string): Set<string> {
  const normalized = normalizeUnit(text);
  if (normalized.length === 0) return new Set();
  const padded = ` ${normalized} `;
  const out = new Set<string>();
  for (let i = 0; i + 3 <= padded.length; i++) out.add(padded.slice(i, i + 3));
  return out;
}

/** Jaccard over character trigrams: 1 = identical after normalization, 0 = nothing shared. */
export function similarity(a: string, b: string): number {
  const A = trigrams(a);
  const B = trigrams(b);
  if (A.size === 0 || B.size === 0) return 0;
  let intersection = 0;
  for (const gram of A) if (B.has(gram)) intersection++;
  return intersection / (A.size + B.size - intersection);
}

export function emptyDistillate(series: string): SeriesDistillate {
  return { schemaVersion: SCHEMA_VERSION, series, editions: [] };
}

/** Read the distillate; a missing file is an empty one (a series' first edition is not an error). */
export function loadDistillate(path: string, series: string): SeriesDistillate {
  if (!existsSync(path)) return emptyDistillate(series);
  const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<SeriesDistillate>;
  return {
    schemaVersion: parsed.schemaVersion ?? SCHEMA_VERSION,
    series: parsed.series ?? series,
    editions: Array.isArray(parsed.editions) ? parsed.editions : [],
  };
}

/** Write as pretty JSON with a trailing newline, creating the parent dir if needed. */
export function saveDistillate(path: string, distillate: SeriesDistillate): void {
  mkdirSync(dirname(path), { recursive: true });
  writeFileSync(path, JSON.stringify(distillate, null, 2) + "\n", "utf8");
}

export interface AppendResult {
  distillate: SeriesDistillate;
  /** true iff the edition was new. */
  appended: boolean;
  /** true iff an existing edition was overwritten in place (a pivot re-lock). */
  replaced: boolean;
}

/**
 * Fold one locked edition into the distillate. An edition already present is
 * REPLACED in place, never duplicated: `/linkedin:pivot` re-opens and re-locks
 * the same edition, and the reader still only ever met it once.
 */
export function appendEntry(distillate: SeriesDistillate, entry: DistillateEntry): AppendResult {
  const stored: DistillateEntry = {
    editionId: entry.editionId,
    title: entry.title,
    lockedAt: entry.lockedAt,
    anecdotes: [...entry.anecdotes],
    arguments: [...entry.arguments],
    hooks: [...entry.hooks],
  };
  const at = distillate.editions.findIndex((e) => e.editionId === entry.editionId);
  if (at >= 0) {
    distillate.editions[at] = stored;
    return { distillate, appended: false, replaced: true };
  }
  distillate.editions.push(stored);
  return { distillate, appended: true, replaced: false };
}

function unitsOf(entry: DistillateEntry, kind: NarrativeKind): string[] {
  if (kind === "anecdote") return entry.anecdotes;
  if (kind === "argument") return entry.arguments;
  return entry.hooks;
}

function candidateUnits(candidate: SkeletonCandidate, kind: NarrativeKind): string[] {
  if (kind === "anecdote") return candidate.anecdotes ?? [];
  if (kind === "argument") return candidate.arguments ?? [];
  return candidate.hooks ?? [];
}

export interface CheckOptions {
  /** Override the calibrated default when a series wants to be stricter or looser. */
  threshold?: number;
}

/**
 * Check a proposed skeleton against everything the series already published.
 * Units are compared WITHIN their kind — an argument re-used as a hook is a new
 * move, not a retread. Advisory: the verdict feeds the Step 2.5 annotation gate.
 */
export function checkSkeleton(
  distillate: SeriesDistillate,
  candidate: SkeletonCandidate,
  options: CheckOptions = {},
): ReuseReport {
  const threshold = options.threshold ?? DEFAULT_THRESHOLD;
  const hits: ReuseHit[] = [];

  for (const kind of KINDS) {
    for (const proposed of candidateUnits(candidate, kind)) {
      for (const edition of distillate.editions) {
        for (const published of unitsOf(edition, kind)) {
          const score = similarity(proposed, published);
          if (score >= threshold) {
            hits.push({ kind, candidate: proposed, matched: published, editionId: edition.editionId, score });
          }
        }
      }
    }
  }

  hits.sort((a, b) => b.score - a.score);

  return {
    verdict: hits.length > 0 ? "REUSE" : "CLEAR",
    hits,
    threshold,
    comparedAgainst: distillate.editions.length,
  };
}

/**
 * The distillate sits beside `edition-state.json` in the series root, not in the
 * per-user data dir: it is series-scoped state, and the series folder is where
 * the edition's state already lives (`<serie>/linkedin/`), so it travels with
 * the series and needs no slug→path map.
 */
export function defaultDistillatePath(seriesRoot: string): string {
  return join(seriesRoot, "linkedin", "series-distillate.json");
}
