/**
 * Tests du moteur de routage — « Le chemin ».
 * Ordre des règles vérifié : R-U, R0a, R0b, R0c, R2, R3, R4, R5, R6.
 * Fonctions pures : fixtures assemblées à la main, aucun store.
 */
import { describe, expect, it } from 'vitest'
import { detectParametricHint, triage } from '../../app/engine/triage'
import {
  BINARY_COST,
  PARAMETRIC_ALT,
  RECORD_ALT,
  URGENT_REFUSED,
} from '../../app/lexicon'
import type {
  Clause,
  CollectiveSettings,
  Mandate,
  TriageContext,
  TriageInput,
} from '../../app/types/domain'

const T0 = '2026-01-01T00:00:00Z'
const T9 = '2027-01-01T00:00:00Z'

function mkSettings(over: {
  triage?: Partial<CollectiveSettings['triage']>
  protocolByRange?: Partial<CollectiveSettings['protocolByRange']>
} = {}): CollectiveSettings {
  return {
    triage: {
      smallGroupMax: 5,
      collectiveMin: 50,
      consentMax: 7,
      objectionWindowHours: 48,
      adviceWindowHours: 72,
      framingDays: 14,
      concernEscalateRatio: 0.5,
      recurrenceThreshold: 3,
      reviewDelayDays: 90,
      requireEffects: 'binding',
      ...over.triage,
    },
    protocolByRange: { consent: 'proto-consent', ...over.protocolByRange },
  }
}

function mkInput(over: Partial<TriageInput> = {}): TriageInput {
  return {
    title: 'Repeindre la salle',
    tags: [],
    scope: { selfOnly: false, circleIds: [], personIds: [] },
    reversibility: 'costly',
    weight: 'light',
    urgent: false,
    ...over,
  }
}

function mkCtx(over: Partial<TriageContext> = {}): TriageContext {
  return {
    myActiveMandates: [],
    otherActiveMandates: [],
    matchingClauses: [],
    similarRecentCount: 0,
    similarRecordedCount: 0,
    computedConcernedIds: ['p1', 'p2', 'p3'],
    ...over,
  }
}

function mkMandate(over: Partial<Mandate> = {}): Mandate {
  return {
    id: 'm1',
    collectiveId: 'col',
    createdAt: T0,
    updatedAt: T0,
    title: 'Modération',
    holderId: 'p-holder',
    originDecisionId: 'd0',
    domain: { circleIds: ['c1', 'c2'], tags: [] },
    startsAt: T0,
    endsAt: T9,
    electorCircleId: 'c1',
    nominationMethod: 'consent',
    reports: [],
    status: 'active',
    ...over,
  }
}

function mkClause(over: Partial<Clause> = {}): Clause {
  return {
    id: 'cl1',
    collectiveId: 'col',
    createdAt: T0,
    updatedAt: T0,
    docId: 'doc1',
    section: 'E',
    position: 9,
    code: 'E9',
    title: 'Acte Forgeron',
    inertia: 'high',
    ...over,
  }
}

const scoped = (circleIds: string[]): TriageInput['scope'] =>
  ({ selfOnly: false, circleIds, personIds: [] })

describe('detectParametricHint — nombre, %, montant dans la phrase', () => {
  it('détecte un montant en euros', () => {
    expect(detectParametricHint('Fixer la cotisation à 25 €')).toBe(true)
  })

  it('ne détecte rien dans une phrase sans chiffre', () => {
    expect(detectParametricHint('Repeindre la salle')).toBe(false)
  })

  it('détecte un pourcentage', () => {
    expect(detectParametricHint('Porter la part commune à douze %')).toBe(true)
  })

  it('détecte l\'unité DU en majuscules', () => {
    expect(detectParametricHint('Verser un DU aux nouveaux venus')).toBe(true)
  })

  it('ne confond pas l\'article « du » avec l\'unité DU', () => {
    expect(detectParametricHint('Changer du mobilier dans la salle')).toBe(false)
  })
})

