/**
 * Store-layer specs — the decisions façade + the seven templates.
 *
 * Plain vitest + createPinia (no Nuxt runtime): the stores import defineStore
 * from 'pinia' and the engines/persistence relatively, so they run here as-is.
 * idb-keyval is mocked with an in-memory Map.
 */

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'

vi.mock('idb-keyval', () => {
  const store = new Map<string, unknown>()
  return {
    get: async (key: string) => store.get(key),
    set: async (key: string, value: unknown) => void store.set(key, value),
    del: async (key: string) => void store.delete(key),
    keys: async () => [...store.keys()],
  }
})

import { useCollectiveStore } from '../../app/stores/collective'
import { useDecisionsStore } from '../../app/stores/decisions'
import type { Refusal } from '../../app/stores/decisions'
import { buildTemplateBundle, TEMPLATE_CARDS } from '../../app/data/templates'
import type { TemplateId } from '../../app/data/templates'
import { validateBundle } from '../../app/data/persistence'
import { hasConsentProtocol, resolveSettings, windowOutcome } from '../../app/engine'
import type {
  Decision,
  Mandate,
  ParamSpec,
  Verdict,
  VoteSession,
  Vote,
} from '../../app/types/domain'

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

const MEMBERS = ['Alice', 'Bakir', 'Chloé', 'Dara']

function isRefusal(value: unknown): value is Refusal {
  return (
    typeof value === 'object'
    && value !== null
    && 'ok' in value
    && (value as { ok: unknown }).ok === false
  )
}

/** Narrow an `X | Refusal` return — throws the French reason on refusal. */
function ok<T>(value: T | Refusal): T {
  if (isRefusal(value)) throw new Error(value.reason)
  return value
}

async function setup(template: TemplateId = 'association') {
  setActivePinia(createPinia())
  const col = useCollectiveStore()
  const store = useDecisionsStore()
  const result = await col.createFromTemplate(template, {
    name: 'Le Test',
    slug: `test-${crypto.randomUUID().slice(0, 8)}`,
    color: '#3b82f6',
    icon: 'i-lucide-users',
    meName: 'Yvv',
    memberNames: MEMBERS,
  })
  expect(result.state).toBeDefined()
  const me = col.me!
  const root = col.circles.find(c => c.id === col.current!.collective.rootCircleId)!
  const person = (name: string) => col.people.find(p => p.displayName === name)!
  const protocolByMethod = (method: string) =>
    col.protocols.find(p => p.method === method)!
  return { col, store, me, root, person, protocolByMethod }
}

const buildOpts = (slug: string) => ({
  name: 'Gabarit',
  slug,
  color: '#0ea5e9',
  icon: 'i-lucide-users',
  meName: 'Yvv',
  memberNames: MEMBERS,
  now: '2026-08-11T10:00:00.000Z',
  newId: () => crypto.randomUUID(),
})

// ─────────────────────────────────────────────────────────────
// Solo — 2 gestures, adopted
// ─────────────────────────────────────────────────────────────

describe('solo path', () => {
  it('capture → triage → applyPath adopts in two mutations', async () => {
    const { store } = await setup()
    const decision = ok(store.capture('Acheter une bouilloire pour le local'))
    expect(decision.status).toBe('draft')
    expect(decision.scope.selfOnly).toBe(true)

    const verdict = ok(
      store.runTriage({
        title: decision.title,
        tags: decision.tags,
        scope: decision.scope,
        reversibility: 'easy',
        weight: 'light',
        urgent: false,
      }),
    ) as Verdict
    expect(verdict.route).toBe('solo')
    expect(verdict.rule).toBe('R2')

    const adopted = ok(store.applyPath(decision, verdict))
    expect(adopted.status).toBe('adopted')
    expect(adopted.decidedAt).toBeDefined()
    expect(adopted.windowEndsAt).toBeUndefined()
  })

  it('extracts #tags at capture — simple', async () => {
    const { store } = await setup()
    const decision = ok(store.capture('Réparer le vidéoprojecteur #matériel #salle'))
    expect(decision.tags).toEqual(['matériel', 'salle'])
  })
})

