/**
 * canTransition — the ONE state machine of the decision (Δ29).
 * Every guard (a–i) tested with positive AND negative cases + windowOutcome.
 */
import { describe, expect, it } from 'vitest'
import { TRANSITIONS, canTransition, windowOutcome } from '../../app/engine/state'
import type { TransitionContext } from '../../app/engine/state'
import type {
  Assent,
  CollectiveSettings,
  Concern,
  Decision,
  Objection,
  ParamSpec,
  VoteSession,
} from '../../app/types/domain'

const T0 = '2026-08-01T00:00:00.000Z'
const NOW = '2026-08-11T12:00:00.000Z'

let seq = 0

function makeEntity() {
  seq += 1
  return { id: `id-${seq}`, collectiveId: 'col-1', createdAt: T0, updatedAt: T0 }
}

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

function makeDecision(overrides: Partial<Decision> = {}): Decision {
  return {
    ...makeEntity(),
    id: 'dec-1',
    authorId: 'p-author',
    title: 'Décision de test',
    tags: [],
    reversibility: 'easy',
    weight: 'light',
    urgent: false,
    scope: { selfOnly: false, circleIds: ['circle-1'], personIds: [] },
    route: 'collective',
    triageRule: 'R5',
    routeOverridden: false,
    status: 'draft',
    stewardIds: [],
    measurerIds: [],
    visibility: 'scope',
    ...overrides,
  }
}

function makeConcern(origin: Concern['origin'], overrides: Partial<Concern> = {}): Concern {
  const entity = makeEntity()
  return {
    ...entity,
    decisionId: 'dec-1',
    personId: `p-${entity.id}`,
    origin,
    reason: 'membre du cercle',
    beforeSnapshot: true,
    ...overrides,
  }
}

function makeAssent(personId: string, overrides: Partial<Assent> = {}): Assent {
  return { ...makeEntity(), decisionId: 'dec-1', personId, ...overrides }
}

function makeObjection(status: Objection['status'], overrides: Partial<Objection> = {}): Objection {
  return {
    ...makeEntity(),
    decisionId: 'dec-1',
    personId: 'p-objector',
    kind: 'content',
    argument: 'Je maintiens mon désaccord.',
    status,
    ...overrides,
  }
}

function makeSession(overrides: Partial<VoteSession> = {}): VoteSession {
  return {
    ...makeEntity(),
    decisionId: 'dec-1',
    protocolId: 'proto-1',
    corpusPersonIds: ['p-1', 'p-2', 'p-3'],
    corpusSize: 3,
    opensAt: T0,
    closesAt: NOW,
    status: 'open',
    ...overrides,
  }
}

function makeCtx(overrides: Partial<TransitionContext> = {}): TransitionContext {
  return {
    concerns: [],
    settings: makeSettings(),
    assents: [],
    objections: [],
    now: NOW,
    ...overrides,
  }
}

const PARAM_SPEC: ParamSpec = {
  params: [
    { key: 'partA', label: 'Part ateliers', kind: 'share', min: 0, max: 100, step: 1 },
    { key: 'partB', label: 'Part réserve', kind: 'share', min: 0, max: 100, step: 1, derived: true },
  ],
  constraint: 'sum100',
}

/** 4 computed + 2 declared ⇒ ratio 0.5 atteint (2 ≥ 0.5 × 4). */
function influxConcerns(): Concern[] {
  return [
    makeConcern('computed'),
    makeConcern('computed'),
    makeConcern('computed'),
    makeConcern('computed'),
    makeConcern('declared'),
    makeConcern('declared'),
  ]
}

describe('La table des transitions', () => {
  it('draft ouvre les sept chemins', () => {
    expect(TRANSITIONS.draft).toEqual([
      'advice',
      'objection',
      'framing',
      'voting',
      'adopted',
      'transmitted',
      'rejected',
    ])
  })

  it('chaque état de travail connaît ses sorties', () => {
    expect(TRANSITIONS.advice).toEqual(['adopted', 'voting'])
    expect(TRANSITIONS.objection).toEqual(['adopted', 'framing', 'voting'])
    expect(TRANSITIONS.framing).toEqual(['voting', 'closed'])
    expect(TRANSITIONS.voting).toEqual(['adopted', 'rejected', 'framing'])
    expect(TRANSITIONS.adopted).toEqual(['revoked', 'closed'])
  })

  it('les états terminaux n’ont aucune sortie', () => {
    expect(TRANSITIONS.rejected).toBeUndefined()
    expect(TRANSITIONS.revoked).toBeUndefined()
    expect(TRANSITIONS.closed).toBeUndefined()
    expect(TRANSITIONS.transmitted).toBeUndefined()
  })
})

