/**
 * topicCompaction — pure deterministic merge from a per-run scratchpad into
 * a per-entity topic file (layered_memory.md L2-L3 boundary).
 *
 * Role (.claude/rules/layered_memory.md §Write-path invariant):
 *   "Sub-agents NEVER write to MEMORY.md or topic files directly. Writes go
 *    through the compaction step, which is the layer boundary owner.
 *    Compaction happens post-structuring:
 *      1. Diff — compare structured output to existing topic file
 *      2. Merge — deterministic, sort-stable
 *      3. Index update — MEMORY.md one-liner refreshed per changed topic
 *      4. Archive — raw scratchpad persisted for audit
 *      5. Drift check — if entity version advanced during structuring, re-run"
 *
 * This module does (1), (2) — pure functions. (3)-(5) are the Convex
 * module's job (convex/domains/product/entityMemory.ts).
 *
 * Design posture (agentic_reliability.md):
 *   - DETERMINISTIC: same input always yields same output (sorted keys,
 *     stable dedup). Required so compaction is idempotent — running twice
 *     on the same scratchpad produces the same topic file.
 *   - BOUND: MAX_FACTS_PER_TOPIC caps topic size (compaction drops oldest
 *     facts when overflow). MAX_FACT_LEN truncates individual facts.
 *   - HONEST_STATUS: compaction returns a verdict — "created" | "updated"
 *     | "unchanged" | "bounded" (when facts were dropped to stay under cap).
 */

/** One normalized fact extracted from a scratchpad section. */
export type CompactionFact = {
  /** The canonical claim text. Normalized by the caller before compaction. */
  text: string;
  /** Optional source reference id (from diligenceProjections.sourceRefIds). */
  sourceRefId?: string;
  /** ms since epoch — newest facts are preferred when the cap kicks in. */
  observedAt: number;
};

/** Compacted topic-file payload. Stored as JSON in entityTopicFiles.contentJson. */
export type TopicFileContent = {
  /** Canonical topic name — e.g. "founder", "product", "funding". */
  topicName: string;
  /** Deduped + sorted fact list. Newest first. */
  facts: ReadonlyArray<CompactionFact>;
  /** Summary line — auto-regenerated by the Convex compactor when facts change. */
  oneLineSummary: string;
  /** ms since epoch when this topic was last compacted. */
  compactedAt: number;
  /** Schema version — bump when TopicFileContent shape changes. */
  schemaVersion: number;
};

export type CompactionVerdict =
  | { status: "created"; newContent: TopicFileContent }
  | { status: "updated"; newContent: TopicFileContent; added: number; dropped: number }
  | { status: "unchanged"; reason: string }
  | { status: "bounded"; newContent: TopicFileContent; added: number; droppedToCap: number };

const MAX_FACTS_PER_TOPIC = 200;
const MAX_FACT_LEN = 800;
const TOPIC_SCHEMA_VERSION = 1;

/** Normalize a single fact — trim, cap length, collapse whitespace. */
function normalizeFactText(raw: string): string {
  return raw.replace(/\s+/g, " ").trim().slice(0, MAX_FACT_LEN);
}

/** Stable dedup key for a fact. DETERMINISTIC — purely textual. */
function factKey(f: { text: string; sourceRefId?: string }): string {
  return `${f.sourceRefId ?? ""}::${f.text}`;
}

/** Sort facts by observedAt desc, then text asc for stable tiebreak. */
function sortFactsDesc(
  a: CompactionFact,
  b: CompactionFact,
): number {
  if (b.observedAt !== a.observedAt) return b.observedAt - a.observedAt;
  return a.text.localeCompare(b.text);
}

/** Build a short one-line summary for the MEMORY.md index row. */
export function buildOneLineSummary(
  topicName: string,
  facts: ReadonlyArray<CompactionFact>,
): string {
  if (facts.length === 0) return `${topicName}: no facts yet`;
  // Newest fact first (already sorted). Truncate to 120 chars so MEMORY.md
  // stays scannable per layered_memory.md guidance ("keep index entries
  // to one line under ~200 chars").
  const headline = facts[0].text.slice(0, 120);
  return `${topicName} · ${facts.length} fact${facts.length === 1 ? "" : "s"} · "${headline}"`;
}

