/**
 * SB-S3e — triple-post reconciliation (the LAST S3 slice, read-side).
 *
 * Silo 1 (`## Recent Posts` in the runtime state file) is the AUTO-tracked stream
 * of posts created via the plugin (written by `updatePostTracking`,
 * state-updater.mjs:116). Silo 2 (`ingest/published/<id>.md`) + silo 3 (analytics)
 * are joined by SB-S3c's `assemblePostGraph`, but ONLY for posts the user MANUALLY
 * ran `brain ingest` on. So the graph is blind to everything created-but-never-
 * ingested. This module reconciles silo 1 against the graph and surfaces that
 * COVERAGE GAP — read-only, never writing the state silo.
 *
 * Honest limit: silo 1 carries only a ≤60-char (possibly `…`-truncated) hook
 * preview, a weaker signal than silo 2's full body; below a floor it cannot
 * discriminate → `orphaned-in-state`. Read-side cannot reconstruct the
 * specifics/trends a post was built from — that needs auto-capture (a follow-up).
 *
 * PURE core: `parseRecentPosts` / `reconcileRecentPosts` / `summarizeReconcile`
 * take strings/objects and return data — no FS/clock/network. The only IO is
 * `loadRecentPosts`, which reads the state file via the canonical `getStateFile()`
 * precedence (`STATE_FILE` first) — a DIFFERENT root than the brain dataRoot.
 */

import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

import { normalize, type PostGraphNode } from "./assemble.js";
import type { PublishedRecord } from "./ingest.js";

export interface RecentPost {
  date: string;
  hook: string;
  charCount: number;
  topic: string;
}

export type ReconcileTier = "in-graph" | "in-brain-only" | "orphaned-in-state";

export interface ReconcileNode {
  recentPost: RecentPost;
  tier: ReconcileTier;
  /** The matched published record's id, when a record matched. */
  contentId?: string;
}

export interface ReconcileSummary {
  created: number;
  inGraph: number;
  inBrainOnly: number;
  orphaned: number;
}

/**
 * Minimum normalized-hook length to attempt a prefix match. Mirrors
 * `assemble.ts`'s `PREFIX_FLOOR` (24) — the brain-local-copy idiom (dataRoot.ts
 * header): the hook preview floor below which a prefix match is coincidental.
 */
const HOOK_PREFIX_FLOOR = 24;

/** Strip a trailing LinkedIn truncation marker so a `…`-suffixed hook still prefix-matches (assemble.ts idiom). */
function stripTrailingEllipsis(s: string): string {
  return s.replace(/(?:…|\.{3})\s*$/, "").trimEnd();
}

/**
 * Parse the `## Recent Posts` section into structured entries. The format source
 * of truth is the WRITER (`updatePostTracking`, state-updater.mjs:116):
 * `- [YYYY-MM-DD] "hook" (chars) - topic`. (NOT `pruneContentHistory`'s :145 regex,
 * which is date-only.) Capture/`match` only — no `String.replace` — so a `$`-bearing
 * hook/topic round-trips verbatim with no injection. Pure.
 */
export function parseRecentPosts(stateText: string): RecentPost[] {
  // Line-walk from the `## Recent Posts` heading to the next `## ` heading (or EOF).
  // Line-based (not one lookahead regex) so a blank line right after the heading
  // can't collapse the capture.
  const lines = stateText.split("\n");
  const start = lines.findIndex((l) => /^## Recent Posts[ \t]*$/.test(l));
  if (start === -1) return [];
  const entry = /^- \[(\d{4}-\d{2}-\d{2})\] "(.*)" \((\d+)\) - (.+)$/;
  const out: RecentPost[] = [];
  for (let i = start + 1; i < lines.length; i++) {
    if (/^## /.test(lines[i])) break; // next section ends the block
    const m = lines[i].match(entry);
    if (m) out.push({ date: m[1], hook: m[2], charCount: Number(m[3]), topic: m[4] });
  }
  return out;
}

/**
 * Find the published record a silo-1 hook corresponds to: the hook (ellipsis-
 * stripped, normalized) must be a PREFIX of the record body, and long enough to
 * discriminate. Among matches, prefer one whose `published_date` equals the
 * entry's date (deterministic), else the first in input order.
 */
function matchRecord(post: RecentPost, records: PublishedRecord[]): PublishedRecord | undefined {
  const nh = stripTrailingEllipsis(normalize(post.hook));
  if (nh.length < HOOK_PREFIX_FLOOR) return undefined; // too short → cannot discriminate
  let first: PublishedRecord | undefined;
  for (const r of records) {
    if (!normalize(r.body).startsWith(nh)) continue;
    if (r.published_date === post.date) return r; // exact date wins
    if (!first) first = r;
  }
  return first;
}

/**
 * Reconcile each silo-1 entry against the graph. `records` carry `body` (the join
 * key the body-less `PostGraphNode` lacks); `graph` carries each record's analytics
 * match. Pure. Tiers: `in-graph` (matched record HAS analytics) · `in-brain-only`
 * (matched record, no analytics) · `orphaned-in-state` (no matching record — the
 * coverage gap: created but never ingested).
 */
export function reconcileRecentPosts(args: {
  recentPosts: RecentPost[];
  records: PublishedRecord[];
  graph: PostGraphNode[];
}): ReconcileNode[] {
  const tierOf = new Map(args.graph.map((g) => [g.contentId, g.match.confidence]));
  return args.recentPosts.map((post) => {
    const rec = matchRecord(post, args.records);
    if (!rec) return { recentPost: post, tier: "orphaned-in-state" as const };
    const hasAnalytics = (tierOf.get(rec.id) ?? "none") !== "none";
    return {
      recentPost: post,
      tier: hasAnalytics ? ("in-graph" as const) : ("in-brain-only" as const),
      contentId: rec.id,
    };
  });
}

/** Count the coverage tiers. Pure. */
export function summarizeReconcile(nodes: ReconcileNode[]): ReconcileSummary {
  const sum: ReconcileSummary = { created: nodes.length, inGraph: 0, inBrainOnly: 0, orphaned: 0 };
  for (const n of nodes) {
    if (n.tier === "in-graph") sum.inGraph++;
    else if (n.tier === "in-brain-only") sum.inBrainOnly++;
    else sum.orphaned++;
  }
  return sum;
}

/**
 * Resolve the runtime state file with the canonical precedence (the brain-side
 * copy of `hooks/scripts/data-root.mjs: getStateFile()` — `STATE_FILE` first, else
 * `~/.claude/linkedin-studio.local.md` with `HOME||USERPROFILE||homedir()`). This
 * is a NEW root-skew seam: the state file lives OUTSIDE the brain dataRoot
 * (`LINKEDIN_STUDIO_DATA`); if `STATE_FILE` is repointed the read follows it.
 */
function getStateFile(): string {
  const home = process.env.HOME || process.env.USERPROFILE || homedir();
  return process.env.STATE_FILE || join(home, ".claude", "linkedin-studio.local.md");
}

/** Read-only: load + parse silo 1 from the state file. Absent/unreadable → [] (fresh-clone safe). */
export function loadRecentPosts(): RecentPost[] {
  const file = getStateFile();
  if (!existsSync(file)) return [];
  try {
    return parseRecentPosts(readFileSync(file, "utf8"));
  } catch {
    return []; // never crash on a malformed/locked state file
  }
}
