/**
 * Decisions store — the ACTION FAÇADE over collective.current.
 *
 * No own state: every read goes through useCollectiveStore(), every mutation
 * stamps the entity (LWW updatedAt) and persists (debounced). Actions return
 * the mutated object, or { ok: false, reason } with ONE French sentence whose
 * subject is the collective or the person — never the engine.
 *
 * EXPLICIT imports only (no Nuxt auto-imports) — testable under plain vitest.
 *
 * Mode atelier: the optional `asPersonId` of castVote / assentTo / objectTo /
 * adviseOn lets the seated person record someone else's gesture in presence;
 * the row then carries recordedById (« saisi par X pour Y »).
 *
 * DOCUMENTED SIMPLE HEURISTICS (runTriage):
 * - matchingClauses: a clause matches when its code (≥2 chars) or its title
 *   (≥4 chars) appears in the typed title (case/accent-insensitive), when a
 *   typed #tag equals its code, or when it is the explicitly targeted clause
 *   (amendsClauseId — R3 needs it in the index);
 * - similarRecentCount: adopted decisions of the last 90 days sharing ≥2 tags;
 * - similarRecordedCount: 'record' entries of the last 90 days sharing ≥2 tags.
 *
 * Vote corpus (openSession): computed concerns ∪ declared-before-snapshot
 * concerns ∪ THE AUTHOR (the author belongs to the arrested list).
 */

import { defineStore } from 'pinia'
import type {
  Advice,
  Assent,
  ClauseVersion,
  Concern,
  Decision,
  DecisionStatus,
  Id,
  ISODate,
  Mandate,
  MandateDraft,
  NominationMethod,
  Objection,
  Protocol,
  TriageInput,
  TriageContext,
  Verdict,
  VoteMethod,
  VoteSession,
  Vote,
} from '../types/domain'
import {
  canTransition,
  computeConcerned,
  crystallize as crystallizeVector,
  electionResult,
  medianByElement,
  nuancedResult,
  smithThreshold,
  techcommThreshold,
  triage,
  validateVote,
  wotThreshold,
  type ElectionOutcome,
  type NuancedResult,
  type TransitionContext,
  type TransitionResult,
} from '../engine'
import { sha256Hex } from '../data/persistence'
import { useCollectiveStore } from './collective'

// ─────────────────────────────────────────────────────────────
// Shared shapes
// ─────────────────────────────────────────────────────────────

export type Refusal = { ok: false; reason: string }
const refuse = (reason: string): Refusal => ({ ok: false, reason })

const NO_COLLECTIVE = 'Aucun collectif actif — ouvre ou crée un collectif d\'abord.'
const NO_DECISION = 'Cette décision est introuvable.'
const NO_SESSION = 'Cette session de vote est introuvable.'

/** User adjustments applied over the verdict when validating the path. */
export type ApplyPathEdits = Partial<
  Pick<
    Decision,
    | 'route' | 'scope' | 'reversibility' | 'weight' | 'urgent' | 'protocolId'
    | 'title' | 'body' | 'tags' | 'resources' | 'brief' | 'baselineNote'
    | 'amendsClauseId' | 'createsMandate' | 'paramSpec' | 'decidedHow'
    | 'stewardIds' | 'measurerIds' | 'visibility' | 'overrideNote' | 'sunsetAt'
  >
>

export interface CastVotePayload {
  value?: Vote['value']
  values?: number[]
  choicePersonId?: Id
  comment?: string
  /** Mode atelier — record the gesture for someone else (recordedById = me). */
  asPersonId?: Id
}

export type TallyResult =
  | { method: 'consent'; openObjections: number; assentCount: number; adopted: boolean }
  | {
      method: 'binary'
      votesFor: number
      votesAgainst: number
      total: number
      threshold: number
      smithMet: boolean
      techcommMet: boolean
      adopted: boolean
    }
  | { method: 'nuanced'; result: NuancedResult; adopted: boolean }
  | { method: 'parametric'; median: number[] }
  | { method: 'election'; result: ElectionOutcome }

/** Prefill returned by reopen() for /decider?parent= — NO mutation. */
export interface ReopenPrefill {
  parentDecisionId: Id
  chainKind: 'revision'
  title: string
  tags: string[]
  scope: Decision['scope']
  reversibility: Decision['reversibility']
  weight: Decision['weight']
  protocolId?: Id
  amendsClauseId?: Id
}

// ─────────────────────────────────────────────────────────────
// Pure helpers
// ─────────────────────────────────────────────────────────────

const HOUR_MS = 3_600_000
const DAY_MS = 86_400_000

function addHours(iso: ISODate, hours: number): ISODate {
  return new Date(new Date(iso).getTime() + hours * HOUR_MS).toISOString()
}
function addDays(iso: ISODate, days: number): ISODate {
  return new Date(new Date(iso).getTime() + days * DAY_MS).toISOString()
}

/** Case/accent-insensitive normalization (same folding as useSearch). */
function fold(text: string): string {
  return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}

