/**
 * Routing engine — UI name « Le chemin » (BLUEPRINT-V2.md « Algorithme de triage »).
 *
 * Pure function triage(input, ctx, settings) → Verdict. No store, no I/O:
 * the store assembles TriageContext, the engine only reasons on it.
 * Rule codes (R-U, R0a…R6) NEVER reach the card: the UI shows the one-sentence
 * French explanation alone; codes live in the « pourquoi ? » disclosure,
 * the PV and the derogation journal.
 *
 * Rule order — first match wins:
 *   R-U (urgency overlay) · R0a already decided · R0b my mandate ·
 *   R0c someone else's mandate · R2 self only · R3 targeted clause ·
 *   R4 small reversible group · R5 collective default · R6 recurrence overlay.
 *
 * DOCUMENTED INTERPRETATION CHOICES:
 * - R0b comes BEFORE R2: a selfOnly decision whose scoped circles are covered
 *   by my mandate still routes 'mandate' (the mandate trace duty prevails).
 * - R0a yields ONLY when the author explicitly targets the clause
 *   (amendsClauseId set): contesting the rule IS R3, and R3 looks the clause
 *   up in ctx.matchingClauses — R0a fires for a bare match.
 * - Specified fallback (Δ10): protocolByRange.consent unresolvable ⇒ route
 *   'advice' with the « Aucun protocole » banner, prior to EVERYTHING except
 *   R2 (solo needs no protocol) and R0a (record needs no protocol).
 *   Its rule code is 'R5' — it degrades the collective default.
 * - Urgency refused (urgent && irreversible): the underlying route is kept
 *   with its MINIMAL windows (never doubled), rule 'R-U', refusal sentence.
 * - R6 is an overlay: it sets Verdict.suggestion but never changes route or
 *   rule; 'claim-mandate' wins over 'protocolize' when both thresholds match.
 */

import type {
  Clause,
  CollectiveSettings,
  Id,
  Mandate,
  TriageContext,
  TriageInput,
  Verdict,
} from '~/types/domain'
import {
  BINARY_COST,
  METHOD_LABELS,
  NO_PROTOCOL_BANNER,
  PARAMETRIC_ALT,
  RECORD_ALT,
  URGENT_BADGE,
  URGENT_REFUSED,
} from '~/lexicon'

// TODO-lexicon: alternative costs missing from app/lexicon.ts (frozen file) —
// move these two constants there once it thaws.
const PARAMETRIC_COST = 'formuler les curseurs avant de voter'
const RECORD_COST = 'aucune fenêtre — la décision est déjà prise'

/** Verdict before the permanent alternatives are attached. */
type BaseVerdict = Omit<Verdict, 'alternatives'>

// ─────────────────────────────────────────────────────────────
// Parametric hint — number / % / amount (€, DU) in the capture sentence
// ─────────────────────────────────────────────────────────────

const DIGIT_PERCENT_CURRENCY = /\d|%|€/
// Ğ1 universal dividend — uppercase only: the French article « du » never counts.
const DU_UNIT = /\bDU\b/

/** Detect a number, percentage or amount (€, DU, digits) in the title. */
export function detectParametricHint(title: string): boolean {
  return DIGIT_PERCENT_CURRENCY.test(title) || DU_UNIT.test(title)
}

// ─────────────────────────────────────────────────────────────
// Main entry
// ─────────────────────────────────────────────────────────────

export function triage(
  input: TriageInput,
  ctx: TriageContext,
  settings: CollectiveSettings,
): Verdict {
  const hint = detectParametricHint(input.title)

  let base = routeIgnoringUrgency(input, ctx, settings)

  // R-U — urgency overlay.
  if (input.urgent) {
    if (input.reversibility === 'irreversible') {
      // Refusal: normal route, MINIMAL windows (never doubled), no chain.
      base = { ...base, rule: 'R-U', explanation: URGENT_REFUSED }
    } else {
      // Conservatory: the lightest legitimate route NOW, doubled window,
      // chained ratification (created by the store when validating).
      base = {
        ...base,
        rule: 'R-U',
        explanation: `${URGENT_BADGE}.`,
        conservatoryChain: true,
        ...(base.windowHours !== undefined ? { windowHours: base.windowHours * 2 } : {}),
      }
    }
  }

  // R6 — recurrence & maturation overlay (never blocking, never re-routing).
  const suggestion = recurrenceOverlay(input, ctx, settings)

  return {
    ...base,
    ...(hint ? { parametricHint: true } : {}),
    ...(suggestion ? { suggestion } : {}),
    alternatives: buildAlternatives(hint),
  }
}