// ─────────────────────────────────────────────────────────────
// Mandate — window, Assent, windowOutcome
// ─────────────────────────────────────────────────────────────

describe('mandate path', () => {
  it('routes under my covering mandate, opens the window, adopts on explicit assent', async () => {
    const { col, store, me, root, person } = await setup()
    const now = col.now()
    const mandate: Mandate = {
      id: col.newId(),
      collectiveId: col.current!.collective.id,
      createdAt: now,
      updatedAt: now,
      title: 'Intendance du local',
      holderId: me.id,
      originDecisionId: col.newId(),
      domain: { circleIds: [root.id], tags: [] },
      startsAt: now,
      endsAt: '2100-01-01T00:00:00.000Z',
      electorCircleId: root.id,
      nominationMethod: 'consent',
      reports: [],
      status: 'active',
    }
    col.current!.mandates.push(mandate)

    const decision = ok(store.capture('Remplacer la serrure du local'))
    decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
    const verdict = ok(
      store.runTriage({
        title: decision.title,
        tags: decision.tags,
        scope: decision.scope,
        reversibility: 'costly',
        weight: 'binding',
        urgent: false,
      }),
    ) as Verdict
    expect(verdict.route).toBe('mandate')
    expect(verdict.rule).toBe('R0b')
    expect(verdict.windowHours).toBe(48)

    // The tunnel's chips travel through edits (the verdict carries none).
    ok(store.applyPath(decision, verdict, { reversibility: 'costly', weight: 'binding' }))
    expect(decision.status).toBe('objection')
    expect(decision.underMandateId).toBe(mandate.id)
    expect(decision.windowEndsAt).toBeDefined()
    // Computed concerns: the four members (the author is excluded).
    const concerns = col.concerns.filter(c => c.decisionId === decision.id)
    expect(concerns).toHaveLength(4)
    expect(concerns.every(c => c.origin === 'computed' && c.beforeSnapshot)).toBe(true)

    // Costly ⇒ never adopted by pure silence: extend, until a third-party assent.
    const ctx = ok(store.transitionContext(decision.id))
    expect(windowOutcome(decision, ctx)).toBe('extend')

    ok(store.assentTo(decision.id, person('Alice').id))
    const ctxAfter = ok(store.transitionContext(decision.id))
    expect(windowOutcome(decision, ctxAfter)).toBe('adopt')

    const moved = store.transition(decision.id, 'adopted')
    expect(moved.ok).toBe(true)
    expect(decision.status).toBe('adopted')
  })
})

// ─────────────────────────────────────────────────────────────
// Collective consent — objection blocks adoption
// ─────────────────────────────────────────────────────────────

describe('collective consent path', () => {
  it('opens a session, and a maintained objection prevents adoption', async () => {
    const { col, store, root, person } = await setup()
    const decision = ok(store.capture('Organiser une fête de quartier au local'))
    decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
    const verdict = ok(
      store.runTriage({
        title: decision.title,
        tags: decision.tags,
        scope: decision.scope,
        reversibility: 'costly',
        weight: 'light',
        urgent: false,
      }),
    ) as Verdict
    expect(verdict.route).toBe('collective')
    expect(verdict.rule).toBe('R5')

    ok(store.applyPath(decision, verdict))
    expect(decision.status).toBe('voting')
    const session = col.sessions.find(s => s.decisionId === decision.id)!
    // Arrested list: 4 computed concerned + the author.
    expect(session.corpusSize).toBe(5)
    expect(session.status).toBe('open')

    ok(store.objectTo(decision.id, 'content', 'La cour est trop petite.', person('Bakir').id))
    const closed = ok(store.closeSession(session))
    expect(closed.outcome).toBe('rejected')
    expect(decision.status).not.toBe('adopted')
  })

  it('boundary objection suspends the window countdown', async () => {
    const { store, person } = await setup()
    const decision = ok(store.capture('Changer le planning du ménage'))
    decision.status = 'objection'
    ok(store.objectTo(decision.id, 'boundary', 'Le cercle Cuisine a été oublié.', person('Chloé').id))
    expect(decision.windowSuspendedAt).toBeDefined()
    const refused = store.transition(decision.id, 'adopted')
    expect(refused.ok).toBe(false)
  })
})