/**
 * Merge new scratchpad facts into an existing topic file. Pure. Deterministic.
 *
 * Semantics:
 *   - Facts already present (matched by factKey) have their observedAt
 *     bumped to max(existing, new) — the topic remembers the latest time
 *     a fact was reconfirmed.
 *   - New facts are prepended then the list is resorted.
 *   - If the merged list exceeds MAX_FACTS_PER_TOPIC, oldest facts are
 *     dropped and status="bounded" is returned.
 *   - If merge produces exactly the existing fact set with no observedAt
 *     changes, status="unchanged" is returned.
 */
export function compactTopic(args: {
  topicName: string;
  existing: TopicFileContent | null;
  newFacts: ReadonlyArray<CompactionFact>;
  compactedAtMs: number;
}): CompactionVerdict {
  const newNormalized: CompactionFact[] = [];
  for (const f of args.newFacts) {
    const text = normalizeFactText(f.text);
    if (text.length === 0) continue;
    newNormalized.push({
      text,
      sourceRefId: f.sourceRefId,
      observedAt: f.observedAt,
    });
  }

  if (!args.existing) {
    // Cold start — dedup by factKey first (later observation wins on tie),
    // then cap if the caller dumped too many distinct facts.
    const dedup = new Map<string, CompactionFact>();
    for (const f of newNormalized) {
      const key = factKey(f);
      const prev = dedup.get(key);
      if (!prev || f.observedAt > prev.observedAt) {
        dedup.set(key, f);
      }
    }
    const sorted = Array.from(dedup.values()).sort(sortFactsDesc);
    const bounded = sorted.slice(0, MAX_FACTS_PER_TOPIC);
    const dropped = sorted.length - bounded.length;
    const newContent: TopicFileContent = {
      topicName: args.topicName,
      facts: bounded,
      oneLineSummary: buildOneLineSummary(args.topicName, bounded),
      compactedAt: args.compactedAtMs,
      schemaVersion: TOPIC_SCHEMA_VERSION,
    };
    if (dropped > 0) {
      return { status: "bounded", newContent, added: bounded.length, droppedToCap: dropped };
    }
    return { status: "created", newContent };
  }

  // Merge path — build a map keyed by factKey.
  const merged = new Map<string, CompactionFact>();
  for (const f of args.existing.facts) merged.set(factKey(f), f);

  let addedCount = 0;
  let changedObservedAtCount = 0;
  for (const f of newNormalized) {
    const key = factKey(f);
    const prev = merged.get(key);
    if (!prev) {
      merged.set(key, f);
      addedCount += 1;
    } else if (f.observedAt > prev.observedAt) {
      merged.set(key, { ...prev, observedAt: f.observedAt });
      changedObservedAtCount += 1;
    }
  }

  if (addedCount === 0 && changedObservedAtCount === 0) {
    return { status: "unchanged", reason: "all new facts already present" };
  }

  const sorted = Array.from(merged.values()).sort(sortFactsDesc);
  const bounded = sorted.slice(0, MAX_FACTS_PER_TOPIC);
  const droppedToCap = sorted.length - bounded.length;

  const newContent: TopicFileContent = {
    topicName: args.topicName,
    facts: bounded,
    oneLineSummary: buildOneLineSummary(args.topicName, bounded),
    compactedAt: args.compactedAtMs,
    schemaVersion: TOPIC_SCHEMA_VERSION,
  };

  if (droppedToCap > 0) {
    return {
      status: "bounded",
      newContent,
      added: addedCount,
      droppedToCap,
    };
  }

  return {
    status: "updated",
    newContent,
    added: addedCount,
    dropped: 0,
  };
}

/** Drift check — pure comparison of entity version snapshots. */
export function isEntityVersionStale(args: {
  scratchpadEntityVersion: number;
  currentEntityVersion: number;
}): boolean {
  return args.currentEntityVersion > args.scratchpadEntityVersion;
}