describe('Garde a — la transition inconnue est refusée', () => {
  it('adopted → voting n’existe pas', () => {
    const result = canTransition(makeDecision({ status: 'adopted' }), 'voting', makeCtx())
    expect(result.ok).toBe(false)
    if (!result.ok) expect(result.reason.length).toBeGreaterThan(0)
  })

  it('rejected est terminal — aucune sortie', () => {
    expect(canTransition(makeDecision({ status: 'rejected' }), 'adopted', makeCtx()).ok).toBe(false)
  })

  it('draft → advice existe', () => {
    const decision = makeDecision({ status: 'draft', route: 'advice' })
    expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true)
  })

  it('le retour explicite d’une fenêtre à son propre état est permis', () => {
    const decision = makeDecision({ status: 'objection', route: 'mandate' })
    expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true)
  })

  it('un état hors fenêtre ne boucle pas sur lui-même', () => {
    expect(canTransition(makeDecision({ status: 'voting' }), 'voting', makeCtx()).ok).toBe(false)
  })
})

describe('Garde b — la frontière contestée suspend toute sortie', () => {
  it('une fenêtre d’objection suspendue ne peut pas adopter', () => {
    const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW })
    const result = canTransition(decision, 'adopted', makeCtx())
    expect(result.ok).toBe(false)
    if (!result.ok) expect(result.reason).toContain('frontière')
  })

  it('une fenêtre d’avis suspendue ne peut pas non plus escalader vers le vote', () => {
    const decision = makeDecision({ status: 'advice', route: 'advice', windowSuspendedAt: NOW })
    expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(false)
  })

  it('le retour explicite au même état reste possible pendant la suspension', () => {
    const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW })
    expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true)
  })

  it('sans suspension, la fenêtre s’adopte normalement', () => {
    const decision = makeDecision({ status: 'objection', route: 'mandate' })
    expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(true)
  })

  it('la frontière prime sur l’affluence (ordre des gardes)', () => {
    const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW })
    const result = canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() }))
    expect(result.ok).toBe(false)
    if (!result.ok) expect(result.reason).toContain('frontière')
  })
})

describe('Garde c — l’affluence exige un traitement du périmètre', () => {
  it('ratio atteint sans scopeKeptNote — la session ne se clôt pas', () => {
    const decision = makeDecision({ status: 'voting' })
    const result = canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() }))
    expect(result.ok).toBe(false)
    if (!result.ok) {
      expect(result.reason).toBe(
        'Le périmètre déborde — élargis d’un cran ou motive publiquement son maintien.',
      )
    }
  })

  it('le maintien motivé publiquement (scopeKeptNote) débloque la clôture', () => {
    const decision = makeDecision({
      status: 'voting',
      scopeKeptNote: 'Le cercle Ateliers reste le bon périmètre : le budget est le sien.',
    })
    expect(canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() })).ok).toBe(true)
  })

  it('sous le ratio, la clôture passe', () => {
    const concerns = [
      makeConcern('computed'),
      makeConcern('computed'),
      makeConcern('computed'),
      makeConcern('computed'),
      makeConcern('declared'),
    ]
    const decision = makeDecision({ status: 'voting' })
    expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true)
  })

  it('sans concerné calculé, la garde ne se déclenche jamais', () => {
    const concerns = [makeConcern('declared'), makeConcern('declared'), makeConcern('declared')]
    const decision = makeDecision({ status: 'voting' })
    expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true)
  })

  it('la garde vaut aussi pour la fenêtre d’avis', () => {
    const decision = makeDecision({ status: 'advice', route: 'advice' })
    expect(canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() })).ok).toBe(false)
  })

  it('la garde vaut pour le rejet d’un vote — clore, c’est clore', () => {
    const decision = makeDecision({ status: 'voting' })
    expect(canTransition(decision, 'rejected', makeCtx({ concerns: influxConcerns() })).ok).toBe(false)
  })

  it('les concernés d’une autre décision ne comptent pas', () => {
    const concerns = influxConcerns().map((concern) => ({ ...concern, decisionId: 'dec-2' }))
    const decision = makeDecision({ status: 'voting' })
    expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true)
  })

  it('l’escalade vers framing ou voting reste ouverte — c’est le traitement, pas la clôture', () => {
    const decision = makeDecision({ status: 'objection', route: 'mandate' })
    expect(canTransition(decision, 'framing', makeCtx({ concerns: influxConcerns() })).ok).toBe(true)
  })
})