// ─────────────────────────────────────────────────────────────
// Nuanced — full loop, clause application
// ─────────────────────────────────────────────────────────────

describe('nuanced session', () => {
  it('votes, tallies, closes and applies the amended clause automatically', async () => {
    const { col, store, root, person, protocolByMethod } = await setup()
    const nuanced = protocolByMethod('nuanced')
    const clauseA1 = col.clauses.find(c => c.code === 'A1')!
    const oldVersionId = clauseA1.currentVersionId!

    const decision = ok(store.capture('Donner une nouvelle boussole au collectif'))
    decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
    const verdict = ok(
      store.runTriage({
        title: decision.title,
        tags: decision.tags,
        scope: decision.scope,
        reversibility: 'costly',
        weight: 'light',
        urgent: false,
      }),
    ) as Verdict
    ok(
      store.applyPath(decision, verdict, {
        protocolId: nuanced.id,
        amendsClauseId: clauseA1.id,
        body: 'La convivialité rejoint nos deux boussoles.',
      }),
    )
    expect(decision.status).toBe('voting')
    const session = col.sessions.find(s => s.decisionId === decision.id)!

    // A negative nuance without comment is refused.
    const refused = store.castVote(session.id, { value: 1 })
    expect(isRefusal(refused)).toBe(true)

    ok(store.castVote(session.id, { value: 4 }))
    ok(store.castVote(session.id, { value: 5, asPersonId: person('Alice').id }))
    ok(store.castVote(session.id, { value: 3, asPersonId: person('Bakir').id }))

    const tallied = ok(store.tally(session))
    if (tallied.method !== 'nuanced') throw new Error('bad method')
    expect(tallied.result.total).toBe(3)
    expect(tallied.result.adopted).toBe(true)

    const closed = ok(store.closeSession(session))
    expect(closed.outcome).toBe('adopted')
    expect(decision.status).toBe('adopted')

    // Automatic application: new current version, old one superseded.
    const newVersion = col.versions.find(
      v => v.clauseId === clauseA1.id && v.status === 'current',
    )!
    expect(newVersion.decisionId).toBe(decision.id)
    expect(newVersion.content).toBe('La convivialité rejoint nos deux boussoles.')
    expect(clauseA1.currentVersionId).toBe(newVersion.id)
    expect(col.versions.find(v => v.id === oldVersionId)!.status).toBe('superseded')
  })

  it('re-vote supersedes — only the last active vote counts', async () => {
    const { col, store, root, protocolByMethod } = await setup()
    const decision = ok(store.capture('Peindre la façade en ocre'))
    decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
    const verdict = ok(
      store.runTriage({
        title: decision.title, tags: [], scope: decision.scope,
        reversibility: 'costly', weight: 'light', urgent: false,
      }),
    ) as Verdict
    ok(store.applyPath(decision, verdict, { protocolId: protocolByMethod('nuanced').id }))
    const session = col.sessions.find(s => s.decisionId === decision.id)!

    const first = ok(store.castVote(session.id, { value: 2 })) as Vote
    const second = ok(store.castVote(session.id, { value: 5 })) as Vote
    expect(second.supersedesVoteId).toBe(first.id)
    const active = store.activeVotes(session.id)
    expect(active).toHaveLength(1)
    expect(active[0]!.value).toBe(5)
  })
})

// ─────────────────────────────────────────────────────────────
// Parametric — frozen, then the steward's gesture
// ─────────────────────────────────────────────────────────────