/** #tag extraction — simple: letters, digits, dashes. */
const TAG_RE = /#([\p{L}\p{N}-]+)/gu
function extractTags(title: string): string[] {
  return [...title.matchAll(TAG_RE)].map(m => m[1]!.toLowerCase())
}

function sharedTagCount(a: string[], b: string[]): number {
  const set = new Set(a.map(fold))
  return b.reduce((n, tag) => n + (set.has(fold(tag)) ? 1 : 0), 0)
}

/** A negative position requires a spoken reason (against, 0, 1). */
function isNegative(value: Vote['value']): boolean {
  return value === 'against' || value === 0 || value === 1
}

/** Deep sort object keys — canonical JSON for the engraving fingerprint. */
function canonicalize(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(canonicalize)
  if (value !== null && typeof value === 'object') {
    const out: Record<string, unknown> = {}
    for (const key of Object.keys(value as Record<string, unknown>).sort()) {
      out[key] = canonicalize((value as Record<string, unknown>)[key])
    }
    return out
  }
  return value
}

const NOMINATION_BY_METHOD: Partial<Record<VoteMethod, NominationMethod>> = {
  election: 'election-no-candidate',
  consent: 'consent',
  nuanced: 'nuanced-vote',
}

// ─────────────────────────────────────────────────────────────
// The store
// ─────────────────────────────────────────────────────────────

