/**
 * Canonical ingress item schema + normalizer for the research engine (RE-R1, B1).
 *
 * A `TrendItem` is the ONE envelope every source/adapter emits before a trend reaches
 * the store — "the one schema downstream never branches on". This module validates +
 * normalizes that envelope deterministically (no AI, no network): required-field
 * validation, topic normalize + dedupe, optional publishedAt ISO validation. It is the
 * trend-side twin of the store's own normalize/dedupe discipline (scripts/trends/src/store.ts).
 *
 * Scope (RE-R1): the validated envelope + normalizer ONLY. The item->store bridge —
 * injecting the store's `capturedAt` and persisting `publishedAt` — is R2 orchestration
 * and lives in the CLI/agent layer, not here. The envelope carries NO `id`: the store
 * derives it via addTrend->trendId, so an id here would be a second source of truth.
 *
 * GENERIC BY ARCHITECTURE: nothing niche-specific lives here. Which topics matter and
 * which sources to poll are decided upstream (config/profile + the capture agent).
 */

import { normalizeField } from "./store.js";
import type { TrendInput } from "./store.js";
import { requiredDimensions, scoreEnvelope, capForActionability } from "./score.js";
import type { ScoreMode, DimensionScores } from "./score.js";
import type { TrendVerdict, Actionability, DemandSignal } from "./types.js";

export interface TrendItem {
  /** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */
  source: string;
  /** The trend headline, VERBATIM (case + spacing preserved — the store keeps it verbatim too). */
  title: string;
  /** The source URL, VERBATIM (case-sensitive paths must survive). */
  url: string;
  /**
   * The SOURCE's own publish date (ISO-8601), validated-if-present. Carried for
   * forward-compat (B4 freshness) — distinct from the store's `capturedAt`, and NOT
   * persisted in R1. Absent -> the key is omitted.
   */
  publishedAt?: string;
  /** Topic tags, normalized (lowercase + whitespace-collapsed via normalizeField) + deduped. */
  topics: string[];
  /** Optional short summary, VERBATIM. Absent/blank -> the key is omitted. */
  summary?: string;
  /**
   * The agent's relevance JUDGMENT (RE-R3a): the mode + the five 1–10 dimension scores —
   * NOT a precomputed composite (the store computes that, one owner). Validated by
   * normalizeItem; turned into the persisted envelope by itemToInput→scoreEnvelope.
   * Absent/invalid -> the key is omitted.
   */
  score?: { mode: ScoreMode; dimensions: DimensionScores };

  // ── N6 proposal fields. Free-text ones follow the `summary` idiom (blank -> key omitted, never an
  //    error); the two TYPED ones (verdict/actionability) hard-fail when present-but-malformed, like score. ──
  /** Proposed article angle (A1-4), VERBATIM. Blank/absent -> omitted. */
  angle?: string;
  /** Proposed target level on the user profile's own span — free string, never an enum. Blank/absent -> omitted. */
  targetLevel?: string;
  /** Why-now / applicability rationale (A1-4), VERBATIM. Blank/absent -> omitted. */
  rationale?: string;
  /** Related trend ids (A1-5): normalized to non-empty strings, deduped. Non-array/empty -> omitted. */
  relatedIds?: string[];
  /** Reader-grip signal (MR-F7). Present-but-malformed -> validation error. */
  actionability?: Actionability;
  /** Reader-side utility verdict (MR-F7). Present-but-out-of-vocab -> validation error. */
  verdict?: TrendVerdict;
  /** Reader's own question (MR-F9, filled downstream by N7.5). Blank/absent -> omitted. */
  readerQuestion?: string;
  /** Cost/risk/duty/tool the topic hits (MR-F9). Blank/absent -> omitted. */
  painPoint?: string;
  /** Market saturation judgment (MR-F9). Blank/absent -> omitted. */
  saturation?: string;
  /** Demand-sweep signal (MR-F9, N7.5). Present-but-malformed -> validation error (like actionability). */
  demand?: DemandSignal;
}

export type NormalizeResult = { ok: true; item: TrendItem } | { ok: false; errors: string[] };

/** One failed entry in a batch: its index in the input + the field errors. */
export interface ItemError {
  index: number;
  errors: string[];
}

