/**
 * The two-layer `brain/profile.md` line-grammar + the user-profile fold (SB-S0).
 *
 * NO YAML dependency (M2): the repo has no YAML parser, so the profile uses a
 * defined line-grammar that round-trips with the regex idiom already in-tree
 * (`state-updater.mjs` scalar regex). One fact = one line:
 *
 *   - [<provenance>|<status>|<first_seen>|<last_seen>|<evidence_count>|<id>] <value>
 *
 * The six bracket tokens are all CONSTRAINED (two enums, two ISO dates, an int, a
 * 12-hex id) — none can contain `]` or `|`, so the first `]` unambiguously closes
 * the bracket and `<value>` (free single-line text, may contain `]`/`|`/quotes) is
 * everything after `] `. `parseProfile(serializeProfile(doc)) === doc` (SC2).
 */

import { mintEntityId } from "./id.js";
import { SCHEMA_VERSION } from "./types.js";
import type { FactStatus, ProfileDoc, ProfileFact, Provenance } from "./types.js";

const STATIC_HEADER = "## Static";
const DYNAMIC_HEADER = "## Dynamic";

/**
 * One fact line. Tokens are pipe-joined in a fixed order; an empty value emits no
 * trailing space (so the common source-absent fact is a clean `- [tokens]`).
 */
function serializeFact(f: ProfileFact): string {
  const bracket = [
    f.provenance,
    f.status,
    f.first_seen,
    f.last_seen,
    String(f.evidence_count),
    f.id,
  ].join("|");
  return f.value === "" ? `- [${bracket}]` : `- [${bracket}] ${f.value}`;
}

/**
 * Deterministic full-document serialization; both sections always present. A
 * constant OKF frontmatter block (`type: Profile`) leads the document so the brain
 * bundle is OKF-compatible form (docs/okf-convergence-brief.md); it carries no
 * typed-doc data, so `parseProfile` skips it and `parse ∘ serialize` stays identity.
 */
export function serializeProfile(doc: ProfileDoc): string {
  const lines: string[] = [
    "---",
    "type: Profile",
    "title: Profile",
    "description: Two-layer semantic profile (static and dynamic) - the distilled who-you-are.",
    "---",
    "",
    "# Profile",
    "",
    `schemaVersion: ${doc.schemaVersion}`,
    "",
    STATIC_HEADER,
    "",
    ...doc.static.map(serializeFact),
    "",
    DYNAMIC_HEADER,
    "",
    ...doc.dynamic.map(serializeFact),
  ];
  return lines.join("\n") + "\n";
}

const FACT_RE =
  /^- \[(human|published|ai-draft)\|(active|superseded)\|(\d{4}-\d{2}-\d{2})\|(\d{4}-\d{2}-\d{2})\|(\d+)\|([0-9a-f]{12})\](?: (.*))?$/;

/**
 * Parse a profile document back into the typed two-layer doc. Lines that are not
 * fact lines or section headers (blank lines, the title) carry no data and are
 * skipped — the fact lines ARE the authoritative representation (C1), so nothing
 * data-bearing is lost, and `parse ∘ serialize` is a true identity.
 */
export function parseProfile(text: string): ProfileDoc {
  const schemaMatch = text.match(/^schemaVersion:\s*(\d+)\s*$/m);
  const version = schemaMatch ? Number(schemaMatch[1]) : SCHEMA_VERSION;
  if (version !== SCHEMA_VERSION) {
    throw new Error(`unsupported profile schemaVersion: ${version} (expected ${SCHEMA_VERSION})`);
  }

  const staticFacts: ProfileFact[] = [];
  const dynamicFacts: ProfileFact[] = [];
  let section: "static" | "dynamic" | null = null;

  for (const line of text.split("\n")) {
    const trimmed = line.trim();
    if (trimmed === STATIC_HEADER) {
      section = "static";
      continue;
    }
    if (trimmed === DYNAMIC_HEADER) {
      section = "dynamic";
      continue;
    }
    const m = line.match(FACT_RE);
    if (!m) continue;
    const fact: ProfileFact = {
      provenance: m[1] as Provenance,
      status: m[2] as FactStatus,
      first_seen: m[3],
      last_seen: m[4],
      evidence_count: Number(m[5]),
      id: m[6],
      value: m[7] ?? "",
    };
    if (section === "static") staticFacts.push(fact);
    else if (section === "dynamic") dynamicFacts.push(fact);
  }

  return { schemaVersion: SCHEMA_VERSION, static: staticFacts, dynamic: dynamicFacts };
}