export const useDecisionsStore = defineStore('decisions', {
  actions: {
    // ── Internal accessors ─────────────────────────────────────

    _col() {
      return useCollectiveStore()
    },

    _decision(decisionId: Id): Decision | undefined {
      return this._col().decisions.find(d => d.id === decisionId)
    },

    _session(sessionId: Id): VoteSession | undefined {
      return this._col().sessions.find(s => s.id === sessionId)
    },

    _protocolOf(session: VoteSession): Protocol | undefined {
      return this._col().protocols.find(p => p.id === session.protocolId)
    },

    /** Latest session of a decision (by opensAt). */
    _latestSessionOf(decisionId: Id): VoteSession | undefined {
      return this._col()
        .sessions.filter(s => s.decisionId === decisionId)
        .sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0]
    },

    /** Whether `personId` may perform the steward gestures on this decision. */
    _isSteward(decision: Decision, personId: Id): boolean {
      if (decision.stewardIds.length > 0) return decision.stewardIds.includes(personId)
      return decision.authorId === personId
    },

    /** Assemble the TransitionContext consumed by engine/state.ts. */
    transitionContext(decisionId: Id): TransitionContext | Refusal {
      const col = this._col()
      const settings = col.settings
      if (!col.current || !settings) return refuse(NO_COLLECTIVE)
      const context: TransitionContext = {
        concerns: col.concerns,
        settings,
        children: col.decisions.filter(d => d.parentDecisionId === decisionId),
        assents: col.assents.filter(a => a.decisionId === decisionId),
        objections: col.objections.filter(o => o.decisionId === decisionId),
        now: col.now(),
      }
      const session = this._latestSessionOf(decisionId)
      if (session) context.session = session
      return context
    },

    // ── ÉMERGENCE — the capture ────────────────────────────────

    /** One sentence → a draft. selfOnly by default; #tags extracted, simple. */
    capture(title: string): Decision | Refusal {
      const col = this._col()
      const me = col.me
      if (!col.current || !me) return refuse(NO_COLLECTIVE)
      const now = col.now()
      const decision: Decision = {
        id: col.newId(),
        collectiveId: col.current.collective.id,
        createdAt: now,
        updatedAt: now,
        authorId: me.id,
        title: title.trim(),
        tags: extractTags(title),
        reversibility: 'easy',
        weight: 'light',
        urgent: false,
        scope: { selfOnly: true, circleIds: [], personIds: [] },
        route: 'solo',
        triageRule: 'R2',
        routeOverridden: false,
        status: 'draft',
        stewardIds: [],
        measurerIds: [],
        visibility: 'private',
      }
      col.current.decisions.push(decision)
      col.persist()
      return decision
    },

    // ── TRIAGE — the path ──────────────────────────────────────

    /** Assemble TriageContext from the state and run the pure engine. */
    runTriage(input: TriageInput): Verdict | Refusal {
      const col = this._col()
      const me = col.me
      const settings = col.settings
      if (!col.current || !me || !settings) return refuse(NO_COLLECTIVE)

      const activeMandates = col.mandates.filter(m => m.status === 'active')
      const title = fold(input.title)
      const tagSet = new Set(input.tags.map(fold))

      const matchingClauses = col.clauses.filter((clause) => {
        if (clause.id === input.amendsClauseId) return true // R3 lookup
        const code = fold(clause.code)
        if (code.length >= 2 && title.includes(code)) return true
        const clauseTitle = fold(clause.title)
        if (clauseTitle.length >= 4 && title.includes(clauseTitle)) return true
        return tagSet.has(code)
      })

      const now = col.now()
      const since = addDays(now, -90)
      const recent = col.decisions.filter(d => (d.decidedAt ?? d.createdAt) >= since)
      const similarRecentCount = recent.filter(
        d => d.status === 'adopted' && d.route !== 'record'
          && sharedTagCount(d.tags, input.tags) >= 2,
      ).length
      const similarRecordedCount = recent.filter(
        d => d.route === 'record' && sharedTagCount(d.tags, input.tags) >= 2,
      ).length

      const ctx: TriageContext = {
        myActiveMandates: activeMandates.filter(m => m.holderId === me.id),
        otherActiveMandates: activeMandates.filter(m => m.holderId !== me.id),
        matchingClauses,
        similarRecentCount,
        similarRecordedCount,
        computedConcernedIds: computeConcerned(input.scope, col.circles, activeMandates, me.id)
          .map(entry => entry.personId),
      }
      return triage(input, ctx, settings)
    },

    /**
     * Validate the path: apply the verdict (then the user's edits) onto the
     * decision and move it to the starting status of its route.
     */
    applyPath(decision: Decision, verdict: Verdict, edits?: ApplyPathEdits): Decision | Refusal {
      const col = this._col()
      const me = col.me
      const settings = col.settings
      if (!col.current || !me || !settings) return refuse(NO_COLLECTIVE)
      const now = col.now()

      // Verdict first, user's edits second (the path recommends, never judges).
      decision.route = verdict.route
      decision.triageRule = verdict.rule
      if (verdict.protocolId !== undefined) decision.protocolId = verdict.protocolId
      Object.assign(decision, edits)
      decision.routeOverridden = edits?.route !== undefined && edits.route !== verdict.route

      if (verdict.windowHours !== undefined) {
        decision.windowEndsAt = addHours(now, verdict.windowHours)
      }
      if (verdict.reviewRequired && !decision.review) {
        decision.review = { dueAt: addDays(now, settings.triage.reviewDelayDays) }
      }

      // My covering mandate → the decision goes under its spotlights.
      if (decision.route === 'mandate') {
        const covering = col.mandates.find(
          m =>
            m.status === 'active'
            && m.holderId === me.id
            && decision.scope.circleIds.length > 0
            && decision.scope.circleIds.every(id => m.domain.circleIds.includes(id)),
        )
        if (covering) decision.underMandateId = covering.id
      }

      const createConcerns = () => {
        const activeMandates = col.mandates.filter(m => m.status === 'active')
        const entries = computeConcerned(
          decision.scope, col.circles, activeMandates, decision.authorId,
        )
        for (const entry of entries) {
          const concern: Concern = {
            id: col.newId(),
            collectiveId: decision.collectiveId,
            createdAt: now,
            updatedAt: now,
            decisionId: decision.id,
            personId: entry.personId,
            origin: 'computed',
            reason: entry.reason,
            beforeSnapshot: true,
          }
          col.current!.concerns.push(concern)
        }
      }

      // Conservatory urgency: decided NOW, the collective ratifies (chained).
      if (verdict.conservatoryChain) {
        createConcerns()
        decision.status = 'adopted'
        decision.decidedAt = now
        const child: Decision = {
          id: col.newId(),
          collectiveId: decision.collectiveId,
          createdAt: now,
          updatedAt: now,
          authorId: decision.authorId,
          title: `Ratifier — ${decision.title}`,
          tags: [...decision.tags],
          reversibility: decision.reversibility,
          weight: decision.weight,
          urgent: false,
          scope: {
            selfOnly: decision.scope.selfOnly,
            circleIds: [...decision.scope.circleIds],
            personIds: [...decision.scope.personIds],
          },
          route: 'collective',
          triageRule: 'R-U',
          routeOverridden: false,
          ...(decision.protocolId !== undefined ? { protocolId: decision.protocolId } : {}),
          parentDecisionId: decision.id,
          chainKind: 'ratification',
          status: 'draft',
          stewardIds: [...decision.stewardIds],
          measurerIds: [],
          visibility: decision.visibility,
        }
        col.current.decisions.push(child)
        col.stamp(decision)
        col.persist()
        return decision
      }

      switch (decision.route) {
        case 'solo':
          decision.status = 'adopted'
          decision.decidedAt = now
          break
        case 'record':
          decision.status = 'adopted'
          decision.decidedAt = now
          if (edits?.decidedHow !== undefined) decision.decidedHow = edits.decidedHow
          break
        case 'mandate':
          createConcerns()
          decision.status = 'objection'
          break
        case 'advice':
          createConcerns()
          decision.status = 'advice'
          break
        case 'collective': {
          createConcerns()
          if (verdict.framingDays !== undefined) {
            decision.status = 'framing'
            decision.windowEndsAt = addDays(now, verdict.framingDays)
          } else {
            const opened = this.openSession(decision)
            if ('ok' in opened) return opened
          }
          break
        }
        case 'transmit':
          decision.status = 'transmitted'
          break
      }

      col.stamp(decision)
      col.persist()
      return decision
    },

    // ── The ONE state machine gate ─────────────────────────────

    transition(decisionId: Id, to: DecisionStatus): TransitionResult {
      const col = this._col()
      const decision = this._decision(decisionId)
      if (!decision) return refuse(NO_DECISION)
      const ctx = this.transitionContext(decisionId)
      if ('ok' in ctx) return ctx

      const result = canTransition(decision, to, ctx)
      if (!result.ok) return result

      decision.status = to
      if (to === 'adopted') decision.decidedAt = ctx.now
      col.stamp(decision)
      col.persist()
      return result
    },

    // ── Windows: concerns, objections, assents, advices ────────

    /** « Ça me concerne » — a right, never a request. */
    declareConcern(decisionId: Id, personId: Id, note?: string): Concern | Refusal {
      const col = this._col()
      if (!col.current) return refuse(NO_COLLECTIVE)
      const decision = this._decision(decisionId)
      if (!decision) return refuse(NO_DECISION)
      const now = col.now()
      const concern: Concern = {
        id: col.newId(),
        collectiveId: decision.collectiveId,
        createdAt: now,
        updatedAt: now,
        decisionId,
        personId,
        origin: 'declared',
        reason: 'se déclare concerné·e',
        ...(note !== undefined ? { declaredNote: note } : {}),
        // Before the arrested list (no session yet) ⇒ vote; after ⇒ consultative.
        beforeSnapshot: !col.sessions.some(s => s.decisionId === decisionId),
      }
      col.current.concerns.push(concern)
      col.persist()
      return concern
    },

    /** « J'objecte » — boundary objections SUSPEND the countdown. */
    objectTo(
      decisionId: Id,
      kind: Objection['kind'],
      argument: string,
      asPersonId?: Id,
    ): Objection | Refusal {
      const col = this._col()
      const me = col.me
      if (!col.current || !me) return refuse(NO_COLLECTIVE)
      const decision = this._decision(decisionId)
      if (!decision) return refuse(NO_DECISION)
      if (argument.trim().length === 0) {
        return refuse('Une objection s\'argumente — écris pourquoi.')
      }
      const now = col.now()
      const personId = asPersonId ?? me.id
      const objection: Objection = {
        id: col.newId(),
        collectiveId: decision.collectiveId,
        createdAt: now,
        updatedAt: now,
        decisionId,
        personId,
        kind,
        argument: argument.trim(),
        status: 'open',
        ...(personId !== me.id ? { recordedById: me.id } : {}),
      }
      col.current.objections.push(objection)
      if (kind === 'boundary') {
        decision.windowSuspendedAt = now
        col.stamp(decision)
      }
      col.persist()
      return objection
    },

    /** « Ça me va » — the stored explicit agreement of objection windows. */
    assentTo(decisionId: Id, asPersonId?: Id): Assent | Refusal {
      const col = this._col()
      const me = col.me
      if (!col.current || !me) return refuse(NO_COLLECTIVE)
      const decision = this._decision(decisionId)
      if (!decision) return refuse(NO_DECISION)
      const now = col.now()
      const personId = asPersonId ?? me.id
      const assent: Assent = {
        id: col.newId(),
        collectiveId: decision.collectiveId,
        createdAt: now,
        updatedAt: now,
        decisionId,
        personId,
        ...(personId !== me.id ? { recordedById: me.id } : {}),
      }
      col.current.assents.push(assent)
      col.persist()
      return assent
    },

    /** Deposit an advice on an open advice window. */
    adviseOn(
      decisionId: Id,
      position: Advice['position'],
      note?: string,
      asPersonId?: Id,
    ): Advice | Refusal {
      const col = this._col()
      const me = col.me
      if (!col.current || !me) return refuse(NO_COLLECTIVE)
      const decision = this._decision(decisionId)
      if (!decision) return refuse(NO_DECISION)
      const now = col.now()
      const personId = asPersonId ?? me.id
      const advice: Advice = {
        id: col.newId(),
        collectiveId: decision.collectiveId,
        createdAt: now,
        updatedAt: now,
        decisionId,
        personId,
        position,
        ...(note !== undefined ? { note } : {}),
        ...(personId !== me.id ? { recordedById: me.id } : {}),
      }
      col.current.advices.push(advice)
      col.persist()
      return advice
    },

    // ── Sessions & votes ───────────────────────────────────────

    /**
     * Open the vote session: protocol resolved (decision.protocolId, fallback
     * consent), corpus ARRESTED = computed ∪ declared-before-snapshot ∪ author.
     */
    openSession(decision: Decision): VoteSession | Refusal {
      const col = this._col()
      const settings = col.settings
      if (!col.current || !settings) return refuse(NO_COLLECTIVE)

      const protocolId = decision.protocolId ?? settings.protocolByRange.consent
      const protocol = col.protocols.find(p => p.id === protocolId)
      if (!protocol) {
        return refuse('Aucun protocole — crée-le ou décide sur avis.')
      }

      const corpus = new Set<Id>([decision.authorId])
      for (const concern of col.concerns) {
        if (concern.decisionId !== decision.id) continue
        if (concern.origin === 'computed' || concern.beforeSnapshot) {
          corpus.add(concern.personId)
        }
      }

      const now = col.now()
      const session: VoteSession = {
        id: col.newId(),
        collectiveId: decision.collectiveId,
        createdAt: now,
        updatedAt: now,
        decisionId: decision.id,
        protocolId: protocol.id,
        corpusPersonIds: [...corpus],
        corpusSize: corpus.size,
        opensAt: now,
        closesAt: addDays(now, protocol.durationDays),
        status: 'open',
      }
      col.current.sessions.push(session)
      decision.protocolId = protocol.id
      decision.status = 'voting'
      col.stamp(decision)
      col.persist()
      return session
    },

    /** Last active votes of a session (supersedes chains resolved). */
    activeVotes(sessionId: Id): Vote[] {
      const votes = this._col().votes.filter(v => v.sessionId === sessionId)
      const superseded = new Set(
        votes.map(v => v.supersedesVoteId).filter((id): id is Id => id !== undefined),
      )
      return votes.filter(v => !superseded.has(v.id))
    },

    /**
     * Cast (or replace) a vote. EXACTLY ONE of value / values / choicePersonId
     * per method — except the election blank (none of the three). A negative
     * position (against, 0, 1) requires a comment.
     */
    castVote(sessionId: Id, payload: CastVotePayload): Vote | Refusal {
      const col = this._col()
      const me = col.me
      if (!col.current || !me) return refuse(NO_COLLECTIVE)
      const session = this._session(sessionId)
      if (!session) return refuse(NO_SESSION)
      if (session.status !== 'open') {
        return refuse('Cette session n\'accepte plus de vote — les positions sont figées.')
      }
      const protocol = this._protocolOf(session)
      if (!protocol) return refuse('Le protocole de cette session est introuvable.')

      const voterId = payload.asPersonId ?? me.id
      if (!session.corpusPersonIds.includes(voterId)) {
        return refuse('Tu n\'es pas dans la liste arrêtée — ta voix est consultative.')
      }

      const decision = this._decision(session.decisionId)
      const provided
        = Number(payload.value !== undefined)
          + Number(payload.values !== undefined)
          + Number(payload.choicePersonId !== undefined)

      switch (protocol.method) {
        case 'consent':
          return refuse('Le consentement ne se vote pas — dis « Ça me va » ou objecte.')
        case 'binary': {
          if (provided !== 1 || (payload.value !== 'for' && payload.value !== 'against')) {
            return refuse('Ce protocole attend une position pour ou contre — rien d\'autre.')
          }
          break
        }
        case 'nuanced': {
          const v = payload.value
          if (provided !== 1 || typeof v !== 'number' || !Number.isInteger(v) || v < 0 || v > 5) {
            return refuse('Ce protocole attend une nuance de 0 à 5 — rien d\'autre.')
          }
          break
        }
        case 'parametric': {
          if (provided !== 1 || payload.values === undefined) {
            return refuse('Ce protocole attend des positions de curseurs — rien d\'autre.')
          }
          if (!decision?.paramSpec) {
            return refuse('Cette décision n\'a pas de curseurs définis.')
          }
          try {
            validateVote(decision.paramSpec, payload.values)
          } catch (error) {
            return refuse(error instanceof Error ? error.message : 'Vote invalide.')
          }
          break
        }
        case 'election': {
          // A deposited vote WITHOUT designation is a BLANK — allowed.
          if (provided > 1 || payload.value !== undefined || payload.values !== undefined) {
            return refuse('Ce protocole attend une désignation — ou un vote blanc.')
          }
          if (
            payload.choicePersonId !== undefined
            && !session.corpusPersonIds.includes(payload.choicePersonId)
          ) {
            return refuse('La personne désignée ne fait pas partie de la liste arrêtée.')
          }
          break
        }
      }

      if (isNegative(payload.value) && !payload.comment?.trim()) {
        return refuse('Dis pourquoi — un commentaire accompagne toute position négative.')
      }

      const previous = this.activeVotes(sessionId).find(v => v.voterId === voterId)
      const now = col.now()
      const vote: Vote = {
        id: col.newId(),
        collectiveId: session.collectiveId,
        createdAt: now,
        updatedAt: now,
        sessionId,
        voterId,
        ...(payload.value !== undefined ? { value: payload.value } : {}),
        ...(payload.values !== undefined ? { values: payload.values } : {}),
        ...(payload.choicePersonId !== undefined
          ? { choicePersonId: payload.choicePersonId }
          : {}),
        ...(payload.comment !== undefined ? { comment: payload.comment } : {}),
        ...(previous ? { supersedesVoteId: previous.id } : {}),
        ...(voterId !== me.id ? { recordedById: me.id } : {}),
      }
      col.current.votes.push(vote)
      col.persist()
      return vote
    },

    /**
     * Tally a session from its last active votes — ALWAYS recomputed from
     * Vote[], never denormalized. Techcomm heuristic (documented): members of
     * a circle whose folded name contains « comite technique ».
     */
    tally(session: VoteSession): TallyResult | Refusal {
      const col = this._col()
      const protocol = this._protocolOf(session)
      if (!protocol) return refuse('Le protocole de cette session est introuvable.')
      const active = this.activeVotes(session.id)

      switch (protocol.method) {
        case 'consent': {
          const openObjections = col.objections.filter(
            o => o.decisionId === session.decisionId && o.status === 'open',
          ).length
          const assentCount = col.assents.filter(
            a => a.decisionId === session.decisionId,
          ).length
          return {
            method: 'consent', openObjections, assentCount, adopted: openObjections === 0,
          }
        }
        case 'binary': {
          const votesFor = active.filter(v => v.value === 'for').length
          const votesAgainst = active.filter(v => v.value === 'against').length
          const total = votesFor + votesAgainst
          const f = protocol.formula
          const threshold = wotThreshold(
            Math.max(session.corpusSize, 1), total,
            f.majorityPct, f.baseExponent, f.gradientExponent, f.constantBase,
          )
          let smithMet = true
          if (f.smithExponent !== undefined) {
            const smithIds = new Set(
              col.people.filter(p => p.wotStatus === 'smith').map(p => p.id),
            )
            const smithCorpus = session.corpusPersonIds.filter(id => smithIds.has(id))
            if (smithCorpus.length > 0) {
              const smithFor = active.filter(
                v => v.value === 'for' && smithIds.has(v.voterId),
              ).length
              smithMet = smithFor >= smithThreshold(smithCorpus.length, f.smithExponent)
            }
          }
          let techcommMet = true
          if (f.techcommExponent !== undefined) {
            const techCircle = col.circles.find(c => fold(c.name).includes('comite technique'))
            const techIds = new Set(techCircle?.memberIds ?? [])
            const techCorpus = session.corpusPersonIds.filter(id => techIds.has(id))
            if (techCorpus.length > 0) {
              const techFor = active.filter(
                v => v.value === 'for' && techIds.has(v.voterId),
              ).length
              techcommMet = techFor >= techcommThreshold(techCorpus.length, f.techcommExponent)
            }
          }
          return {
            method: 'binary',
            votesFor, votesAgainst, total, threshold, smithMet, techcommMet,
            adopted: votesFor >= threshold && smithMet && techcommMet,
          }
        }
        case 'nuanced': {
          const values: number[] = active
            .map(v => v.value)
            .filter((v): v is Exclude<Vote['value'], 'for' | 'against' | undefined> =>
              typeof v === 'number')
          // A formula without an explicit quorum imposes none (fallback 1) —
          // the engine's 59 default is a Ğ1 constant, not a template rule.
          const result = nuancedResult(
            values,
            protocol.formula.nuancedThresholdPct ?? 80,
            protocol.formula.nuancedMinParticipants ?? 1,
          )
          return { method: 'nuanced', result, adopted: result.adopted }
        }
        case 'parametric': {
          const vectors = active
            .map(v => v.values)
            .filter((v): v is number[] => v !== undefined)
          return { method: 'parametric', median: medianByElement(vectors) }
        }
        case 'election':
          return { method: 'election', result: electionResult(active, protocol.formula) }
      }
    },

    /**
     * Close a session at its deadline.
     * Parametric ⇒ 'frozen' (the steward crystallizes — a HUMAN gesture).
     * Election ⇒ automatic, except a tie stays 'tie' (humans break ties).
     * Others ⇒ outcome + decision transition + AUTOMATIC application
     * (clause version, mandate creation, parent revocation).
     */
    closeSession(session: VoteSession): VoteSession | Refusal {
      const col = this._col()
      if (!col.current) return refuse(NO_COLLECTIVE)
      if (session.status !== 'open') {
        return refuse('Cette session est déjà close ou figée.')
      }
      const protocol = this._protocolOf(session)
      if (!protocol) return refuse('Le protocole de cette session est introuvable.')
      const decision = this._decision(session.decisionId)
      if (!decision) return refuse(NO_DECISION)

      if (protocol.method === 'parametric') {
        session.status = 'frozen'
        col.stamp(session)
        col.persist()
        return session
      }

      const tallied = this.tally(session)
      if ('ok' in tallied) return tallied

      if (tallied.method === 'election') {
        session.status = 'closed'
        const res = tallied.result
        if (res.outcome === 'tie') {
          session.outcome = 'tie' // the tool NEVER breaks a tie
        } else if (res.outcome === 'elected') {
          session.outcome = 'adopted'
          const moved = this.transition(decision.id, 'adopted')
          if (moved.ok) this._applyAdoption(decision, res.winnerId)
        } else {
          session.outcome = 'rejected'
          this.transition(decision.id, 'rejected')
        }
        col.stamp(session)
        col.persist()
        return session
      }

      const adopted = tallied.method === 'parametric' ? false : tallied.adopted
      session.status = 'closed'
      session.outcome = adopted ? 'adopted' : 'rejected'
      const moved = this.transition(decision.id, adopted ? 'adopted' : 'rejected')
      if (adopted && moved.ok) this._applyAdoption(decision)
      col.stamp(session)
      col.persist()
      return session
    },

    /**
     * Automatic application at adoption — zero manual acceptance button:
     * - amendsClauseId ⇒ the linked ClauseVersion becomes 'current', the old
     *   one 'superseded' (the proposed version of this decision if it exists,
     *   otherwise a version created from the decision body);
     * - createsMandate ⇒ an active Mandate born of this decision;
     * - chainKind 'revocation' ⇒ the parent decision moves to 'revoked'.
     */
    _applyAdoption(decision: Decision, holderId?: Id): void {
      const col = this._col()
      if (!col.current) return
      const now = col.now()

      if (decision.amendsClauseId) {
        const clause = col.clauses.find(c => c.id === decision.amendsClauseId)
        if (clause) {
          const clauseVersions = col.versions.filter(v => v.clauseId === clause.id)
          let adoptedVersion = clauseVersions.find(
            v => v.decisionId === decision.id && v.status === 'proposed',
          )
          if (adoptedVersion) {
            adoptedVersion.status = 'current'
            adoptedVersion.adoptedAt = now
            col.stamp(adoptedVersion)
          } else {
            adoptedVersion = {
              id: col.newId(),
              collectiveId: decision.collectiveId,
              createdAt: now,
              updatedAt: now,
              clauseId: clause.id,
              decisionId: decision.id,
              versionLabel: `v${clauseVersions.length + 1}`,
              content: decision.body ?? decision.title,
              status: 'current',
              adoptedAt: now,
            } satisfies ClauseVersion
            // Cast: pushing into the reactive array re-triggers the
            // recursive-Json UnwrapRef explosion (settingValue) — same shape.
            ;(col.current.versions as unknown as ClauseVersion[]).push(adoptedVersion)
          }
          for (const version of clauseVersions) {
            if (version.id !== adoptedVersion.id && version.status === 'current') {
              version.status = 'superseded'
              col.stamp(version)
            }
          }
          clause.currentVersionId = adoptedVersion.id
          col.stamp(clause)
        }
      }

      if (decision.createsMandate) {
        const draft: MandateDraft = decision.createsMandate
        const protocol = col.protocols.find(p => p.id === decision.protocolId)
        const mandate: Mandate = {
          id: col.newId(),
          collectiveId: decision.collectiveId,
          createdAt: now,
          updatedAt: now,
          title: draft.title,
          holderId: holderId ?? decision.authorId,
          originDecisionId: decision.id,
          domain: { circleIds: [...draft.domainCircleIds], tags: [...draft.domainTags] },
          startsAt: now,
          endsAt: addDays(now, draft.durationDays),
          electorCircleId:
            decision.scope.circleIds[0] ?? col.current.collective.rootCircleId,
          nominationMethod:
            (protocol && NOMINATION_BY_METHOD[protocol.method]) ?? 'ratified-self',
          reports: draft.reportEveryDays !== undefined
            ? [{ dueAt: addDays(now, draft.reportEveryDays) }]
            : [],
          status: 'active',
        }
        col.current.mandates.push(mandate)
      }

      if (decision.chainKind === 'revocation' && decision.parentDecisionId) {
        this.transition(decision.parentDecisionId, 'revoked')
      }

      col.persist()
    },

    /**
     * THE GESTURE — the steward crystallizes a frozen parametric session:
     * dated, signed, never automatic. Quorum below parametricMinParticipants
     * ⇒ rejected, observed at the same gesture.
     */
    crystallize(sessionId: Id): VoteSession | Refusal {
      const col = this._col()
      const me = col.me
      if (!col.current || !me) return refuse(NO_COLLECTIVE)
      const session = this._session(sessionId)
      if (!session) return refuse(NO_SESSION)
      if (session.status !== 'frozen') {
        return refuse('Les votes ne sont pas figés — la cristallisation attend la clôture.')
      }
      const decision = this._decision(session.decisionId)
      if (!decision?.paramSpec) return refuse(NO_DECISION)
      if (!this._isSteward(decision, me.id)) {
        return refuse('Seul·e un·e garant·e de cette décision peut cristalliser.')
      }
      const protocol = this._protocolOf(session)
      if (!protocol) return refuse('Le protocole de cette session est introuvable.')

      const now = col.now()
      const vectors = this.activeVotes(sessionId)
        .map(v => v.values)
        .filter((v): v is number[] => v !== undefined)

      session.crystallizedById = me.id
      session.crystallizedAt = now
      session.status = 'closed'

      const quorum = protocol.formula.parametricMinParticipants
      if (quorum !== undefined && vectors.length < quorum) {
        session.outcome = 'rejected'
        col.stamp(session)
        this.transition(decision.id, 'rejected')
        col.persist()
        return session
      }

      const position = crystallizeVector(decision.paramSpec, vectors)
      const readable = decision.paramSpec.params
        .map((p, i) => `${p.label} : ${position[i] ?? '—'}${p.unit ? ` ${p.unit}` : ''}`)
        .join(' · ')
      decision.body
        = `${decision.body ? `${decision.body}\n\n` : ''}Position cristallisée : ${readable}`

      session.outcome = 'adopted'
      col.stamp(session)
      col.stamp(decision)
      const moved = this.transition(decision.id, 'adopted')
      if (moved.ok) this._applyAdoption(decision)
      col.persist()
      return session
    },

    // ── L'épreuve du réel ──────────────────────────────────────

    /**
     * « Ça tient / À revoir / À révoquer ». 'confirmed' closes the review
     * (a one-shot decision — sunset already past — moves to 'closed');
     * 'revise'/'revoke' pre-fills a chained child under the ORIGINAL protocol.
     */
    reviewVerdict(
      decisionId: Id,
      verdict: 'confirmed' | 'revise' | 'revoke',
      note?: string,
    ): Decision | Refusal {
      const col = this._col()
      const me = col.me
      if (!col.current || !me) return refuse(NO_COLLECTIVE)
      const decision = this._decision(decisionId)
      if (!decision) return refuse(NO_DECISION)
      if (!decision.review) {
        return refuse('Cette décision n\'a pas d\'épreuve du réel posée.')
      }
      const now = col.now()
      decision.review.verdict = verdict
      if (note !== undefined) decision.review.note = note
      decision.review.decidedAt = now
      col.stamp(decision)

      if (verdict === 'confirmed') {
        if (decision.sunsetAt !== undefined && decision.sunsetAt <= now) {
          this.transition(decision.id, 'closed')
        }
        col.persist()
        return decision
      }

      const chainKind = verdict === 'revise' ? 'revision' : 'revocation'
      const child: Decision = {
        id: col.newId(),
        collectiveId: decision.collectiveId,
        createdAt: now,
        updatedAt: now,
        authorId: me.id,
        title: `${verdict === 'revise' ? 'Réviser' : 'Révoquer'} — ${decision.title}`,
        tags: [...decision.tags],
        reversibility: decision.reversibility,
        weight: decision.weight,
        urgent: false,
        scope: {
          selfOnly: decision.scope.selfOnly,
          circleIds: [...decision.scope.circleIds],
          personIds: [...decision.scope.personIds],
        },
        route: 'collective',
        triageRule: decision.triageRule,
        routeOverridden: false,
        ...(decision.protocolId !== undefined ? { protocolId: decision.protocolId } : {}),
        ...(chainKind === 'revision' && decision.amendsClauseId !== undefined
          ? { amendsClauseId: decision.amendsClauseId }
          : {}),
        parentDecisionId: decision.id,
        chainKind,
        status: 'draft',
        stewardIds: [...decision.stewardIds],
        measurerIds: [...decision.measurerIds],
        visibility: decision.visibility,
      }
      col.current.decisions.push(child)
      col.persist()
      return child
    },

    /** « Remettre en question » — prefill for /decider?parent=, NO mutation. */
    reopen(decisionId: Id): ReopenPrefill | Refusal {
      const decision = this._decision(decisionId)
      if (!decision) return refuse(NO_DECISION)
      return {
        parentDecisionId: decision.id,
        chainKind: 'revision',
        title: decision.title,
        tags: [...decision.tags],
        scope: {
          selfOnly: decision.scope.selfOnly,
          circleIds: [...decision.scope.circleIds],
          personIds: [...decision.scope.personIds],
        },
        reversibility: decision.reversibility,
        weight: decision.weight,
        ...(decision.protocolId !== undefined ? { protocolId: decision.protocolId } : {}),
        ...(decision.amendsClauseId !== undefined
          ? { amendsClauseId: decision.amendsClauseId }
          : {}),
      }
    },

    // ── Preuve ─────────────────────────────────────────────────

    /** « empreinte locale — démo » : sha256 of the canonical decision JSON. */
    async engrave(decisionId: Id): Promise<Decision | Refusal> {
      const col = this._col()
      if (!col.current) return refuse(NO_COLLECTIVE)
      const decision = this._decision(decisionId)
      if (!decision) return refuse(NO_DECISION)
      const { engraving: _previous, ...fingerprintable } = decision
      const canonical = JSON.stringify(canonicalize(fingerprintable))
      decision.engraving = {
        sha256: await sha256Hex(canonical),
        engravedAt: col.now(),
        proofLevel: 'local',
      }
      col.stamp(decision)
      col.persist()
      return decision
    },
  },
})