const REQUIRED_FIELDS = ["source", "title", "url"] as const;

/** Strict ISO-8601: a calendar date (YYYY-MM-DD), optionally with a time/zone. Rejects impossible dates. */
function isValidIso(value: string): boolean {
  if (!/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$/.test(value)) {
    return false;
  }
  const ms = Date.parse(value);
  if (Number.isNaN(ms)) return false;
  // Round-trip the date part: catches 2026-02-30 / out-of-range that the regex lets through.
  return new Date(ms).toISOString().slice(0, 10) === value.slice(0, 10);
}

function isNonEmptyString(v: unknown): v is string {
  return typeof v === "string" && v.trim().length > 0;
}

/** A plain (non-array, non-null) object. */
function isPlainObject(v: unknown): v is Record<string, unknown> {
  return typeof v === "object" && v !== null && !Array.isArray(v);
}

const SCORE_MODES = ["kortform", "long-form"] as const;

/**
 * Validate a raw `score` structurally — never throws (returns a reason on failure). The
 * mode must be known; `dimensions` must be a non-array object carrying every key the mode
 * requires (requiredDimensions) as a number in [1,10]. On success returns the VALIDATED
 * envelope (the validated dimensions object, not the raw one).
 */
function validateScore(
  raw: unknown,
): { ok: true; score: { mode: ScoreMode; dimensions: DimensionScores } } | { ok: false; reason: string } {
  if (!isPlainObject(raw)) return { ok: false, reason: "score must be an object" };
  const mode = raw.mode;
  if (typeof mode !== "string" || !(SCORE_MODES as readonly string[]).includes(mode)) {
    return { ok: false, reason: `mode must be one of ${SCORE_MODES.join(", ")} (got ${String(mode)})` };
  }
  const dims = raw.dimensions;
  if (!isPlainObject(dims)) return { ok: false, reason: "dimensions must be an object" };
  const validated: DimensionScores = {};
  for (const key of requiredDimensions(mode as ScoreMode)) {
    const value = dims[key];
    if (typeof value !== "number" || Number.isNaN(value) || value < 1 || value > 10) {
      return { ok: false, reason: `dimension "${key}" must be a number in [1,10] (got ${String(value)})` };
    }
    validated[key] = value;
  }
  return { ok: true, score: { mode: mode as ScoreMode, dimensions: validated } };
}

/** The closed reader-side utility vocabulary (MR-F7) — mechanism, not niche calibration. */
const VERDICTS = ["BÆRENDE", "STØTTE", "NYHET"] as const;

/** The closed demand-strength vocabulary (MR-F9, N7.5) — the sweep's honest ordinal, not niche calibration. */
const DEMAND_STRENGTHS = ["strong", "moderate", "thin"] as const;

/**
 * Validate an optional `demand` (MR-F9, N7.5) — never throws. `strength` must be one of the closed
 * vocabulary; `answered` is a REQUIRED boolean (the rankable "is it already answered" the arc layer
 * reads). Hard-fail when present-but-malformed, like actionability — a bad payload is caught at the
 * seam, not persisted silently.
 */
function validateDemand(raw: unknown): { ok: true; value: DemandSignal } | { ok: false; reason: string } {
  if (!isPlainObject(raw)) return { ok: false, reason: "demand must be an object" };
  if (typeof raw.strength !== "string" || !(DEMAND_STRENGTHS as readonly string[]).includes(raw.strength)) {
    return { ok: false, reason: `demand.strength must be one of ${DEMAND_STRENGTHS.join(", ")} (got ${String(raw.strength)})` };
  }
  if (typeof raw.answered !== "boolean") {
    return { ok: false, reason: `demand.answered must be a boolean (got ${String(raw.answered)})` };
  }
  return { ok: true, value: { strength: raw.strength as DemandSignal["strength"], answered: raw.answered } };
}

/** The free-text proposal fields, all validated by the `summary` idiom (non-empty string -> kept verbatim, else omitted). */
const FREE_TEXT_FIELDS = ["angle", "targetLevel", "rationale", "readerQuestion", "painPoint", "saturation"] as const;