describe('parametric session', () => {
  const spec: ParamSpec = {
    params: [
      { key: 'ateliers', label: 'Ateliers', kind: 'share', min: 0, max: 100, step: 5, baseline: 40 },
      { key: 'materiel', label: 'Matériel', kind: 'share', min: 0, max: 100, step: 5, baseline: 30 },
      { key: 'reserve', label: 'Réserve', kind: 'share', min: 0, max: 100, step: 5, derived: true },
    ],
    constraint: 'sum100',
  }

  async function openParametric() {
    const context = await setup()
    const { col, store, root, protocolByMethod, person } = context
    const decision = ok(store.capture('Répartir le budget des ateliers'))
    decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
    const verdict = ok(
      store.runTriage({
        title: decision.title, tags: [], scope: decision.scope,
        reversibility: 'costly', weight: 'light', urgent: false,
      }),
    ) as Verdict
    ok(
      store.applyPath(decision, verdict, {
        protocolId: protocolByMethod('parametric').id,
        paramSpec: spec,
        stewardIds: [person('Alice').id],
      }),
    )
    const session = col.sessions.find(s => s.decisionId === decision.id)!
    return { ...context, decision, session }
  }

  it('validates votes, freezes at closure, refuses a non-steward, crystallizes by gesture', async () => {
    const { store, person, me, decision, session } = await openParametric()

    // Off-grid vote refused (step 5).
    const bad = store.castVote(session.id, { values: [3, 30] })
    expect(isRefusal(bad)).toBe(true)

    ok(store.castVote(session.id, { values: [50, 30] }))
    ok(store.castVote(session.id, { values: [40, 40], asPersonId: person('Alice').id }))
    ok(store.castVote(session.id, { values: [60, 20], asPersonId: person('Bakir').id }))

    // Parametric NEVER closes automatically: it freezes and waits.
    const frozen = ok(store.closeSession(session))
    expect(frozen.status).toBe('frozen')
    expect(decision.status).toBe('voting')

    // I am not a steward (Alice is) — the gesture is refused.
    const refused = store.crystallize(session.id)
    expect(isRefusal(refused) && refused.reason.includes('garant')).toBe(true)

    // As a steward, the dated human gesture crystallizes the LOW median.
    decision.stewardIds = [me.id]
    const closed = ok(store.crystallize(session.id))
    expect(closed.status).toBe('closed')
    expect(closed.outcome).toBe('adopted')
    expect(closed.crystallizedById).toBe(me.id)
    expect(closed.crystallizedAt).toBeDefined()
    expect(decision.status).toBe('adopted')
    // Low medians: Ateliers 50, Matériel 30, Réserve derived 20.
    expect(decision.body).toContain('Position cristallisée')
    expect(decision.body).toContain('Ateliers : 50')
    expect(decision.body).toContain('Réserve : 20')
  })

  it('quorum not reached ⇒ rejected, observed at the same gesture', async () => {
    const { store, me, decision, session } = await openParametric()
    ok(store.castVote(session.id, { values: [50, 30] })) // 1 < parametricMinParticipants 3
    ok(store.closeSession(session))
    decision.stewardIds = [me.id]
    const closed = ok(store.crystallize(session.id))
    expect(closed.outcome).toBe('rejected')
    expect(decision.status).toBe('rejected')
  })
})

// ─────────────────────────────────────────────────────────────
// Election — blank votes, tie left to humans
// ─────────────────────────────────────────────────────────────

describe('election session', () => {
  it('accepts blanks, and a tie is NEVER broken by the tool', async () => {
    const { col, store, root, person, protocolByMethod } = await setup()
    const decision = ok(store.capture('Confier la trésorerie'))
    decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
    const verdict = ok(
      store.runTriage({
        title: decision.title, tags: [], scope: decision.scope,
        reversibility: 'costly', weight: 'light', urgent: false,
      }),
    ) as Verdict
    ok(store.applyPath(decision, verdict, { protocolId: protocolByMethod('election').id }))
    const session = col.sessions.find(s => s.decisionId === decision.id)!

    ok(store.castVote(session.id, { choicePersonId: person('Alice').id }))
    ok(store.castVote(session.id, {
      choicePersonId: person('Chloé').id, asPersonId: person('Bakir').id,
    }))
    // A deposited vote WITHOUT designation is a BLANK — participation only.
    const blank = ok(store.castVote(session.id, { asPersonId: person('Dara').id })) as Vote
    expect(blank.choicePersonId).toBeUndefined()

    const closed = ok(store.closeSession(session))
    expect(closed.outcome).toBe('tie')
    // The decision waits for the HUMAN runoff — never adopted by the engine.
    expect(decision.status).toBe('voting')

    const tallied = ok(store.tally(session))
    if (tallied.method !== 'election') throw new Error('bad method')
    if (tallied.result.outcome !== 'tie') throw new Error('expected tie')
    expect(tallied.result.blanks).toBe(1)
    expect(tallied.result.participants).toBe(3)
  })
})