// ─────────────────────────────────────────────────────────────
// Rules R0a → R5 (urgency stripped)
// ─────────────────────────────────────────────────────────────

function routeIgnoringUrgency(
  input: TriageInput,
  ctx: TriageContext,
  settings: CollectiveSettings,
): BaseVerdict {
  // R0a — already decided. Skipped when the author explicitly targets the
  // clause (that is R3 — contesting the rule). Route 'record' proposed as
  // principal: act under the standing rule, and keep the trace.
  const matched = ctx.matchingClauses[0]
  if (matched && !input.amendsClauseId) {
    return {
      route: 'record',
      rule: 'R0a',
      explanation: `C'est déjà décidé (${matched.code}, ${matched.title}) — agis, ou conteste la règle.`,
      reviewRequired: false,
      engravingSuggested: false,
    }
  }

  // Specified fallback (Δ10): no consent protocol resolvable — prior to
  // everything except R2/record. Rule code 'R5': the collective default,
  // degraded to advice because the collective has no protocol yet.
  if (!settings.protocolByRange.consent) {
    if (input.scope.selfOnly) return r2SelfOnly(input)
    return {
      route: 'advice',
      rule: 'R5',
      explanation: `${NO_PROTOCOL_BANNER}.`,
      windowHours: settings.triage.adviceWindowHours,
      reviewRequired: false,
      engravingSuggested: false,
    }
  }

  // R0b — my mandate covers. Before R2: even selfOnly, the mandate trace
  // duty prevails. Empty scope.circleIds never counts as covered (a trivial
  // ⊆ match would hand every uncircled decision to the first mandate).
  const myMandate = coveringMandate(input, ctx.myActiveMandates)
  if (myMandate) {
    return {
      route: 'mandate',
      rule: 'R0b',
      explanation: `Ton mandat ${myMandate.title} couvre — décide, c'est tracé.`,
      windowHours: settings.triage.objectionWindowHours,
      reviewRequired: false,
      engravingSuggested: false,
    }
  }

  // R0c — someone else's mandate covers. TriageContext carries no person
  // names: the mandate TITLE names the power, the store resolves the holder.
  const otherMandate = coveringMandate(input, ctx.otherActiveMandates)
  if (otherMandate) {
    return {
      route: 'transmit',
      rule: 'R0c',
      explanation: `Le mandat ${otherMandate.title} couvre — transmets à sa ou son titulaire.`,
      reviewRequired: false,
      engravingSuggested: false,
    }
  }

  // R2 — self only.
  if (input.scope.selfOnly) return r2SelfOnly(input)

  // R3 — targeted clause: its inertia applies, vote of those it governs.
  if (input.amendsClauseId) return r3TargetedClause(input, ctx, settings)

  const n = ctx.computedConcernedIds.length

  // R4 — small reversible group: ask for advice, then decide.
  if (input.reversibility === 'easy' && n <= settings.triage.smallGroupMax) {
    const plural = n > 1 ? 's' : ''
    return {
      route: 'advice',
      rule: 'R4',
      explanation: `Réversible et ${n} personne${plural} concernée${plural} — demande leur avis puis décide.`,
      windowHours: settings.triage.adviceWindowHours,
      reviewRequired: false,
      engravingSuggested: false,
    }
  }

  // R5 — collective default, modality by perimeter size.
  return r5Collective(input, n, settings)
}

/** R2 — « Personne d'autre n'est concerné ». Zero window. On irreversible the
 * engine SUGGESTS a review (reviewRequired) — the UI shows it pre-checked but
 * REMOVABLE: on oneself the tool never interposes; the pre-check is a UI fact,
 * the engine only recommends. */
function r2SelfOnly(input: TriageInput): BaseVerdict {
  return {
    route: 'solo',
    rule: 'R2',
    explanation: 'Personne d\'autre n\'est concerné — décide.',
    reviewRequired: input.reversibility === 'irreversible',
    engravingSuggested: false,
  }
}

/** R3 — amendsClauseId set. Protocol resolved by the clause's inertia
 * (clauseByInertia), specified fallback → consent. The clause is looked up in
 * ctx.matchingClauses (the Q0 index); if absent, consent applies and the
 * sentence names « cette règle » instead of a code. */