describe('R-U — urgence', () => {
  it('conservatoire : route sous-jacente calculée sans urgence, fenêtre doublée, chaîne posée', () => {
    // Sans urgence, ce cas serait R4 (easy, 3 ≤ 5, fenêtre 72 h).
    const verdict = triage(
      mkInput({ urgent: true, reversibility: 'easy' }),
      mkCtx(),
      mkSettings(),
    )

    expect(verdict.rule).toBe('R-U')
    expect(verdict.route).toBe('advice')
    expect(verdict.conservatoryChain).toBe(true)
    expect(verdict.windowHours).toBe(144)
    expect(verdict.explanation).toBe('Décidé en urgence — le collectif ratifie.')
  })

  it('urgence × irréversible : refus — route normale, fenêtres minimales (jamais doublées)', () => {
    // Sans urgence, ce cas serait R0b (mandat couvrant, fenêtre 48 h).
    const verdict = triage(
      mkInput({ urgent: true, reversibility: 'irreversible', scope: scoped(['c1']) }),
      mkCtx({ myActiveMandates: [mkMandate()] }),
      mkSettings(),
    )

    expect(verdict.rule).toBe('R-U')
    expect(verdict.route).toBe('mandate')
    expect(verdict.explanation).toBe(URGENT_REFUSED)
    expect(verdict.conservatoryChain).toBeUndefined()
    expect(verdict.windowHours).toBe(48)
  })
})

describe('R0a — déjà décidé', () => {
  it('clause en vigueur trouvée : consigner en principal, agir ou contester', () => {
    const verdict = triage(mkInput(), mkCtx({ matchingClauses: [mkClause()] }), mkSettings())

    expect(verdict.rule).toBe('R0a')
    expect(verdict.route).toBe('record')
    expect(verdict.explanation).toBe(
      'C\'est déjà décidé (E9, Acte Forgeron) — agis, ou conteste la règle.',
    )
    expect(verdict.windowHours).toBeUndefined()
  })

  it('limite : viser explicitement la clause (amendsClauseId) passe à R3, pas R0a', () => {
    // Choix documenté : contester la règle EST R3 — R0a ne bloque pas l'amendement.
    const verdict = triage(
      mkInput({ amendsClauseId: 'cl1' }),
      mkCtx({ matchingClauses: [mkClause()] }),
      mkSettings(),
    )

    expect(verdict.rule).toBe('R3')
    expect(verdict.route).toBe('collective')
  })
})

describe('R0b — mon mandat couvre', () => {
  it('périmètre inclus dans le domaine du mandat : décide, c\'est tracé', () => {
    const verdict = triage(
      mkInput({ scope: scoped(['c1']) }),
      mkCtx({ myActiveMandates: [mkMandate()] }),
      mkSettings(),
    )

    expect(verdict.rule).toBe('R0b')
    expect(verdict.route).toBe('mandate')
    expect(verdict.windowHours).toBe(48)
    expect(verdict.explanation).toBe('Ton mandat Modération couvre — décide, c\'est tracé.')
  })

  it('limite : couverture partielle du périmètre ne suffit pas', () => {
    const verdict = triage(
      mkInput({ scope: scoped(['c1', 'c3']) }),
      mkCtx({ myActiveMandates: [mkMandate({ domain: { circleIds: ['c1', 'c2'], tags: [] } })] }),
      mkSettings(),
    )

    expect(verdict.rule).not.toBe('R0b')
  })

  it('ordre : selfOnly avec mandat couvrant ⇒ R0b gagne, car R0b précède R2', () => {
    // Choix documenté : le devoir de trace du mandat prime sur le solo.
    const verdict = triage(
      mkInput({ scope: { selfOnly: true, circleIds: ['c1'], personIds: [] } }),
      mkCtx({ myActiveMandates: [mkMandate()] }),
      mkSettings(),
    )

    expect(verdict.rule).toBe('R0b')
    expect(verdict.route).toBe('mandate')
  })

  it('limite : périmètre sans cercle ⇒ jamais de couverture triviale', () => {
    const verdict = triage(
      mkInput({ scope: scoped([]) }),
      mkCtx({ myActiveMandates: [mkMandate()] }),
      mkSettings(),
    )

    expect(verdict.rule).not.toBe('R0b')
  })
})

