/**
 * WorkFactReader — WP-11.
 *
 * Read-only work-site facts from FCoP files. Never invents missing facts
 * (emits UNKNOWN). Never reads hidden thinking streams.
 */

import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import { basename, join } from "node:path";

export const WORK_FACT_BUNDLE_SCHEMA = "codeflowmu.work-fact-bundle/v1" as const;

/** Patterns that must never be loaded as work facts (hidden thinking). */
const HIDDEN_THINKING_NAME =
  /(^|[\\/])(\.thinking|thinking\.jsonl|sdk\.thinking|live-thinking)([\\/]|$)/i;

export interface WorkFactRef {
  kind:
    | "task"
    | "report"
    | "review"
    | "issue"
    | "eval"
    | "evidence"
    | "other";
  path: string;
  digest?: string;
  excerpt?: string;
  meta?: Record<string, string>;
}

export interface WorkFactBundle {
  schema: typeof WORK_FACT_BUNDLE_SCHEMA;
  projectRoot: string;
  taskId: string;
  threadKey: string | "UNKNOWN";
  parent: string | "UNKNOWN";
  references: string[];
  taskPath: string | "UNKNOWN";
  taskBodyExcerpt: string | "UNKNOWN";
  reports: WorkFactRef[];
  reviews: WorkFactRef[];
  issues: WorkFactRef[];
  evals: WorkFactRef[];
  evidence: WorkFactRef[];
  unknowns: string[];
  readAt: string;
}

export interface WorkFactReaderOptions {
  projectRoot: string;
  /** Max chars kept per excerpt (default 1200). */
  excerptLimit?: number;
  now?: () => Date;
}

function isHiddenThinkingPath(p: string): boolean {
  return HIDDEN_THINKING_NAME.test(p.replace(/\\/g, "/"));
}

function digestOf(raw: string): string {
  return createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 16);
}