function r3TargetedClause(
  input: TriageInput,
  ctx: TriageContext,
  settings: CollectiveSettings,
): BaseVerdict {
  const clause: Clause | undefined = ctx.matchingClauses.find(
    c => c.id === input.amendsClauseId,
  )
  const protocolId: Id
    = (clause && settings.protocolByRange.clauseByInertia?.[clause.inertia])
      ?? settings.protocolByRange.consent
  const name = clause ? clause.code : 'cette règle'
  return {
    route: 'collective',
    rule: 'R3',
    explanation: `Tu proposes une version de ${name} — son inertie s'applique : vote de ceux qu'elle gouverne.`,
    protocolId,
    ...(input.weight === 'structural' ? { framingDays: settings.triage.framingDays } : {}),
    reviewRequired: false,
    engravingSuggested: false,
  }
}

/** R5 — modality by computed perimeter size, protocols resolved by the Pact
 * with the specified fallback → consent. */
function r5Collective(
  input: TriageInput,
  n: number,
  settings: CollectiveSettings,
): BaseVerdict {
  const range = settings.protocolByRange
  let protocolId: Id
  let explanation: string

  if (n <= settings.triage.consentMax) {
    protocolId = range.consent
    explanation = `Vous êtes ${n} — un tour d'accord suffit : sans objection, c'est adopté.`
  } else if (n <= settings.triage.collectiveMin) {
    protocolId = range.nuanced ?? range.consent
    explanation = `Vous êtes ${n} — vote nuancé : chacun se prononce en nuances, pas en camps.`
  } else {
    protocolId = range.large ?? range.consent
    explanation = `Vous êtes ${n} — la modalité que votre Pacte a choisie s'applique.`
  }

  const structural = input.weight === 'structural'
  return {
    route: 'collective',
    rule: 'R5',
    explanation,
    protocolId,
    ...(structural ? { framingDays: settings.triage.framingDays } : {}),
    reviewRequired: structural || input.reversibility === 'irreversible',
    engravingSuggested: structural,
  }
}

// ─────────────────────────────────────────────────────────────
// R6 overlay & permanent alternatives
// ─────────────────────────────────────────────────────────────

/** R6 — recurrence & maturation. Overlay only: a suggestion, never a route.
 * 'claim-mandate' (recent adopted look-alikes) wins over 'protocolize'
 * (recorded look-alikes) when both thresholds are reached. */
function recurrenceOverlay(
  input: TriageInput,
  ctx: TriageContext,
  settings: CollectiveSettings,
): Verdict['suggestion'] | undefined {
  const threshold = settings.triage.recurrenceThreshold
  if (ctx.similarRecentCount >= threshold) {
    return {
      kind: 'claim-mandate',
      prefill: {
        title: input.title,
        domainTags: input.tags,
        domainCircleIds: input.scope.circleIds,
      },
    }
  }
  if (ctx.similarRecordedCount >= threshold) {
    return {
      kind: 'protocolize',
      prefill: { title: input.title, tags: input.tags },
    }
  }
  return undefined
}

/** The permanent « Je choisis autrement » alternatives — ALWAYS present.
 * Parametric comes FIRST when a number/%/amount was detected (parametricHint);
 * otherwise record leads (the observatory gesture stays one tap away). */
function buildAlternatives(parametricFirst: boolean): Verdict['alternatives'] {
  const parametric = {
    route: 'collective' as const,
    label: PARAMETRIC_ALT,
    cost: PARAMETRIC_COST,
  }
  const record = { route: 'record' as const, label: RECORD_ALT, cost: RECORD_COST }
  const binary = {
    route: 'collective' as const,
    label: METHOD_LABELS.binary,
    cost: BINARY_COST,
  }
  return parametricFirst ? [parametric, record, binary] : [record, parametric, binary]
}

// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────

/** A mandate covers when scope.circleIds is a NON-EMPTY subset of its
 * domain.circleIds. Defensive status filter: only 'active' mandates count. */
function coveringMandate(input: TriageInput, mandates: Mandate[]): Mandate | undefined {
  const { circleIds } = input.scope
  if (circleIds.length === 0) return undefined
  return mandates.find(
    m => m.status === 'active' && circleIds.every(id => m.domain.circleIds.includes(id)),
  )
}