describe('R0c — le mandat d\'un autre couvre', () => {
  it('transmets à sa ou son titulaire — le titre du mandat nomme le pouvoir', () => {
    const verdict = triage(
      mkInput({ scope: scoped(['c1']) }),
      mkCtx({ otherActiveMandates: [mkMandate({ holderId: 'p-other' })] }),
      mkSettings(),
    )

    expect(verdict.rule).toBe('R0c')
    expect(verdict.route).toBe('transmit')
    expect(verdict.explanation).toBe(
      'Le mandat Modération couvre — transmets à sa ou son titulaire.',
    )
  })

  it('ordre : mon mandat gagne sur celui d\'un autre (R0b avant R0c)', () => {
    const verdict = triage(
      mkInput({ scope: scoped(['c1']) }),
      mkCtx({
        myActiveMandates: [mkMandate({ id: 'm-me', title: 'Trésorerie' })],
        otherActiveMandates: [mkMandate({ id: 'm-other', holderId: 'p-other' })],
      }),
      mkSettings(),
    )

    expect(verdict.rule).toBe('R0b')
  })

  it('limite : un mandat expiré ne couvre pas', () => {
    const verdict = triage(
      mkInput({ scope: scoped(['c1']) }),
      mkCtx({ otherActiveMandates: [mkMandate({ status: 'expired' })] }),
      mkSettings(),
    )

    expect(verdict.rule).not.toBe('R0c')
  })
})

describe('R2 — moi seul', () => {
  it('selfOnly : décide, zéro fenêtre', () => {
    const verdict = triage(
      mkInput({ scope: { selfOnly: true, circleIds: [], personIds: [] } }),
      mkCtx({ computedConcernedIds: [] }),
      mkSettings(),
    )

    expect(verdict.rule).toBe('R2')
    expect(verdict.route).toBe('solo')
    expect(verdict.explanation).toBe('Personne d\'autre n\'est concerné — décide.')
    expect(verdict.windowHours).toBeUndefined()
    expect(verdict.reviewRequired).toBe(false)
  })

  it('limite : irréversible sur soi ⇒ revoyure suggérée (pré-coche retirable côté UI)', () => {
    // Le moteur suggère (reviewRequired) ; la pré-coche retirable est un fait
    // d'UI — sur soi, l'outil ne s'interpose jamais.
    const verdict = triage(
      mkInput({
        scope: { selfOnly: true, circleIds: [], personIds: [] },
        reversibility: 'irreversible',
      }),
      mkCtx({ computedConcernedIds: [] }),
      mkSettings(),
    )

    expect(verdict.rule).toBe('R2')
    expect(verdict.reviewRequired).toBe(true)
  })
})

describe('R3 — clause visée', () => {
  const clauseSettings = mkSettings({
    protocolByRange: {
      clauseByInertia: {
        low: 'proto-low',
        standard: 'proto-standard',
        high: 'proto-high',
        max: 'proto-max',
      },
    },
  })

  it('l\'inertie de la clause choisit le protocole', () => {
    const verdict = triage(
      mkInput({ amendsClauseId: 'cl1' }),
      mkCtx({ matchingClauses: [mkClause({ inertia: 'high' })] }),
      clauseSettings,
    )

    expect(verdict.rule).toBe('R3')
    expect(verdict.route).toBe('collective')
    expect(verdict.protocolId).toBe('proto-high')
    expect(verdict.explanation).toBe(
      'Tu proposes une version de E9 — son inertie s\'applique : vote de ceux qu\'elle gouverne.',
    )
  })

  it('repli : sans clauseByInertia, le consentement s\'applique', () => {
    const verdict = triage(
      mkInput({ amendsClauseId: 'cl1' }),
      mkCtx({ matchingClauses: [mkClause()] }),
      mkSettings(),
    )

    expect(verdict.protocolId).toBe('proto-consent')
  })

  it('poids structurel ⇒ fenêtre de formulation posée', () => {
    const verdict = triage(
      mkInput({ amendsClauseId: 'cl1', weight: 'structural' }),
      mkCtx({ matchingClauses: [mkClause()] }),
      clauseSettings,
    )

    expect(verdict.framingDays).toBe(14)
  })

  it('limite : clause introuvable dans l\'index ⇒ consentement et phrase générique', () => {
    const verdict = triage(
      mkInput({ amendsClauseId: 'cl-inconnue' }),
      mkCtx({ matchingClauses: [] }),
      clauseSettings,
    )

    expect(verdict.rule).toBe('R3')
    expect(verdict.protocolId).toBe('proto-consent')
    expect(verdict.explanation).toBe(
      'Tu proposes une version de cette règle — son inertie s\'applique : vote de ceux qu\'elle gouverne.',
    )
  })
})