describe('Garde d — la matière (requireEffects) à l’ouverture de session collective', () => {
  const resources = { note: 'Deux heures par semaine pendant un mois' }

  it('« binding » + poids binding sans effet recherché — refus', () => {
    const decision = makeDecision({ status: 'draft', weight: 'binding', resources })
    const result = canTransition(decision, 'voting', makeCtx())
    expect(result.ok).toBe(false)
    if (!result.ok) expect(result.reason).toContain('effet recherché')
  })

  it('« binding » + poids binding avec un effet recherché — ouverture', () => {
    const decision = makeDecision({
      status: 'draft',
      weight: 'binding',
      resources,
      brief: { effects: [{ label: 'Réduire le temps de réunion' }] },
    })
    expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
  })

  it('« binding » + poids structural sans cible mesurable — refus', () => {
    const decision = makeDecision({
      status: 'draft',
      weight: 'structural',
      resources,
      brief: { effects: [{ label: 'Assainir le budget' }] },
    })
    const result = canTransition(decision, 'voting', makeCtx())
    expect(result.ok).toBe(false)
    if (!result.ok) expect(result.reason).toContain('cible')
  })

  it('« binding » + poids structural avec une cible — ouverture', () => {
    const decision = makeDecision({
      status: 'draft',
      weight: 'structural',
      resources,
      brief: { effects: [{ label: 'Assainir le budget', target: '≤ 400 € par mois' }] },
    })
    expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
  })

  it('« structural » : le poids binding n’est pas sous garde', () => {
    const decision = makeDecision({ status: 'draft', weight: 'binding', resources })
    const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'structural' }) })
    expect(canTransition(decision, 'voting', ctx).ok).toBe(true)
  })

  it('« structural » : le poids structural sans effet est refusé', () => {
    const decision = makeDecision({ status: 'draft', weight: 'structural', resources })
    const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'structural' }) })
    expect(canTransition(decision, 'voting', ctx).ok).toBe(false)
  })

  it('« none » : aucune exigence de matière, même structural', () => {
    const decision = makeDecision({ status: 'draft', weight: 'structural', resources })
    const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'none' }) })
    expect(canTransition(decision, 'voting', ctx).ok).toBe(true)
  })

  it('le poids light n’est jamais soumis à la matière', () => {
    const decision = makeDecision({ status: 'draft', weight: 'light' })
    expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
  })

  it('la garde est réservée à la route collective', () => {
    const decision = makeDecision({ status: 'draft', route: 'mandate', weight: 'binding', resources })
    expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
  })

  it('framing → voting est gardé de la même façon', () => {
    const decision = makeDecision({ status: 'framing', weight: 'structural', resources })
    expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(false)
  })
})

describe('Garde e — « Ce que ça engage » à l’ouverture de fenêtre ou de session', () => {
  it('poids binding sans note de ressources — la fenêtre d’objection ne s’ouvre pas', () => {
    const decision = makeDecision({ status: 'draft', route: 'mandate', weight: 'binding' })
    const result = canTransition(decision, 'objection', makeCtx())
    expect(result.ok).toBe(false)
    if (!result.ok) expect(result.reason).toContain('engage')
  })

  it('avec la note, la fenêtre s’ouvre', () => {
    const decision = makeDecision({
      status: 'draft',
      route: 'mandate',
      weight: 'binding',
      resources: { note: 'Une demi-journée de l’équipe accueil' },
    })
    expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true)
  })

  it('une note blanche ne compte pas', () => {
    const decision = makeDecision({
      status: 'draft',
      route: 'mandate',
      weight: 'binding',
      resources: { note: '   ' },
    })
    expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(false)
  })

  it('le poids light ouvre sans note', () => {
    const decision = makeDecision({ status: 'draft', route: 'advice', weight: 'light' })
    expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true)
  })

  it('la route solo est exemptée', () => {
    const decision = makeDecision({ status: 'draft', route: 'solo', weight: 'binding' })
    expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true)
  })

  it('la garde vit aussi à l’ouverture de session — après la matière', () => {
    const decision = makeDecision({
      status: 'draft',
      weight: 'binding',
      brief: { effects: [{ label: 'Un effet recherché' }] },
    })
    const result = canTransition(decision, 'voting', makeCtx())
    expect(result.ok).toBe(false)
    if (!result.ok) expect(result.reason).toContain('engage')
  })
})