function parseFmField(raw: string, key: string): string | undefined {
  const m = raw.match(new RegExp(`^${key}:[ \\t]*(.+)$`, "m"));
  return m?.[1]?.trim().replace(/^["']|["']$/g, "");
}

function parseFmList(raw: string, key: string): string[] {
  const inline = parseFmField(raw, key);
  if (!inline || inline === "[]" || inline.toLowerCase() === "null") return [];
  const ids = inline.match(/TASK-\d{8}-\d{3,}(?:-[A-Za-z0-9]+)*/gi);
  if (ids?.length) return [...new Set(ids)];
  return inline
    .split(/[,\s]+/)
    .map((s) => s.trim())
    .filter(Boolean);
}

function sanitizeExcerpt(raw: string, limit: number): string {
  // Drop fenced thinking / secret-looking lines; keep factual markdown.
  const lines = raw.split(/\r?\n/).filter((line) => {
    if (/sdk\.thinking|hidden.?thinking/i.test(line)) return false;
    if (/api[_-]?key|secret|password|token\s*=/i.test(line)) return false;
    return true;
  });
  let text = lines.join("\n").trim();
  // Redact absolute Windows user paths in excerpts.
  text = text.replace(/[A-Za-z]:\\Users\\[^\\\s]+/g, "<REDACTED_USER_PATH>");
  text = text.replace(/\/Users\/[^/\s]+/g, "<REDACTED_USER_PATH>");
  if (text.length > limit) text = `${text.slice(0, limit)}\n…`;
  return text || "UNKNOWN";
}

async function readTextSafe(path: string): Promise<string | null> {
  if (isHiddenThinkingPath(path)) return null;
  try {
    return await fs.readFile(path, "utf8");
  } catch {
    return null;
  }
}

async function listMarkdown(
  dir: string,
  filter?: (name: string) => boolean,
): Promise<string[]> {
  let names: string[] = [];
  try {
    names = await fs.readdir(dir);
  } catch {
    return [];
  }
  return names
    .filter((n) => n.endsWith(".md") && !n.includes(".tmp"))
    .filter((n) => (filter ? filter(n) : true))
    .filter((n) => !isHiddenThinkingPath(n))
    .sort()
    .map((n) => join(dir, n));
}

export class WorkFactReader {
  readonly #root: string;
  readonly #excerptLimit: number;
  readonly #now: () => Date;

  constructor(opts: WorkFactReaderOptions) {
    this.#root = opts.projectRoot;
    this.#excerptLimit = opts.excerptLimit ?? 1200;
    this.#now = opts.now ?? (() => new Date());
  }

  get projectRoot(): string {
    return this.#root;
  }

  /**
   * Collect file-native facts for a task id. Missing pieces become UNKNOWN.
   */
  async readTaskFacts(taskId: string): Promise<WorkFactBundle> {
    const id = String(taskId ?? "").trim();
    const unknowns: string[] = [];
    if (!id) {
      return this.#empty("UNKNOWN", ["taskId missing"]);
    }

    const taskPath = await this.#findTaskFile(id);
    let threadKey: string | "UNKNOWN" = "UNKNOWN";
    let parent: string | "UNKNOWN" = "UNKNOWN";
    let references: string[] = [];
    let taskBodyExcerpt: string | "UNKNOWN" = "UNKNOWN";

    if (!taskPath) {
      unknowns.push(`task file not found for ${id}`);
    } else {
      const raw = await readTextSafe(taskPath);
      if (!raw) {
        unknowns.push(`task unreadable: ${taskPath}`);
      } else {
        threadKey = parseFmField(raw, "thread_key") || "UNKNOWN";
        parent =
          parseFmField(raw, "parent") ||
          parseFmField(raw, "parent_task_id") ||
          "UNKNOWN";
        references = parseFmList(raw, "references");
        const body = raw.replace(/^---[\s\S]*?---\s*/, "");
        taskBodyExcerpt = sanitizeExcerpt(body, this.#excerptLimit);
      }
    }

    const reports = await this.#collectReports(id, threadKey);
    const reviews = await this.#collectDir(
      join(this.#root, "fcop", "reviews"),
      "review",
      (n) => n.includes(id) || (threadKey !== "UNKNOWN" && n.includes(threadKey)),
    );
    const issues = await this.#collectDir(
      join(this.#root, "fcop", "issues"),
      "issue",
      (n) => n.includes(id) || n.startsWith("ISSUE-"),
    );
    const evals = await this.#collectDir(
      join(this.#root, "fcop", "internal", "eval"),
      "eval",
      (n) => n.includes("OBSERVATION") || n.includes(id),
    );
    const evidence = await this.#collectEvidence(id);

    if (reports.length === 0) unknowns.push("no related REPORT found");

    return {
      schema: WORK_FACT_BUNDLE_SCHEMA,
      projectRoot: this.#root,
      taskId: id,
      threadKey,
      parent,
      references,
      taskPath: taskPath ?? "UNKNOWN",
      taskBodyExcerpt,
      reports,
      reviews,
      issues,
      evals,
      evidence,
      unknowns,
      readAt: this.#now().toISOString(),
    };
  }

  async #empty(taskId: string, unknowns: string[]): Promise<WorkFactBundle> {
    return {
      schema: WORK_FACT_BUNDLE_SCHEMA,
      projectRoot: this.#root,
      taskId,
      threadKey: "UNKNOWN",
      parent: "UNKNOWN",
      references: [],
      taskPath: "UNKNOWN",
      taskBodyExcerpt: "UNKNOWN",
      reports: [],
      reviews: [],
      issues: [],
      evals: [],
      evidence: [],
      unknowns,
      readAt: this.#now().toISOString(),
    };
  }

  async #findTaskFile(taskId: string): Promise<string | null> {
    const buckets = ["inbox", "active", "review", "done", "archive"];
    for (const b of buckets) {
      const dir = join(this.#root, "fcop", "_lifecycle", b);
      const files = await listMarkdown(dir, (n) => n.startsWith(taskId));
      if (files[0]) return files[0]!;
    }
    // Also search adopted / flat tasks if present
    const flat = await listMarkdown(join(this.#root, "fcop", "tasks"), (n) =>
      n.startsWith(taskId),
    );
    return flat[0] ?? null;
  }

  async #collectReports(
    taskId: string,
    threadKey: string | "UNKNOWN",
  ): Promise<WorkFactRef[]> {
    const dir = join(this.#root, "fcop", "reports");
    const files = await listMarkdown(dir);
    const out: WorkFactRef[] = [];
    for (const path of files) {
      if (isHiddenThinkingPath(path)) continue;
      const raw = await readTextSafe(path);
      if (!raw) continue;
      const source =
        parseFmField(raw, "source_task_id") ||
        parseFmField(raw, "task_id") ||
        "";
      const tk = parseFmField(raw, "thread_key") || "";
      const related =
        source.includes(taskId) ||
        raw.includes(taskId) ||
        (threadKey !== "UNKNOWN" && tk === threadKey);
      if (!related) continue;
      out.push({
        kind: "report",
        path: this.#rel(path),
        digest: digestOf(raw),
        excerpt: sanitizeExcerpt(
          raw.replace(/^---[\s\S]*?---\s*/, ""),
          this.#excerptLimit,
        ),
        meta: {
          status: parseFmField(raw, "status") || "UNKNOWN",
          reporter: parseFmField(raw, "reporter") || "UNKNOWN",
        },
      });
    }
    return out;
  }

  async #collectDir(
    dir: string,
    kind: WorkFactRef["kind"],
    filter: (name: string) => boolean,
  ): Promise<WorkFactRef[]> {
    const files = await listMarkdown(dir, filter);
    const out: WorkFactRef[] = [];
    for (const path of files) {
      if (isHiddenThinkingPath(path)) continue;
      const raw = await readTextSafe(path);
      if (!raw) continue;
      out.push({
        kind,
        path: this.#rel(path),
        digest: digestOf(raw),
        excerpt: sanitizeExcerpt(raw, Math.min(400, this.#excerptLimit)),
      });
    }
    return out;
  }

  async #collectEvidence(taskId: string): Promise<WorkFactRef[]> {
    const roots = [
      join(this.#root, "workspace", "core-refactor-plan", "qa-evidence", taskId),
      join(this.#root, ".codeflowmu", "evidence"),
      join(this.#root, "fcop", "shared"),
    ];
    const out: WorkFactRef[] = [];
    for (const dir of roots) {
      let names: string[] = [];
      try {
        names = await fs.readdir(dir);
      } catch {
        continue;
      }
      for (const name of names.sort()) {
        const path = join(dir, name);
        if (isHiddenThinkingPath(path)) continue;
        if (name.includes(".tmp")) continue;
        const raw = await readTextSafe(path);
        if (raw === null) {
          // binary / unreadable — record path only
          out.push({
            kind: "evidence",
            path: this.#rel(path),
            meta: { status: "UNREADABLE_OR_BINARY" },
          });
          continue;
        }
        out.push({
          kind: "evidence",
          path: this.#rel(path),
          digest: digestOf(raw),
          excerpt: sanitizeExcerpt(raw, 240),
        });
      }
    }
    return out;
  }

  #rel(abs: string): string {
    const root = this.#root.replace(/[\\/]+$/, "");
    if (abs.startsWith(root)) {
      return abs.slice(root.length).replace(/^[\\/]/, "").replace(/\\/g, "/");
    }
    // Never leak absolute user paths into projections.
    return basename(abs);
  }
}
