/**
 * Deterministic triage scorer for the research engine (RE-R1, B2).
 *
 * Owns ONLY the arithmetic the SSOT (references/trend-scoring-modes.md) defines: the
 * per-mode weighted composite, the composite->band map, and the threshold triage.
 * Producing the five 1-10 dimension scores stays MODEL JUDGMENT by design — this module
 * never scores; it only combines + classifies + ranks. No AI, no network: pure and tested.
 *
 * SSOT discipline: the weights, the four band thresholds, and the ten band action strings
 * below MIRROR references/trend-scoring-modes.md (the human source of truth). score.test.ts
 * pins all three against the SSOT values so silent drift in any of them fails loudly. The
 * ORDERING of the weights is the signal; the exact percentages are a documented choice, not
 * a measured coefficient (SSOT "How to read this file").
 */

export type ScoreMode = "kortform" | "long-form";
export type DimensionScores = Record<string, number>;

/** kortform weights (SSOT "Mode: kortform"). Sigma = 1.0. */
export const KORTFORM_WEIGHTS = {
  pillar: 0.3,
  audience: 0.25,
  timing: 0.2,
  angle: 0.15,
  authority: 0.1,
} as const;

/** long-form weights (SSOT "Mode: long-form"). Sigma = 1.0. */
export const LONG_FORM_WEIGHTS = {
  pillar: 0.3,
  depth: 0.25,
  angle: 0.2,
  authority: 0.15,
  currency: 0.1,
} as const;

const WEIGHTS: Record<ScoreMode, Record<string, number>> = {
  kortform: KORTFORM_WEIGHTS,
  "long-form": LONG_FORM_WEIGHTS,
};

export type Priority = "Immediate" | "High" | "Medium" | "Low" | "Skip";

export interface Band {
  priority: Priority;
  kortformAction: string;
  longformAction: string;
}

/**
 * Composite->band map (SSOT "Composite -> action"). Descending by `min`; the first band
 * whose `min` the composite reaches wins. Thresholds + action strings are pinned by
 * score.test.ts against the SSOT, so any drift here fails the gate.
 */
const BANDS: ReadonlyArray<{ readonly min: number } & Band> = [
  { min: 8.0, priority: "Immediate", kortformAction: "Draft within 24h", longformAction: "Promote to the edition backlog now" },
  { min: 6.0, priority: "High", kortformAction: "Publish within 48–72h", longformAction: "Strong edition candidate — schedule it" },
  { min: 4.0, priority: "Medium", kortformAction: "Add to this week's calendar", longformAction: "Hold as a backlog candidate, revisit" },
  { min: 2.0, priority: "Low", kortformAction: "Note, skip for now", longformAction: "Park unless the angle sharpens" },
  { min: 0, priority: "Skip", kortformAction: "Off positioning", longformAction: "Off positioning" },
];

function round1(x: number): number {
  return Math.round(x * 10) / 10;
}

function toBand(b: { readonly min: number } & Band): Band {
  return { priority: b.priority, kortformAction: b.kortformAction, longformAction: b.longformAction };
}

/**
 * Weighted composite on the shared 0-10 scale, rounded to 1 decimal (the SSOT's display
 * granularity). Validates each of the mode's five dimensions in [1,10]; a missing or
 * out-of-range dimension throws — the scores are model output, and a bad one is a contract
 * violation, not a value to silently clamp.
 */
export function composite(scores: DimensionScores, mode: ScoreMode): number {
  const weights = WEIGHTS[mode];
  let sum = 0;
  for (const [dim, weight] of Object.entries(weights)) {
    const value = scores[dim];
    if (typeof value !== "number" || Number.isNaN(value) || value < 1 || value > 10) {
      throw new RangeError(`dimension "${dim}" must be a number in [1,10] (got ${String(value)})`);
    }
    sum += value * weight;
  }
  return round1(sum);
}

/** Map a composite to its priority band + the mode-specific action strings. */
export function band(composite: number): Band {
  for (const b of BANDS) {
    if (composite >= b.min) return toBand(b);
  }
  // composite < 0 (off the scale) — classify as Skip rather than throw; band is a classifier.
  return toBand(BANDS[BANDS.length - 1]);
}