describe('Garde f — l’accord explicite (Assent) hors du réversible', () => {
  it('easy : le silence vaut accord, la fenêtre s’adopte sans Assent', () => {
    const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
    expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(true)
  })

  it('costly sans Assent — la fenêtre se prolonge', () => {
    const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
    const result = canTransition(decision, 'adopted', makeCtx())
    expect(result.ok).toBe(false)
    if (!result.ok) {
      expect(result.reason).toBe('Il manque un accord explicite — la fenêtre se prolonge.')
    }
  })

  it('costly : l’accord de l’auteur seul ne suffit pas', () => {
    const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
    const ctx = makeCtx({ assents: [makeAssent('p-author')] })
    expect(canTransition(decision, 'adopted', ctx).ok).toBe(false)
  })

  it('costly : un accord d’un tiers concerné débloque l’adoption', () => {
    const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
    const ctx = makeCtx({ assents: [makeAssent('p-other')] })
    expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
  })

  it('irreversible sans Assent — refus aussi', () => {
    const decision = makeDecision({
      status: 'objection',
      route: 'mandate',
      reversibility: 'irreversible',
    })
    expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(false)
  })

  it('l’Assent d’une autre décision ne compte pas', () => {
    const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
    const ctx = makeCtx({ assents: [makeAssent('p-other', { decisionId: 'dec-2' })] })
    expect(canTransition(decision, 'adopted', ctx).ok).toBe(false)
  })
})

describe('Garde g — jamais d’adoption sur une objection ouverte', () => {
  it('une objection ouverte bloque l’adoption, même en easy', () => {
    const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
    const result = canTransition(decision, 'adopted', makeCtx({ objections: [makeObjection('open')] }))
    expect(result.ok).toBe(false)
    if (!result.ok) expect(result.reason).toContain('objection')
  })

  it('une objection retirée ne bloque plus', () => {
    const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
    const ctx = makeCtx({ objections: [makeObjection('withdrawn')] })
    expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
  })

  it('une objection intégrée ne bloque plus', () => {
    const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
    const ctx = makeCtx({ objections: [makeObjection('integrated')] })
    expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
  })

  it('l’Assent d’un tiers ne contourne pas une objection ouverte', () => {
    const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
    const ctx = makeCtx({ assents: [makeAssent('p-other')], objections: [makeObjection('open')] })
    const result = canTransition(decision, 'adopted', ctx)
    expect(result.ok).toBe(false)
    if (!result.ok) expect(result.reason).toContain('objection')
  })
})

describe('Garde h — la clôture du dossier découpé', () => {
  const parent = () => makeDecision({ id: 'dossier-1', status: 'framing' })

  function element(status: Decision['status'], overrides: Partial<Decision> = {}): Decision {
    const entity = makeEntity()
    return makeDecision({
      id: entity.id,
      parentDecisionId: 'dossier-1',
      chainKind: 'element',
      status,
      ...overrides,
    })
  }

  it('sans élément, le dossier ne se clôt pas', () => {
    const result = canTransition(parent(), 'closed', makeCtx({ children: [] }))
    expect(result.ok).toBe(false)
    if (!result.ok) expect(result.reason).toContain('dossier')
  })

  it('tous les éléments terminaux — le garant peut clore', () => {
    const children = [
      element('adopted'),
      element('rejected'),
      element('revoked'),
      element('closed'),
      element('transmitted'),
    ]
    expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(true)
  })

  it('un élément encore en vote retient le dossier', () => {
    const children = [element('adopted'), element('voting')]
    const result = canTransition(parent(), 'closed', makeCtx({ children }))
    expect(result.ok).toBe(false)
    if (!result.ok) expect(result.reason).toContain('en cours')
  })

  it('des enfants non-éléments ne font pas un dossier', () => {
    const children = [element('adopted', { chainKind: 'revision' })]
    expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(false)
  })

  it('l’élément d’un autre dossier ne compte pas', () => {
    const children = [element('adopted', { parentDecisionId: 'dossier-2' })]
    expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(false)
  })

  it('framing → voting du parent reste possible pendant que le dossier vit', () => {
    const decision = makeDecision({ id: 'dossier-1', status: 'framing', weight: 'light' })
    expect(canTransition(decision, 'voting', makeCtx({ children: [element('voting')] })).ok).toBe(true)
  })
})