describe('R4 — petit cercle réversible', () => {
  it('réversible et 3 personnes : demande leur avis puis décide (72 h)', () => {
    const verdict = triage(mkInput({ reversibility: 'easy' }), mkCtx(), mkSettings())

    expect(verdict.rule).toBe('R4')
    expect(verdict.route).toBe('advice')
    expect(verdict.windowHours).toBe(72)
    expect(verdict.explanation).toBe(
      'Réversible et 3 personnes concernées — demande leur avis puis décide.',
    )
  })

  it('limite : accord du singulier pour 1 personne', () => {
    const verdict = triage(
      mkInput({ reversibility: 'easy' }),
      mkCtx({ computedConcernedIds: ['p1'] }),
      mkSettings(),
    )

    expect(verdict.explanation).toBe(
      'Réversible et 1 personne concernée — demande leur avis puis décide.',
    )
  })

  it('limite : au-delà de smallGroupMax, l\'escalade collective s\'applique', () => {
    const verdict = triage(
      mkInput({ reversibility: 'easy' }),
      mkCtx({ computedConcernedIds: ['p1', 'p2', 'p3', 'p4', 'p5', 'p6'] }),
      mkSettings(),
    )

    expect(verdict.rule).toBe('R5')
  })

  it('limite : coûteux à défaire ⇒ jamais R4, même à 3 personnes', () => {
    const verdict = triage(mkInput({ reversibility: 'costly' }), mkCtx(), mkSettings())

    expect(verdict.rule).toBe('R5')
  })
})

describe('R5 — collectif, modalité par taille du périmètre', () => {
  const ids = (n: number): string[] => Array.from({ length: n }, (_, i) => `p${i}`)
  const fullSettings = mkSettings({
    protocolByRange: { nuanced: 'proto-nuanced', large: 'proto-large' },
  })

  it('n ≤ consentMax : un tour d\'accord suffit', () => {
    const verdict = triage(
      mkInput(),
      mkCtx({ computedConcernedIds: ids(6) }),
      fullSettings,
    )

    expect(verdict.rule).toBe('R5')
    expect(verdict.route).toBe('collective')
    expect(verdict.protocolId).toBe('proto-consent')
    expect(verdict.explanation).toBe(
      'Vous êtes 6 — un tour d\'accord suffit : sans objection, c\'est adopté.',
    )
  })

  it('n ≤ collectiveMin : vote nuancé', () => {
    const verdict = triage(
      mkInput(),
      mkCtx({ computedConcernedIds: ids(23) }),
      fullSettings,
    )

    expect(verdict.protocolId).toBe('proto-nuanced')
    expect(verdict.explanation).toBe(
      'Vous êtes 23 — vote nuancé : chacun se prononce en nuances, pas en camps.',
    )
  })

  it('repli : sans protocole nuancé, le consentement s\'applique', () => {
    const verdict = triage(mkInput(), mkCtx({ computedConcernedIds: ids(23) }), mkSettings())

    expect(verdict.protocolId).toBe('proto-consent')
  })

  it('n > collectiveMin : la modalité choisie par le Pacte', () => {
    const verdict = triage(
      mkInput(),
      mkCtx({ computedConcernedIds: ids(60) }),
      fullSettings,
    )

    expect(verdict.protocolId).toBe('proto-large')
    expect(verdict.explanation).toBe(
      'Vous êtes 60 — la modalité que votre Pacte a choisie s\'applique.',
    )
  })

  it('repli : sans protocole large, le consentement s\'applique', () => {
    const verdict = triage(mkInput(), mkCtx({ computedConcernedIds: ids(60) }), mkSettings())

    expect(verdict.protocolId).toBe('proto-consent')
  })

  it('poids structurel ⇒ formulation + revoyure + gravure suggérée', () => {
    const verdict = triage(
      mkInput({ weight: 'structural' }),
      mkCtx({ computedConcernedIds: ids(6) }),
      fullSettings,
    )

    expect(verdict.framingDays).toBe(14)
    expect(verdict.reviewRequired).toBe(true)
    expect(verdict.engravingSuggested).toBe(true)
  })

  it('irréversible ⇒ revoyure exigée, sans gravure automatique', () => {
    const verdict = triage(
      mkInput({ reversibility: 'irreversible' }),
      mkCtx({ computedConcernedIds: ids(6) }),
      fullSettings,
    )

    expect(verdict.reviewRequired).toBe(true)
    expect(verdict.engravingSuggested).toBe(false)
  })
})