// ── user-profile fold ──────────────────────────────────────────────────────────
//
// Folds the field-set of `config/user-profile.template.md` into the static layer
// (M4: extraction pinned against the real template). Exactly two productions, and
// a hard section boundary:
//
//   P1 — labeled scalar: `**Label:** value` (optionally `- ` prefixed). A line
//        whose value is EMPTY is a GROUP HEADER (`**Signature Elements:**`) and is
//        skipped — only the scalar fields under it fold. A `[placeholder]` value
//        is stored EMPTY (unfilled); a literal value is kept.
//   P2 — expertise group: numbered lines under `**Core Expertise Areas …:**`
//        become `expertise-area-N` fields.
//   Boundary — extraction STOPS at the `### Research Tooling` section, so the
//        deferred Research-MCP / Asset-Utilization prose (which uses the same
//        `**Label:** value` syntax for explainer text) cannot leak in as fields.
//
// Deferred to a later slice when the profile schema grows (§8): the checkbox
// preference groups (Goals, Tone, MCPs, Assets), Content Style Mix, and the
// Voice-Profile-Summary qualities — none of which match P1/P2.

const PROFILE_FIELD_KIND = "profile-field";

interface ExtractedField {
  label: string;
  value: string;
}

/** A leading `[...]` bracketed placeholder means the field is unfilled → empty. */
function placeholderToEmpty(raw: string): string {
  return /^\[[^\]]*\]/.test(raw) ? "" : raw;
}

/** Apply P1 + P2 over the template/instance text up to the Research-Tooling boundary. */
function extractFields(rawText: string): ExtractedField[] {
  const cutoff = rawText.search(/^###\s+Research Tooling\b/m);
  const text = cutoff === -1 ? rawText : rawText.slice(0, cutoff);

  const fields: ExtractedField[] = [];
  let inExpertise = false;
  let expertiseIndex = 0;

  for (const line of text.split("\n")) {
    // P2 anchor — the expertise group header (value-less by construction).
    if (/^\s*\*\*Core Expertise Areas\b.*:\*\*\s*$/.test(line)) {
      inExpertise = true;
      expertiseIndex = 0;
      continue;
    }
    if (inExpertise) {
      const item = line.match(/^\s*(\d+)\.\s+(.*)$/);
      if (item) {
        expertiseIndex += 1;
        fields.push({ label: `expertise-area-${expertiseIndex}`, value: placeholderToEmpty(item[2].trim()) });
        continue;
      }
      // A non-blank, non-numbered line closes the group; blanks keep it open.
      if (line.trim() !== "") inExpertise = false;
    }
    // P1 — labeled scalar.
    const m = line.match(/^\s*(?:- )?\*\*(.+?):\*\*\s*(.*)$/);
    if (m) {
      const value = m[2].trim();
      if (value === "") continue; // group header — owned by its sub-fields, not a fact
      fields.push({ label: m[1].trim(), value: placeholderToEmpty(value) });
    }
  }
  return fields;
}

/**
 * Fold the user-profile field-set into a two-layer `ProfileDoc.static`. The
 * TEMPLATE defines the canonical field-set; an optional runtime INSTANCE supplies
 * filled values; an optional EXISTING doc makes the fold idempotent.
 *
 * Lossless + idempotent (SC3):
 *  - source-absent → every template field present, placeholder values empty;
 *  - populated → each filled instance value carried onto the matching field;
 *  - re-run → no duplication, a non-empty value is never overwritten, only
 *    `last_seen` is bumped to `today`.
 *
 * Pure — the caller supplies the file texts and the run date (`today`).
 */
export function foldUserProfile(opts: {
  templateText: string;
  instanceText?: string;
  existing?: ProfileDoc;
  today: string;
}): ProfileDoc {
  const { templateText, instanceText, existing, today } = opts;

  const templateFields = extractFields(templateText);
  const instanceByLabel = new Map(
    (instanceText ? extractFields(instanceText) : []).map((f) => [f.label, f.value]),
  );
  const existingById = new Map((existing?.static ?? []).map((f) => [f.id, f]));

  const staticFacts: ProfileFact[] = templateFields.map((tf) => {
    const id = mintEntityId({ kind: PROFILE_FIELD_KIND, key: tf.label });
    const instanceValue = instanceByLabel.get(tf.label);
    // Prefer a filled instance value over the template's (possibly empty) value.
    const incomingValue = instanceValue !== undefined && instanceValue !== "" ? instanceValue : tf.value;

    const prev = existingById.get(id);
    if (prev) {
      // Idempotent merge: never overwrite a non-empty value; fill empties; bump last_seen.
      return { ...prev, value: prev.value !== "" ? prev.value : incomingValue, last_seen: today };
    }
    return {
      id,
      value: incomingValue,
      first_seen: today,
      last_seen: today,
      evidence_count: 0,
      provenance: "human",
      status: "active",
    };
  });

  return { schemaVersion: SCHEMA_VERSION, static: staticFacts, dynamic: existing?.dynamic ?? [] };
}