/**
 * Validate an optional `actionability` (MR-F7) — never throws. `formulated` is a REQUIRED boolean
 * (the yes/no the N7 band-cap gate reads); `note` is an optional string (blank -> omitted). Returns
 * the validated value (note carried verbatim only when non-blank).
 */
function validateActionability(raw: unknown): { ok: true; value: Actionability } | { ok: false; reason: string } {
  if (!isPlainObject(raw)) return { ok: false, reason: "actionability must be an object" };
  if (typeof raw.formulated !== "boolean") {
    return { ok: false, reason: `actionability.formulated must be a boolean (got ${String(raw.formulated)})` };
  }
  const value: Actionability = { formulated: raw.formulated };
  if (raw.note !== undefined && raw.note !== null) {
    if (typeof raw.note !== "string") return { ok: false, reason: "actionability.note must be a string" };
    if (raw.note.trim().length > 0) value.note = raw.note;
  }
  return { ok: true, value };
}

/**
 * Normalize related ids (A1-5): keep non-empty trimmed strings, first-seen dedupe. A non-array -> [].
 * Ids are carried VERBATIM (not lowercased like topics — they are content hashes, not tags), so a
 * caller-supplied id matches the store's `trendId` exactly.
 */
function normalizeIds(raw: unknown): string[] {
  if (!Array.isArray(raw)) return [];
  const out: string[] = [];
  const seen = new Set<string>();
  for (const v of raw) {
    if (typeof v !== "string") continue;
    const s = v.trim();
    if (s.length === 0 || seen.has(s)) continue;
    seen.add(s);
    out.push(s);
  }
  return out;
}

/** Normalize each topic via the store's normalizeField, drop blanks, dedupe (first-seen order). */
function normalizeTopics(raw: unknown): string[] {
  if (!Array.isArray(raw)) return [];
  const out: string[] = [];
  const seen = new Set<string>();
  for (const t of raw) {
    if (typeof t !== "string") continue;
    const norm = normalizeField(t);
    if (norm.length === 0 || seen.has(norm)) continue;
    seen.add(norm);
    out.push(norm);
  }
  return out;
}

/**
 * Validate + normalize one raw item into the canonical envelope. Pure. Returns a
 * structured error (never a silent partial) when a required field is missing/empty
 * or publishedAt is present-but-invalid.
 */
export function normalizeItem(raw: unknown): NormalizeResult {
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
    return { ok: false, errors: ["raw item must be an object"] };
  }
  const r = raw as Record<string, unknown>;
  const errors: string[] = [];

  for (const field of REQUIRED_FIELDS) {
    if (!isNonEmptyString(r[field])) {
      errors.push(`missing or empty required field: ${field}`);
    }
  }

  let publishedAt: string | undefined;
  if (r.publishedAt !== undefined && r.publishedAt !== null) {
    if (typeof r.publishedAt !== "string" || !isValidIso(r.publishedAt)) {
      errors.push(`invalid publishedAt (expected an ISO-8601 date): ${String(r.publishedAt)}`);
    } else {
      publishedAt = r.publishedAt;
    }
  }

  let score: { mode: ScoreMode; dimensions: DimensionScores } | undefined;
  if (r.score !== undefined && r.score !== null) {
    const res = validateScore(r.score);
    if (!res.ok) errors.push(`invalid score: ${res.reason}`);
    else score = res.score;
  }

  // N6 typed fields: hard-fail when present-but-malformed (like score), so a bad payload is caught
  // at the seam, not persisted silently.
  let actionability: Actionability | undefined;
  if (r.actionability !== undefined && r.actionability !== null) {
    const res = validateActionability(r.actionability);
    if (!res.ok) errors.push(`invalid actionability: ${res.reason}`);
    else actionability = res.value;
  }

  let verdict: TrendVerdict | undefined;
  if (r.verdict !== undefined && r.verdict !== null) {
    if (typeof r.verdict !== "string" || !(VERDICTS as readonly string[]).includes(r.verdict)) {
      errors.push(`invalid verdict: must be one of ${VERDICTS.join(", ")} (got ${String(r.verdict)})`);
    } else {
      verdict = r.verdict as TrendVerdict;
    }
  }

  let demand: DemandSignal | undefined;
  if (r.demand !== undefined && r.demand !== null) {
    const res = validateDemand(r.demand);
    if (!res.ok) errors.push(`invalid demand: ${res.reason}`);
    else demand = res.value;
  }

  if (errors.length > 0) return { ok: false, errors };

  // N6 free-text fields: the summary idiom (non-empty string kept verbatim, else the key is omitted).
  const freeText: Partial<Record<(typeof FREE_TEXT_FIELDS)[number], string>> = {};
  for (const f of FREE_TEXT_FIELDS) {
    if (isNonEmptyString(r[f])) freeText[f] = r[f] as string;
  }
  const relatedIds = normalizeIds(r.relatedIds);

  const item: TrendItem = {
    source: r.source as string,
    title: r.title as string,
    url: r.url as string,
    topics: normalizeTopics(r.topics),
    ...(publishedAt !== undefined ? { publishedAt } : {}),
    ...(isNonEmptyString(r.summary) ? { summary: r.summary as string } : {}),
    ...(score !== undefined ? { score } : {}),
    ...freeText,
    ...(relatedIds.length > 0 ? { relatedIds } : {}),
    ...(actionability !== undefined ? { actionability } : {}),
    ...(verdict !== undefined ? { verdict } : {}),
    ...(demand !== undefined ? { demand } : {}),
  };
  return { ok: true, item };
}