describe('R6 — récurrence et maturation (surcouche, jamais bloquante)', () => {
  it('3 décisions similaires récentes ⇒ suggère de réclamer un mandat, sans changer la route', () => {
    const verdict = triage(
      mkInput({ tags: ['cuisine', 'achats'] }),
      mkCtx({ similarRecentCount: 3 }),
      mkSettings(),
    )

    expect(verdict.rule).toBe('R5')
    expect(verdict.suggestion?.kind).toBe('claim-mandate')
  })

  it('3 consignations similaires ⇒ suggère de protocoliser la pratique', () => {
    const verdict = triage(mkInput(), mkCtx({ similarRecordedCount: 3 }), mkSettings())

    expect(verdict.suggestion?.kind).toBe('protocolize')
  })

  it('les deux seuils atteints ⇒ réclamer un mandat passe en premier', () => {
    const verdict = triage(
      mkInput(),
      mkCtx({ similarRecentCount: 3, similarRecordedCount: 3 }),
      mkSettings(),
    )

    expect(verdict.suggestion?.kind).toBe('claim-mandate')
  })

  it('sous le seuil ⇒ aucune suggestion', () => {
    const verdict = triage(
      mkInput(),
      mkCtx({ similarRecentCount: 2, similarRecordedCount: 2 }),
      mkSettings(),
    )

    expect(verdict.suggestion).toBeUndefined()
  })
})

describe('Repli sans protocole de consentement (bundle corrompu)', () => {
  const broken = mkSettings({ protocolByRange: { consent: '' } })

  it('route sur avis avec la bannière — prioritaire même sur un mandat couvrant', () => {
    const verdict = triage(
      mkInput({ scope: scoped(['c1']) }),
      mkCtx({ myActiveMandates: [mkMandate()] }),
      broken,
    )

    expect(verdict.route).toBe('advice')
    expect(verdict.explanation).toBe('Aucun protocole — crée-le ou décide sur avis.')
    expect(verdict.windowHours).toBe(72)
  })

  it('sauf R2 : moi seul décide sans protocole', () => {
    const verdict = triage(
      mkInput({ scope: { selfOnly: true, circleIds: [], personIds: [] } }),
      mkCtx(),
      broken,
    )

    expect(verdict.rule).toBe('R2')
    expect(verdict.route).toBe('solo')
  })

  it('sauf consigner : une clause en vigueur reste consignable sans protocole', () => {
    const verdict = triage(mkInput(), mkCtx({ matchingClauses: [mkClause()] }), broken)

    expect(verdict.rule).toBe('R0a')
    expect(verdict.route).toBe('record')
  })
})

describe('Alternatives permanentes — « Je choisis autrement »', () => {
  it('toujours présentes : réglage, consigner, binaire avec son coût', () => {
    const verdict = triage(mkInput(), mkCtx(), mkSettings())

    expect(verdict.alternatives).toHaveLength(3)
    const labels = verdict.alternatives.map(a => a.label)
    expect(labels).toContain(PARAMETRIC_ALT)
    expect(labels).toContain(RECORD_ALT)
    const binary = verdict.alternatives.find(a => a.cost === BINARY_COST)
    expect(binary).toBeDefined()
    expect(binary?.route).toBe('collective')
  })

  it('présentes sur toutes les routes, solo et consigner comprises', () => {
    const solo = triage(
      mkInput({ scope: { selfOnly: true, circleIds: [], personIds: [] } }),
      mkCtx(),
      mkSettings(),
    )
    const record = triage(mkInput(), mkCtx({ matchingClauses: [mkClause()] }), mkSettings())

    expect(solo.alternatives).toHaveLength(3)
    expect(record.alternatives).toHaveLength(3)
  })

  it('montant détecté ⇒ le réglage passe en premier et parametricHint est posé', () => {
    const verdict = triage(
      mkInput({ title: 'Fixer la cotisation à 25 €' }),
      mkCtx(),
      mkSettings(),
    )

    expect(verdict.parametricHint).toBe(true)
    expect(verdict.alternatives[0]?.label).toBe(PARAMETRIC_ALT)
  })

  it('sans montant ⇒ pas de mise en avant du réglage', () => {
    const verdict = triage(mkInput(), mkCtx(), mkSettings())

    expect(verdict.parametricHint).toBeUndefined()
    expect(verdict.alternatives[0]?.label).not.toBe(PARAMETRIC_ALT)
  })
})