// ─────────────────────────────────────────────────────────────
// L'épreuve du réel — revise chains a child
// ─────────────────────────────────────────────────────────────

describe('review', () => {
  it("'revise' chains a pre-filled child under the original protocol", async () => {
    const { col, store } = await setup()
    const decision = ok(store.capture('Vendre la vieille imprimante'))
    const verdict = ok(
      store.runTriage({
        title: decision.title, tags: decision.tags, scope: decision.scope,
        reversibility: 'irreversible', weight: 'light', urgent: false,
      }),
    ) as Verdict
    expect(verdict.reviewRequired).toBe(true)

    ok(store.applyPath(decision, verdict))
    expect(decision.status).toBe('adopted')
    expect(decision.review?.dueAt).toBeDefined()

    const child = ok(store.reviewVerdict(decision.id, 'revise', 'Elle sert encore.')) as Decision
    expect(decision.review?.verdict).toBe('revise')
    expect(child.parentDecisionId).toBe(decision.id)
    expect(child.chainKind).toBe('revision')
    expect(child.status).toBe('draft')
    expect(child.title).toBe(`Réviser — ${decision.title}`)
    expect(col.decisions.some(d => d.id === child.id)).toBe(true)
  })
})

// ─────────────────────────────────────────────────────────────
// Templates — the seven founding bundles
// ─────────────────────────────────────────────────────────────

describe('templates', () => {
  it('exposes the seven cards, blank first, standards re-labelled', () => {
    expect(TEMPLATE_CARDS).toHaveLength(7)
    expect(TEMPLATE_CARDS[0]!.id).toBe('blank')
    expect(TEMPLATE_CARDS[0]!.subtitle).toContain('observatoire')
    for (const id of ['informal', 'association', 'cooperative', 'commune', 'free-currency']) {
      const card = TEMPLATE_CARDS.find(c => c.id === id)!
      expect(card.subtitle).toBe('point de départ — tout est amendable par décision')
    }
    expect(TEMPLATE_CARDS.find(c => c.id === 'symmetric')!.title).toBe('Institution symétrique')
  })

  it('blank passes validateBundle and satisfies the consent invariant', () => {
    const bundle = buildTemplateBundle('blank', buildOpts('page-blanche'))
    const { bundle: validated, issues } = validateBundle(JSON.parse(JSON.stringify(bundle)))
    expect(validated).toBeDefined()
    expect(issues.filter(i => i.level === 'error')).toHaveLength(0)
    // Consent ONLY — the invariant satisfied, everything else falls back.
    expect(bundle.protocols).toHaveLength(1)
    expect(bundle.protocols[0]!.method).toBe('consent')
    const settings = resolveSettings(bundle.clauses, bundle.versions)
    expect(hasConsentProtocol(settings)).toBe(true)
    expect(settings.triage.requireEffects).toBe('none')
    expect(settings.protocolByRange.large).toBeUndefined()
  })

  it('builds the seven templates with every clause founded by an adopted decision', () => {
    const cards: TemplateId[] = TEMPLATE_CARDS.map(c => c.id)
    for (const id of cards) {
      const bundle = buildTemplateBundle(id, buildOpts(`t-${id}`))
      const { issues } = validateBundle(JSON.parse(JSON.stringify(bundle)))
      expect(issues.filter(i => i.level === 'error')).toHaveLength(0)

      // Every clause: a 'current' version born of an ADOPTED founding decision.
      for (const clause of bundle.clauses) {
        expect(clause.currentVersionId).toBeDefined()
        const version = bundle.versions.find(v => v.id === clause.currentVersionId)!
        expect(version.status).toBe('current')
        const foundingDecision = bundle.decisions.find(d => d.id === version.decisionId)!
        expect(foundingDecision.status).toBe('adopted')
      }

      // The first proposed decision, in the infinitive.
      expect(bundle.decisions.some(
        d => d.title === 'Adopter notre Pacte' && d.status === 'draft' && d.route === 'collective',
      )).toBe(true)

      const settings = resolveSettings(bundle.clauses, bundle.versions)
      expect(hasConsentProtocol(settings)).toBe(true)
    }
  })

  it('wires protocolByRange.large: nuanced for standards, inertial binary for free-currency', () => {
    const assoc = buildTemplateBundle('association', buildOpts('assoc'))
    const assocSettings = resolveSettings(assoc.clauses, assoc.versions)
    const assocLarge = assoc.protocols.find(p => p.id === assocSettings.protocolByRange.large)!
    expect(assocLarge.method).toBe('nuanced')
    expect(assocSettings.triage.requireEffects).toBe('binding')

    const g1 = buildTemplateBundle('free-currency', buildOpts('g1'))
    const g1Settings = resolveSettings(g1.clauses, g1.versions)
    const g1Large = g1.protocols.find(p => p.id === g1Settings.protocolByRange.large)!
    expect(g1Large.method).toBe('binary')
    expect(g1Large.modeParams).toBe('D30M50B.1G.2')
    expect(g1Large.description).toContain('héritage Toile de Confiance')

    const informal = buildTemplateBundle('informal', buildOpts('informel'))
    expect(resolveSettings(informal.clauses, informal.versions).triage.requireEffects).toBe('none')
  })

  it('symmetric seeds the declaration and the founding draft decision', () => {
    const bundle = buildTemplateBundle('symmetric', buildOpts('sym'))
    expect(bundle.collective.isTransparent).toBe(true)
    expect(bundle.docs.some(d => d.role === 'reference' && d.title === 'Déclaration de symétrie')).toBe(true)
    expect(bundle.decisions.some(
      d => d.title === 'Déclarer notre symétrie' && d.status === 'draft',
    )).toBe(true)
  })
})