describe('Garde i — la cristallisation attend le geste du garant', () => {
  const parametricDecision = () => makeDecision({ status: 'voting', paramSpec: PARAM_SPEC })

  it('session figée — pas d’adoption sans le geste', () => {
    const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
    const result = canTransition(parametricDecision(), 'adopted', ctx)
    expect(result.ok).toBe(false)
    if (!result.ok) {
      expect(result.reason).toBe('Les votes sont figés — la cristallisation attend son geste.')
    }
  })

  it('session close sans crystallizedById — le moteur ne cristallise jamais', () => {
    const ctx = makeCtx({ session: makeSession({ status: 'closed' }) })
    expect(canTransition(parametricDecision(), 'adopted', ctx).ok).toBe(false)
  })

  it('session close et geste daté — l’adoption passe', () => {
    const ctx = makeCtx({
      session: makeSession({ status: 'closed', crystallizedById: 'p-steward', crystallizedAt: NOW }),
    })
    expect(canTransition(parametricDecision(), 'adopted', ctx).ok).toBe(true)
  })

  it('le rejet (quorum non atteint) exige le même geste', () => {
    const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
    expect(canTransition(parametricDecision(), 'rejected', ctx).ok).toBe(false)
  })

  it('le rejet constaté au geste passe', () => {
    const ctx = makeCtx({
      session: makeSession({ status: 'closed', crystallizedById: 'p-steward', crystallizedAt: NOW }),
    })
    expect(canTransition(parametricDecision(), 'rejected', ctx).ok).toBe(true)
  })

  it('une session figée trahit le paramétrique même sans paramSpec', () => {
    const decision = makeDecision({ status: 'voting' })
    const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
    expect(canTransition(decision, 'adopted', ctx).ok).toBe(false)
  })

  it('une session non paramétrique se clôt automatiquement — pas de geste exigé', () => {
    const decision = makeDecision({ status: 'voting' })
    const ctx = makeCtx({ session: makeSession({ status: 'closed' }) })
    expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
  })

  it('sans session dans le contexte, la garde ne s’applique pas', () => {
    expect(canTransition(makeDecision({ status: 'voting' }), 'adopted', makeCtx()).ok).toBe(true)
  })
})

describe('windowOutcome — l’échéance des fenêtres d’objection', () => {
  it('easy — le silence vaut accord, la fenêtre s’adopte', () => {
    const decision = makeDecision({ status: 'objection', reversibility: 'easy' })
    expect(windowOutcome(decision, makeCtx())).toBe('adopt')
  })

  it('costly sans accord tiers — la fenêtre se prolonge d’un cran', () => {
    const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
    expect(windowOutcome(decision, makeCtx())).toBe('extend')
  })

  it('costly avec l’accord d’un tiers — la fenêtre s’adopte', () => {
    const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
    expect(windowOutcome(decision, makeCtx({ assents: [makeAssent('p-other')] }))).toBe('adopt')
  })

  it('l’accord de l’auteur seul ne compte pas', () => {
    const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
    expect(windowOutcome(decision, makeCtx({ assents: [makeAssent('p-author')] }))).toBe('extend')
  })

  it('frontière suspendue — la fenêtre attend, même en easy', () => {
    const decision = makeDecision({
      status: 'objection',
      reversibility: 'easy',
      windowSuspendedAt: NOW,
    })
    expect(windowOutcome(decision, makeCtx())).toBe('wait')
  })
})

describe('Garde j — reformuler (voting→framing) réservé au réglage collectif figé', () => {
  it('session figée non cristallisée — reformuler est permis', () => {
    const decision = makeDecision({ status: 'voting', route: 'collective' })
    const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
    expect(canTransition(decision, 'framing', ctx).ok).toBe(true)
  })

  it('session ouverte — reformuler est refusé', () => {
    const decision = makeDecision({ status: 'voting', route: 'collective' })
    const ctx = makeCtx({ session: makeSession({ status: 'open' }) })
    expect(canTransition(decision, 'framing', ctx).ok).toBe(false)
  })

  it('session cristallisée — reformuler est refusé', () => {
    const decision = makeDecision({ status: 'voting', route: 'collective' })
    const ctx = makeCtx({
      session: makeSession({ status: 'closed', crystallizedById: 'p-steward' }),
    })
    expect(canTransition(decision, 'framing', ctx).ok).toBe(false)
  })

  it('sans session — reformuler est refusé', () => {
    const decision = makeDecision({ status: 'voting', route: 'collective' })
    expect(canTransition(decision, 'framing', makeCtx()).ok).toBe(false)
  })
})