/**
 * The persist-ready relevance envelope (RE-R3a): the agent's judgment (mode + the five
 * dimension scores) plus the deterministically-derived composite + priority band. Lives
 * in score.ts (the score domain owns it); types.ts imports it (one-way — score.ts imports
 * nothing internal, so no cycle).
 */
export interface TrendScore {
  mode: ScoreMode;
  dimensions: DimensionScores;
  composite: number;
  priority: Priority;
}

/**
 * The mode's five dimension keys, in SSOT weight-literal order. `normalizeItem` consumes
 * this as a membership set; score.test pins the order so a silent SSOT reorder fails.
 */
export function requiredDimensions(mode: ScoreMode): string[] {
  return Object.keys(WEIGHTS[mode]);
}

/**
 * Compose the persist-ready envelope from the agent's judgment: the composite is
 * `composite(dimensions, mode)` and the priority is `band(composite).priority` — the
 * existing pure functions stay the single owners (no new arithmetic). Throws via
 * `composite` on an out-of-range dimension (its contract).
 */
export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): TrendScore {
  const c = composite(dimensions, mode);
  return { mode, dimensions, composite: c, priority: band(c).priority };
}

/**
 * A structural view of the reader-grip signal the F7 gate reads — the `formulated` boolean
 * of types.ts `Actionability`, redeclared here so score.ts stays free of internal imports
 * (types.ts imports TrendScore from here; importing back would form a cycle). The real
 * `Actionability` is assignable to this by structure.
 */
export interface ActionabilitySignal {
  formulated: boolean;
}

/** Index of the `High` band in the SSOT ordering (BANDS is priority-descending — lower index outranks). */
const HIGH_BAND_INDEX = BANDS.findIndex((b) => b.priority === "High");

/** True when `p` sits strictly above the `High` band — the only bands the F7 gate can cap down. */
function aboveHigh(p: Priority): boolean {
  const i = BANDS.findIndex((b) => b.priority === p);
  return i !== -1 && i < HIGH_BAND_INDEX;
}

/**
 * F7 band-cap gate (MR-F7, N7). A candidate whose reader-grip is explicitly NOT formulated
 * (`actionability.formulated === false`) cannot occupy the top `Immediate` band regardless of
 * its composite — its priority is capped to at most `High`. This is the ONE place the
 * deterministic composite->band derivation is overridden, and only downward: composite,
 * dimensions, and mode are left untouched (the real score stays etterprøvbar; only the priority
 * label changes). Absent actionability (`undefined`) is left uncapped — legacy / un-annotated
 * records keep their derived band (the N6 fields are additive-optional, no migration). Pure; no
 * KTG values here — what counts as a formulated grip is the operator's reader-side judgment
 * carried by the N6 field, which this gate merely enforces.
 */
export function capForActionability(score: TrendScore, actionability?: ActionabilitySignal): TrendScore {
  if (actionability?.formulated === false && aboveHigh(score.priority)) {
    return { ...score, priority: "High" };
  }
  return score;
}

export interface TriageOptions {
  mode: ScoreMode;
  threshold: number;
}

export type Triaged<T> = T & { composite: number; band: Band };

/**
 * Score each candidate, keep composite >= threshold (ranked composite-desc), drop below
 * (also composite-desc). Each returned entry is annotated with its composite + band. Pure.
 */
export function triage<T extends { scores: DimensionScores }>(
  candidates: T[],
  opts: TriageOptions,
): { kept: Array<Triaged<T>>; dropped: Array<Triaged<T>> } {
  const annotated: Array<Triaged<T>> = candidates.map((c) => {
    const comp = composite(c.scores, opts.mode);
    return { ...c, composite: comp, band: band(comp) };
  });
  const byCompositeDesc = (a: Triaged<T>, b: Triaged<T>) => b.composite - a.composite;
  const kept = annotated.filter((a) => a.composite >= opts.threshold).sort(byCompositeDesc);
  const dropped = annotated.filter((a) => a.composite < opts.threshold).sort(byCompositeDesc);
  return { kept, dropped };
}