/**
 * Map a validated envelope to a store input (the item→store bridge RE-R1 deferred).
 * Pure: injects `capturedAt` (the store's "when WE saw it", supplied by the caller —
 * never derived here) and carries the rest verbatim. Does NOT re-validate (the item is
 * already validated by normalizeItem) and does NOT derive an `id` (the store owns id via
 * addTrend→trendId). `publishedAt`/`summary`/`score` are carried only when present (key
 * omitted otherwise), mirroring the store's conditional-spread idiom. The `score` is turned
 * into the persisted envelope here (judgment → composite, via scoreEnvelope), then run through
 * the F7 band-cap gate (`capForActionability`) so a candidate with no formulated reader-grip is
 * persisted at most `High`, not `Immediate` (MR-F7, N7). On the capture path the dims are
 * pre-validated by normalizeItem, so scoreEnvelope→composite cannot throw; called DIRECTLY with
 * bad dims it throws by contract (defense-in-depth — SC2).
 */
export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
  return {
    source: item.source,
    title: item.title,
    url: item.url,
    capturedAt,
    topics: [...item.topics],
    ...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}),
    ...(item.summary !== undefined ? { summary: item.summary } : {}),
    ...(item.score !== undefined
      ? { score: capForActionability(scoreEnvelope(item.score.mode, item.score.dimensions), item.actionability) }
      : {}),
    // N6 proposal fields carried through to the store input (validated already; key omitted when absent).
    ...(item.angle !== undefined ? { angle: item.angle } : {}),
    ...(item.targetLevel !== undefined ? { targetLevel: item.targetLevel } : {}),
    ...(item.rationale !== undefined ? { rationale: item.rationale } : {}),
    ...(item.relatedIds !== undefined ? { relatedIds: [...item.relatedIds] } : {}),
    ...(item.actionability !== undefined ? { actionability: item.actionability } : {}),
    ...(item.verdict !== undefined ? { verdict: item.verdict } : {}),
    ...(item.readerQuestion !== undefined ? { readerQuestion: item.readerQuestion } : {}),
    ...(item.painPoint !== undefined ? { painPoint: item.painPoint } : {}),
    ...(item.saturation !== undefined ? { saturation: item.saturation } : {}),
    ...(item.demand !== undefined ? { demand: item.demand } : {}),
  };
}

/** Partition a raw batch into normalized items + per-index errors (never throws). */
export function normalizeItems(raw: unknown[]): { items: TrendItem[]; errors: ItemError[] } {
  const items: TrendItem[] = [];
  const errors: ItemError[] = [];
  raw.forEach((entry, index) => {
    const res = normalizeItem(entry);
    if (res.ok) items.push(res.item);
    else errors.push({ index, errors: res.errors });
  });
  return { items, errors };
}