// ─────────────────────────────────────────────────────────────
// Collective store — import path & refusals
// ─────────────────────────────────────────────────────────────

describe('collective store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('refuses an import colliding with an existing collective id', async () => {
    const col = useCollectiveStore()
    const bundle = buildTemplateBundle('blank', buildOpts('collision'))
    const first = await col.importJson(JSON.stringify(bundle), true)
    expect(first.state).toBeDefined()
    const second = await col.importJson(JSON.stringify(bundle), true)
    expect(second.collided).toBe(true)
    expect(second.issues.some(i => i.level === 'error' && i.message.includes('existe déjà'))).toBe(true)
  })

  it('resolves settings and the me profile from the active state', async () => {
    const col = useCollectiveStore()
    await col.createFromTemplate('commune', {
      name: 'La Commune', slug: `commune-${crypto.randomUUID().slice(0, 8)}`,
      color: '#16a34a', icon: 'i-lucide-landmark',
      meName: 'Yvv', memberNames: MEMBERS,
    })
    expect(col.me?.displayName).toBe('Yvv')
    expect(col.hasConsent).toBe(true)
    expect(col.settings?.triage.requireEffects).toBe('binding')
    expect(col.pactDoc?.description).toBe('Notre contrat social — sacralisé, jamais immuable')
    expect(col.exportJson()).toContain('"schemaVersion": 2')
  })

  it('actions refuse politely without an active collective', () => {
    const store = useDecisionsStore()
    const result = store.capture('Sans collectif')
    expect(isRefusal(result)).toBe(true)
  })

  it('loads the Atelier du Canal seed through the same import path', async () => {
    const col = useCollectiveStore()
    const result = await col.loadSeed('atelier-du-canal')
    // Idempotence guard: an earlier test may have already imported it — a
    // collision is then the expected polite refusal, never a crash.
    if (result.state) {
      expect(col.current?.collective.slug).toBe('atelier-du-canal')
      expect(col.hasConsent).toBe(true)
    } else {
      expect(result.collided).toBe(true)
    }
  })
})
