# DDD Hexagonal Architecture — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Refactorer le monolithe `src/index.html` (3130 lignes) en architecture DDD hexagonale avec CQRS, TypeScript strict, Vite, et tests complets (unit, functional, regression).

**Architecture:** Domain-Driven Design avec couches strictes (Domain → Application → Infrastructure → Presentation). Le domaine est pur TypeScript sans dépendance. Les ports (interfaces) sont définis dans le domaine, implémentés dans l'infrastructure. CQRS sépare les commands (écriture) des queries (lecture). Injection de dépendances par constructeur.

**Tech Stack:** Vite + vite-plugin-electron, TypeScript strict, Vitest, Electron 32

---

## Phase 1 — Setup tooling

### Task 1: Initialiser Vite + TypeScript + Vitest

**Files:**
- Create: `vite.config.ts`
- Create: `vitest.config.ts`
- Create: `tsconfig.json`
- Modify: `package.json`
- Create: `src/renderer/index.ts` (point d'entrée renderer vide)
- Create: `src/index.html` (shell HTML minimal — remplace l'ancien après migration complète)

**Step 1: Installer les dépendances**

```bash
npm install --save-dev vite vite-plugin-electron vite-plugin-electron-renderer typescript vitest @types/node
```

**Step 2: Créer tsconfig.json**

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": false,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "dist-ts",
    "rootDir": "src",
    "baseUrl": ".",
    "paths": {
      "@domain/*": ["src/domain/*"],
      "@application/*": ["src/application/*"],
      "@infrastructure/*": ["src/infrastructure/*"],
      "@presentation/*": ["src/presentation/*"]
    }
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist", "dist-ts", "tests"]
}
```

**Step 3: Créer vite.config.ts**

```typescript
import { defineConfig } from 'vite';
import electron from 'vite-plugin-electron';
import renderer from 'vite-plugin-electron-renderer';
import { resolve } from 'path';

export default defineConfig({
  plugins: [
    electron([
      {
        entry: 'src/infrastructure/electron/main.ts',
        vite: {
          build: {
            outDir: 'dist-electron',
            rollupOptions: { external: ['electron'] },
          },
        },
      },
      {
        entry: 'src/infrastructure/electron/preload.ts',
        onstart(args) { args.reload(); },
        vite: {
          build: {
            outDir: 'dist-electron',
            rollupOptions: { external: ['electron'] },
          },
        },
      },
    ]),
    renderer(),
  ],
  resolve: {
    alias: {
      '@domain': resolve(__dirname, 'src/domain'),
      '@application': resolve(__dirname, 'src/application'),
      '@infrastructure': resolve(__dirname, 'src/infrastructure'),
      '@presentation': resolve(__dirname, 'src/presentation'),
    },
  },
  root: '.',
  build: {
    outDir: 'dist-vite',
  },
});
```

**Step 4: Créer vitest.config.ts**

```typescript
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';

export default defineConfig({
  resolve: {
    alias: {
      '@domain': resolve(__dirname, 'src/domain'),
      '@application': resolve(__dirname, 'src/application'),
      '@infrastructure': resolve(__dirname, 'src/infrastructure'),
      '@presentation': resolve(__dirname, 'src/presentation'),
    },
  },
  test: {
    globals: true,
    environment: 'node',
    include: ['tests/**/*.test.ts'],
    coverage: {
      provider: 'v8',
      include: ['src/domain/**', 'src/application/**'],
      thresholds: { branches: 80, functions: 80, lines: 80 },
    },
  },
});
```

**Step 5: Mettre à jour package.json scripts**

Ajouter dans `"scripts"`:
```json
{
  "dev": "vite",
  "build:vite": "vite build",
  "test": "vitest run",
  "test:watch": "vitest",
  "test:coverage": "vitest run --coverage"
}
```

**Step 6: Créer les dossiers de structure**

```bash
mkdir -p src/domain/{entities,value-objects,services,ports,events}
mkdir -p src/application/{commands,queries,handlers,event-handlers}
mkdir -p src/infrastructure/{persistence,notifications,alarm,electron}
mkdir -p src/presentation/{components,styles,state}
mkdir -p tests/{unit/domain,unit/application,functional,regression}
```

**Step 7: Vérifier que vitest fonctionne**

Créer `tests/unit/domain/smoke.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';

describe('smoke test', () => {
  it('should run', () => {
    expect(1 + 1).toBe(2);
  });
});
```

Run: `npx vitest run`
Expected: 1 test PASS

**Step 8: Commit**

```
feat: setup Vite + TypeScript + Vitest tooling
```

---

## Phase 2 — Domain Value Objects

### Task 2: Value Object — GaugeType

**Files:**
- Create: `src/domain/value-objects/GaugeType.ts`
- Test: `tests/unit/domain/GaugeType.test.ts`

**Step 1: Write failing test**

```typescript
import { describe, it, expect } from 'vitest';
import { GaugeType, GAUGE_DEFS, StatType } from '@domain/value-objects/GaugeType';

describe('GaugeType', () => {
  it('should have 6 gauge types', () => {
    const types: GaugeType[] = ['baffeur', 'caresseur', 'foudroyeur', 'abreuvoir', 'dragofesse', 'mangeoire'];
    expect(types).toHaveLength(6);
    types.forEach(t => expect(GAUGE_DEFS[t]).toBeDefined());
  });

  it('baffeur decreases serenite', () => {
    expect(GAUGE_DEFS.baffeur.stat).toBe('serenite');
    expect(GAUGE_DEFS.baffeur.dir).toBe(-1);
  });

  it('caresseur increases serenite', () => {
    expect(GAUGE_DEFS.caresseur.stat).toBe('serenite');
    expect(GAUGE_DEFS.caresseur.dir).toBe(1);
  });

  it('mangeoire is XP type', () => {
    expect(GAUGE_DEFS.mangeoire.isXp).toBe(true);
    expect(GAUGE_DEFS.mangeoire.stat).toBe('xp');
  });
});
```

**Step 2: Run test — expected FAIL**

Run: `npx vitest run tests/unit/domain/GaugeType.test.ts`

**Step 3: Implement**

```typescript
export type GaugeType = 'baffeur' | 'caresseur' | 'foudroyeur' | 'abreuvoir' | 'dragofesse' | 'mangeoire';
export type StatType = 'serenite' | 'endurance' | 'maturite' | 'amour' | 'xp';

export interface GaugeDef {
  readonly label: string;
  readonly icon: string;
  readonly cssVar: string;
  readonly stat: StatType;
  readonly dir: -1 | 1;
  readonly isXp?: true;
}

export const GAUGE_DEFS: Readonly<Record<GaugeType, GaugeDef>> = {
  baffeur:    { label: 'Baffeur',    icon: '➖', cssVar: '--ser',   stat: 'serenite',  dir: -1 },
  caresseur:  { label: 'Caresseur',  icon: '➕', cssVar: '--ser',   stat: 'serenite',  dir:  1 },
  foudroyeur: { label: 'Foudroyeur', icon: '⚡', cssVar: '--end',   stat: 'endurance', dir:  1 },
  abreuvoir:  { label: 'Abreuvoir',  icon: '💧', cssVar: '--mat',   stat: 'maturite',  dir:  1 },
  dragofesse: { label: 'Dragofesse', icon: '❤',  cssVar: '--amour', stat: 'amour',     dir:  1 },
  mangeoire:  { label: 'Mangeoire',  icon: '🍖', cssVar: '--xp',    stat: 'xp',        dir:  1, isXp: true },
};

export interface StatDef {
  readonly label: string;
  readonly min: number;
  readonly max: number;
  readonly cssVar: string;
  readonly isLevel?: true;
}

export const STAT_DEFS: Readonly<Record<StatType, StatDef>> = {
  serenite:  { label: 'Sérénité',  min: -5000, max: 5000,  cssVar: '--ser' },
  endurance: { label: 'Endurance', min: 0,     max: 20000, cssVar: '--end' },
  maturite:  { label: 'Maturité',  min: 0,     max: 20000, cssVar: '--mat' },
  amour:     { label: 'Amour',     min: 0,     max: 20000, cssVar: '--amour' },
  xp:        { label: 'Niveau',    min: 1,     max: 200,   cssVar: '--xp', isLevel: true },
};

export const DEFAULT_TARGETS: Readonly<Record<GaugeType, number>> = {
  baffeur: -5000, caresseur: 40, foudroyeur: 20000, abreuvoir: 20000, dragofesse: 20000, mangeoire: 100,
};

export function targetRange(gid: GaugeType): { min: number; max: number } {
  const def = GAUGE_DEFS[gid];
  if (def.isXp) return { min: 1, max: 200 };
  const sd = STAT_DEFS[def.stat];
  if (def.dir < 0) return { min: sd.min, max: 0 };
  if (def.dir > 0 && sd.min < 0) return { min: 0, max: sd.max };
  return { min: sd.min, max: sd.max };
}
```

**Step 4: Run test — expected PASS**

**Step 5: Commit** `feat(domain): add GaugeType value object`

---

### Task 3: Value Object — Tier

**Files:**
- Create: `src/domain/value-objects/Tier.ts`
- Test: `tests/unit/domain/Tier.test.ts`

**Step 1: Write failing test**

```typescript
import { describe, it, expect } from 'vitest';
import { tierRate, tierNum, TIER_THRESHOLDS } from '@domain/value-objects/Tier';

describe('Tier', () => {
  it('tier 1: level 0-40000, rate 10', () => {
    expect(tierRate(0)).toBe(10);
    expect(tierRate(39999)).toBe(10);
    expect(tierRate(40000)).toBe(10);
    expect(tierNum(0)).toBe(1);
  });

  it('tier 2: level 40001-70000, rate 20', () => {
    expect(tierRate(40001)).toBe(20);
    expect(tierRate(70000)).toBe(20);
    expect(tierNum(50000)).toBe(2);
  });

  it('tier 3: level 70001-90000, rate 30', () => {
    expect(tierRate(70001)).toBe(30);
    expect(tierRate(90000)).toBe(30);
    expect(tierNum(80000)).toBe(3);
  });

  it('tier 4: level 90001+, rate 40', () => {
    expect(tierRate(90001)).toBe(40);
    expect(tierRate(100000)).toBe(40);
    expect(tierNum(95000)).toBe(4);
  });
});
```

**Step 2: Run test — expected FAIL**

**Step 3: Implement**

```typescript
export const TIER_THRESHOLDS = [
  { lo: 90000, rate: 40, num: 4 },
  { lo: 70000, rate: 30, num: 3 },
  { lo: 40000, rate: 20, num: 2 },
  { lo: 0,     rate: 10, num: 1 },
] as const;

export function tierRate(level: number): number {
  return level > 90000 ? 40 : level > 70000 ? 30 : level > 40000 ? 20 : 10;
}

export function tierNum(level: number): number {
  return level > 90000 ? 4 : level > 70000 ? 3 : level > 40000 ? 2 : 1;
}
```

**Step 4: Run test — expected PASS**

**Step 5: Commit** `feat(domain): add Tier value object`

---

### Task 4: Value Object — Race + Gender

**Files:**
- Create: `src/domain/value-objects/Race.ts`
- Create: `src/domain/value-objects/Gender.ts`
- Test: `tests/unit/domain/Race.test.ts`

**Step 1: Write failing test**

```typescript
import { describe, it, expect } from 'vitest';
import { RACE_GEN, GEN_COLORS, isBaseRace, generationOf } from '@domain/value-objects/Race';
import { Gender } from '@domain/value-objects/Gender';

describe('Race', () => {
  it('base races are gen 1', () => {
    expect(generationOf('Rousse')).toBe(1);
    expect(generationOf('Amande')).toBe(1);
    expect(generationOf('Dorée')).toBe(1);
    expect(isBaseRace('Rousse')).toBe(true);
  });

  it('gen 2 races are correctly mapped', () => {
    expect(generationOf('Dorée et Rousse')).toBe(2);
    expect(isBaseRace('Dorée et Rousse')).toBe(false);
  });

  it('GEN_COLORS has 10 entries', () => {
    expect(Object.keys(GEN_COLORS)).toHaveLength(10);
  });
});

describe('Gender', () => {
  it('has male, female, neutral', () => {
    const values: Gender[] = ['m', 'f', 'n'];
    expect(values).toHaveLength(3);
  });
});
```

**Step 2: Run test — expected FAIL**

**Step 3: Implement Gender.ts**

```typescript
export type Gender = 'm' | 'f' | 'n';
```

**Step 3b: Implement Race.ts**

```typescript
// Copier RACES_DATA, BREEDING_RECIPES, RACE_GEN, GEN_COLORS, RACE_BASE_COLORS depuis index.html
// Transformer en exports TypeScript typés

export const GEN_COLORS: Readonly<Record<number, string>> = {
  1:'#c8622a',2:'#e8b820',3:'#6040b0',4:'#2a8acc',5:'#c03050',
  6:'#d040a0',7:'#c8c0a0',8:'#20a8b0',9:'#28a058',10:'#8050a0',
};

export const RACE_BASE_COLORS: Readonly<Record<string, string>> = {
  'Rousse':'#c8622a','Amande':'#d4b48a','Dorée':'#e8b820',
  'Ebène':'#2a2a2a','Indigo':'#6040b0','Pourpre':'#c03050',
  'Orchidée':'#d040a0','Ivoire':'#c8c0a0','Turquoise':'#20a8b0',
  'Emeraude':'#28a058','Prune':'#8050a0',
};

export interface RaceData {
  readonly name: string;
  readonly stats: readonly string[];
  readonly parents: readonly [string, string] | null;
  readonly icon: string;
}

// RACES_DATA: copié intégralement depuis index.html lignes 510-592
export const RACES_DATA: Readonly<Record<number, readonly RaceData[]>> = {
  // ... (copier l'intégralité depuis le monolithe)
};

// Lookup table: baby → [parentA, parentB]
export const BREEDING_RECIPES: Readonly<Record<string, readonly [string, string]>> = {
  // ... (copier l'intégralité depuis index.html lignes 597-661)
};

// Derived lookups — computed at module load
export const RACE_GEN: Record<string, number> = {};

// Initialiser RACE_GEN
['Rousse','Amande','Dorée'].forEach(n => { RACE_GEN[n] = 1; });
for (const [genStr, races] of Object.entries(RACES_DATA)) {
  const gen = Number(genStr);
  for (const r of races) { RACE_GEN[r.name] = gen; }
}

export function generationOf(raceName: string): number {
  return RACE_GEN[raceName] ?? 0;
}

export function isBaseRace(raceName: string): boolean {
  return generationOf(raceName) === 1;
}

export function raceColor(name: string): string {
  const order = ['Emeraude','Prune','Ivoire','Turquoise','Orchidée','Pourpre','Indigo','Ebène','Dorée','Amande','Rousse'];
  for (const k of order) { if (name.includes(k)) return RACE_BASE_COLORS[k] ?? '#888'; }
  return '#888';
}
```

**Step 4: Run test — expected PASS**

**Step 5: Commit** `feat(domain): add Race and Gender value objects`

---

### Task 5: Value Object — XpTable

**Files:**
- Create: `src/domain/value-objects/XpTable.ts`
- Test: `tests/unit/domain/XpTable.test.ts`

**Step 1: Write failing test**

```typescript
import { describe, it, expect } from 'vitest';
import { xpForLevel, levelFromXp } from '@domain/value-objects/XpTable';

describe('XpTable', () => {
  it('level 1 requires 0 XP', () => {
    expect(xpForLevel(1)).toBe(0);
  });

  it('level 200 requires 867582 XP', () => {
    expect(xpForLevel(200)).toBe(867582);
  });

  it('clamps below 1', () => {
    expect(xpForLevel(0)).toBe(0);
    expect(xpForLevel(-5)).toBe(0);
  });

  it('clamps above 200', () => {
    expect(xpForLevel(250)).toBe(867582);
  });

  it('levelFromXp returns correct level', () => {
    expect(levelFromXp(0)).toBe(1);
    expect(levelFromXp(18)).toBe(1);
    expect(levelFromXp(19)).toBe(2);
    expect(levelFromXp(867582)).toBe(200);
    expect(levelFromXp(999999)).toBe(200);
  });

  it('roundtrip: xpForLevel → levelFromXp', () => {
    for (let lvl = 1; lvl <= 200; lvl++) {
      expect(levelFromXp(xpForLevel(lvl))).toBe(lvl);
    }
  });
});
```

**Step 2: Run test — expected FAIL**

**Step 3: Implement** — copier XP_RAW + xpForLevel + levelFromXp depuis index.html lignes 357-365

```typescript
export const XP_RAW: Readonly<Record<number, number>> = {
  1:0,2:19,3:49,4:96,5:161,6:246,7:353,8:481,9:633,10:809,
  // ... (copier intégralement les 200 entrées)
  200:867582
};

export function xpForLevel(lvl: number): number {
  return XP_RAW[Math.min(200, Math.max(1, Math.round(lvl)))] ?? 0;
}

export function levelFromXp(xp: number): number {
  if (xp >= XP_RAW[200]!) return 200;
  for (let i = 199; i >= 1; i--) {
    if (XP_RAW[i]! <= xp) return i;
  }
  return 1;
}
```

**Step 4: Run test — expected PASS**

**Step 5: Commit** `feat(domain): add XpTable value object`

---

## Phase 3 — Domain Services (pure math)

### Task 6: GaugeCalculator

**Files:**
- Create: `src/domain/services/GaugeCalculator.ts`
- Test: `tests/unit/domain/GaugeCalculator.test.ts`

**Step 1: Write failing tests**

```typescript
import { describe, it, expect } from 'vitest';
import { gainedIn, timeToGain, gaugeAfter, elapsed } from '@domain/services/GaugeCalculator';

describe('GaugeCalculator', () => {

  describe('gainedIn', () => {
    it('tier 1 only: 100 pts in 100 sec (10 ticks × 10pts)', () => {
      expect(gainedIn(100, 100)).toBe(100);
    });

    it('zero seconds = zero gain', () => {
      expect(gainedIn(50000, 0)).toBe(0);
    });

    it('crosses tier boundary 2→1', () => {
      // Start at 40010, 10 sec = 1 tick. Tier 2 rate = 20. Gain = 20 but only 10 in tier 2
      expect(gainedIn(40010, 10)).toBe(10);
    });

    it('large gauge drains through multiple tiers', () => {
      // Start at 100000 (tier 4), 5000 sec = 500 ticks
      // Tier 4: 100000→90000 = 10000/40 = 250 ticks → gain 10000
      // Tier 3: 90000→70000 = 20000/30 = 666.67 → 250 remaining ticks → 250*30 = 7500
      // Total = 17500
      expect(gainedIn(100000, 5000)).toBe(17500);
    });
  });

  describe('timeToGain', () => {
    it('zero points = zero time', () => {
      expect(timeToGain(50000, 0)).toBe(0);
    });

    it('negative points = zero time', () => {
      expect(timeToGain(50000, -10)).toBe(0);
    });

    it('tier 1: 100 pts = 100 sec', () => {
      expect(timeToGain(100, 100)).toBe(100);
    });

    it('returns Infinity if level too low', () => {
      expect(timeToGain(0, 100)).toBe(Infinity);
    });
  });

  describe('gaugeAfter', () => {
    it('tier 1: level 100 after 100 sec = 0', () => {
      expect(gaugeAfter(100, 100)).toBe(0);
    });

    it('does not go below 0', () => {
      expect(gaugeAfter(50, 1000)).toBe(0);
    });

    it('no time = same level', () => {
      expect(gaugeAfter(50000, 0)).toBe(50000);
    });
  });

  describe('elapsed', () => {
    it('no start time = 0', () => {
      expect(elapsed({ startTime: null, running: false, pausedAt: null, pausedMs: 0 })).toBe(0);
    });

    it('running timer', () => {
      const now = Date.now();
      const timer = { startTime: now - 10000, running: true, pausedAt: null, pausedMs: 0 };
      const el = elapsed(timer);
      expect(el).toBeGreaterThanOrEqual(9.9);
      expect(el).toBeLessThanOrEqual(10.5);
    });

    it('paused timer', () => {
      const now = Date.now();
      const timer = { startTime: now - 10000, running: false, pausedAt: now - 5000, pausedMs: 0 };
      expect(elapsed(timer)).toBeCloseTo(5, 0);
    });
  });
});
```

**Step 2: Run test — expected FAIL**

**Step 3: Implement**

```typescript
export interface TimerState {
  readonly startTime: number | null;
  readonly running: boolean;
  readonly pausedAt: number | null;
  readonly pausedMs: number;
}

export function gainedIn(lvl: number, sec: number): number {
  const T = [
    { lo: 90000, r: 40 },
    { lo: 70000, r: 30 },
    { lo: 40000, r: 20 },
    { lo: 0,     r: 10 },
  ];
  let g = Math.min(Math.max(lvl, 0), 100000);
  let tl = Math.floor(sec / 10);
  let out = 0;
  for (const { lo, r } of T) {
    if (g <= lo || tl <= 0) continue;
    const a = g - lo;
    const m = Math.floor(a / r);
    const u = Math.min(m, tl);
    out += u * r;
    tl -= u;
    g = lo;
  }
  return out;
}

export function timeToGain(lvl: number, pts: number): number {
  if (pts <= 0) return 0;
  const T = [
    { lo: 90000, r: 40 },
    { lo: 70000, r: 30 },
    { lo: 40000, r: 20 },
    { lo: 0,     r: 10 },
  ];
  let s = 0, rem = pts;
  let g = Math.min(Math.max(lvl, 0), 100000);
  for (const { lo, r } of T) {
    if (g <= lo || rem <= 0) continue;
    const a = g - lo;
    const d = Math.min(rem, a);
    s += Math.ceil(d / r) * 10;
    rem -= d;
    g = lo;
  }
  return rem > 0 ? Infinity : s;
}

export function gaugeAfter(lvl: number, sec: number): number {
  const T = [
    { lo: 90000, r: 40 },
    { lo: 70000, r: 30 },
    { lo: 40000, r: 20 },
    { lo: 0,     r: 10 },
  ];
  let g = Math.min(Math.max(lvl, 0), 100000);
  let tl = Math.floor(sec / 10);
  for (const { lo, r } of T) {
    if (g <= lo || tl <= 0) continue;
    const a = g - lo;
    const m = Math.floor(a / r);
    const u = Math.min(m, tl);
    g -= u * r;
    tl -= u;
  }
  return g;
}

export function elapsed(timer: TimerState): number {
  if (!timer.startTime) return 0;
  if (timer.running) return (Date.now() - timer.startTime - timer.pausedMs) / 1000;
  if (timer.pausedAt) return (timer.pausedAt - timer.startTime - timer.pausedMs) / 1000;
  return 0;
}
```

**Step 4: Run test — expected PASS**

**Step 5: Commit** `feat(domain): add GaugeCalculator service`

---

### Task 7: BreedingService

**Files:**
- Create: `src/domain/services/BreedingService.ts`
- Test: `tests/unit/domain/BreedingService.test.ts`

**Step 1: Write failing test**

```typescript
import { describe, it, expect } from 'vitest';
import { BreedingService } from '@domain/services/BreedingService';

describe('BreedingService', () => {
  const svc = new BreedingService();

  it('deduces baby from two parents', () => {
    expect(svc.deduceBaby('Rousse', 'Dorée')).toBe('Dorée et Rousse');
    expect(svc.deduceBaby('Dorée', 'Rousse')).toBe('Dorée et Rousse');
  });

  it('returns null for incompatible parents', () => {
    expect(svc.deduceBaby('Rousse', 'Rousse')).toBeNull();
  });

  it('lists compatible partners for a race', () => {
    const partners = svc.getCompatiblePartners('Rousse');
    expect(partners.length).toBeGreaterThan(0);
    expect(partners.some(p => p.partner === 'Dorée')).toBe(true);
  });

  it('gets parents for a baby race', () => {
    const parents = svc.getParents('Dorée et Rousse');
    expect(parents).toEqual(['Rousse', 'Dorée']);
  });

  it('returns null parents for base race', () => {
    expect(svc.getParents('Rousse')).toBeNull();
  });
});
```

**Step 2: Run test — expected FAIL**

**Step 3: Implement**

```typescript
import { BREEDING_RECIPES } from '@domain/value-objects/Race';

interface PartnerInfo {
  readonly partner: string;
  readonly baby: string;
  readonly gen: number;
}

export class BreedingService {
  private readonly byParents: Map<string, string>;
  private readonly partners: Map<string, PartnerInfo[]>;

  constructor() {
    this.byParents = new Map();
    this.partners = new Map();

    for (const [baby, [a, b]] of Object.entries(BREEDING_RECIPES)) {
      const gen = (RACE_GEN[baby] ?? 0);
      // Bidirectional key
      this.byParents.set(`${a}|${b}`, baby);
      this.byParents.set(`${b}|${a}`, baby);

      // Partners lookup
      if (!this.partners.has(a)) this.partners.set(a, []);
      this.partners.get(a)!.push({ partner: b, baby, gen });
      if (a !== b) {
        if (!this.partners.has(b)) this.partners.set(b, []);
        this.partners.get(b)!.push({ partner: a, baby, gen });
      }
    }
  }

  deduceBaby(parent1: string, parent2: string): string | null {
    return this.byParents.get(`${parent1}|${parent2}`) ?? null;
  }

  getCompatiblePartners(race: string): readonly PartnerInfo[] {
    return this.partners.get(race) ?? [];
  }

  getParents(babyRace: string): readonly [string, string] | null {
    const recipe = BREEDING_RECIPES[babyRace];
    return recipe ? [recipe[0], recipe[1]] : null;
  }
}
```

Note: importer `RACE_GEN` depuis `@domain/value-objects/Race`.

**Step 4: Run test — expected PASS**

**Step 5: Commit** `feat(domain): add BreedingService`

---

### Task 8: SerenityCalculator + XpCalculator

**Files:**
- Create: `src/domain/services/SerenityCalculator.ts`
- Create: `src/domain/services/XpCalculator.ts`
- Test: `tests/unit/domain/SerenityCalculator.test.ts`
- Test: `tests/unit/domain/XpCalculator.test.ts`

**Step 1: Write failing tests**

SerenityCalculator.test.ts:
```typescript
import { describe, it, expect } from 'vitest';
import { SerenityCalculator } from '@domain/services/SerenityCalculator';

describe('SerenityCalculator', () => {
  const calc = new SerenityCalculator();

  it('returns done when target reached', () => {
    const eta = calc.computeEta({
      currentSerenite: -5000,
      target: -5000,
      activeGauges: ['baffeur'],
      gaugeLevels: { baffeur: 50000 },
    });
    expect(eta.done).toBe(true);
  });

  it('needs baffeur to decrease serenite', () => {
    const eta = calc.computeEta({
      currentSerenite: 0,
      target: -5000,
      activeGauges: [],
      gaugeLevels: {},
    });
    expect(eta.needsGauge).toBe('baffeur');
  });

  it('computes time to target', () => {
    const eta = calc.computeEta({
      currentSerenite: 0,
      target: -5000,
      activeGauges: ['baffeur'],
      gaugeLevels: { baffeur: 50000 },
    });
    expect(eta.done).toBe(false);
    expect(eta.seconds).toBeGreaterThan(0);
    expect(eta.seconds).toBeLessThan(Infinity);
  });
});
```

XpCalculator.test.ts:
```typescript
import { describe, it, expect } from 'vitest';
import { XpCalculator } from '@domain/services/XpCalculator';

describe('XpCalculator', () => {
  const calc = new XpCalculator();

  it('returns done when level reached', () => {
    const eta = calc.computeEta({ currentLevel: 100, target: 100, gaugeLevels: { mangeoire: 50000 }, activeGauges: ['mangeoire'] });
    expect(eta.done).toBe(true);
  });

  it('needs mangeoire gauge', () => {
    const eta = calc.computeEta({ currentLevel: 1, target: 100, gaugeLevels: {}, activeGauges: [] });
    expect(eta.needsGauge).toBe('mangeoire');
  });

  it('computes seconds to level up', () => {
    const eta = calc.computeEta({ currentLevel: 1, target: 10, gaugeLevels: { mangeoire: 50000 }, activeGauges: ['mangeoire'] });
    expect(eta.done).toBe(false);
    expect(eta.seconds).toBeGreaterThan(0);
  });
});
```

**Step 2: Run tests — expected FAIL**

**Step 3: Implement both**

SerenityCalculator.ts:
```typescript
import { timeToGain } from './GaugeCalculator';
import type { GaugeType } from '@domain/value-objects/GaugeType';

export interface SerenityEtaInput {
  currentSerenite: number;
  target: number | null;
  activeGauges: readonly string[];
  gaugeLevels: Readonly<Record<string, number>>;
}

export interface EtaResult {
  done: boolean;
  seconds: number;
  needsGauge?: GaugeType;
}

export class SerenityCalculator {
  computeEta(input: SerenityEtaInput): EtaResult {
    const { currentSerenite, target, activeGauges, gaugeLevels } = input;
    if (target === null || target === undefined) return { done: false, seconds: 0 };
    const diff = target - currentSerenite;
    if (diff === 0) return { done: true, seconds: 0 };

    const needUp = diff > 0;
    const gid: GaugeType = needUp ? 'caresseur' : 'baffeur';

    if (!activeGauges.includes(gid)) return { done: false, seconds: Infinity, needsGauge: gid };

    const gl = gaugeLevels[gid] ?? 0;
    const pts = Math.abs(diff);
    const sec = timeToGain(gl, pts);
    return { done: false, seconds: sec };
  }
}
```

XpCalculator.ts:
```typescript
import { tierRate } from '@domain/value-objects/Tier';
import { xpForLevel } from '@domain/value-objects/XpTable';
import type { GaugeType } from '@domain/value-objects/GaugeType';

export interface XpEtaInput {
  currentLevel: number;
  target: number | null;
  gaugeLevels: Readonly<Record<string, number>>;
  activeGauges: readonly string[];
}

export interface EtaResult {
  done: boolean;
  seconds: number;
  needsGauge?: GaugeType;
}

export class XpCalculator {
  computeEta(input: XpEtaInput): EtaResult {
    const { currentLevel, target, gaugeLevels, activeGauges } = input;
    if (target === null || target === undefined) return { done: false, seconds: 0 };
    if (currentLevel >= target) return { done: true, seconds: 0 };
    if (!activeGauges.includes('mangeoire')) return { done: false, seconds: Infinity, needsGauge: 'mangeoire' };

    const gl = gaugeLevels.mangeoire ?? 0;
    const xpNeeded = Math.max(0, xpForLevel(target) - xpForLevel(currentLevel));
    if (xpNeeded <= 0) return { done: true, seconds: 0 };

    const rate = Math.max(10, tierRate(gl));
    const sec = Math.ceil(xpNeeded / rate) * 10;
    return { done: false, seconds: sec };
  }
}
```

**Step 4: Run tests — expected PASS**

**Step 5: Commit** `feat(domain): add SerenityCalculator and XpCalculator`

---

### Task 9: ReapproCalculator

**Files:**
- Create: `src/domain/services/ReapproCalculator.ts`
- Test: `tests/unit/domain/ReapproCalculator.test.ts`

**Step 1: Write failing test**

```typescript
import { describe, it, expect } from 'vitest';
import { ReapproCalculator } from '@domain/services/ReapproCalculator';

describe('ReapproCalculator', () => {
  const calc = new ReapproCalculator();

  it('computes gen1 needs for a gen2 race', () => {
    const result = calc.compute({
      target: 'Dorée et Rousse',
      qty: 4,
      repro: {},
      inverted: {},
    });
    expect(result.steps.length).toBe(1);
    expect(result.steps[0]!.parentA).toBe('Rousse');
    expect(result.steps[0]!.parentB).toBe('Dorée');
    expect(result.gen1Needs.length).toBe(2);
    const totalGen1 = result.gen1Needs.reduce((s, n) => s + n.total, 0);
    expect(totalGen1).toBe(8); // 4 couples = 4 Rousse + 4 Dorée
  });

  it('handles gender inversion', () => {
    const result = calc.compute({
      target: 'Dorée et Rousse',
      qty: 4,
      repro: {},
      inverted: { 'Dorée et Rousse': true },
    });
    // Inverted: parentA=Dorée, parentB=Rousse
    expect(result.steps[0]!.parentA).toBe('Dorée');
    expect(result.steps[0]!.parentB).toBe('Rousse');
  });

  it('subtracts breeders from couple count', () => {
    const result = calc.compute({
      target: 'Dorée et Rousse',
      qty: 4,
      repro: { 'Dorée et Rousse': 2 },
      inverted: {},
    });
    expect(result.steps[0]!.couples).toBe(2);
  });
});
```

**Step 2: Run test — expected FAIL**

**Step 3: Implement**

```typescript
import { BREEDING_RECIPES, RACE_GEN } from '@domain/value-objects/Race';

export interface ReapproInput {
  target: string;
  qty: number;
  repro: Readonly<Record<string, number>>;
  inverted: Readonly<Record<string, boolean>>;
}

export interface ReapproStep {
  race: string;
  gen: number;
  qty: number;
  couples: number;
  parentA: string;
  parentB: string;
  repro: number;
}

export interface Gen1Need {
  name: string;
  total: number;
  m: number;
  f: number;
}

export interface ReapproResult {
  steps: readonly ReapproStep[];
  gen1Needs: readonly Gen1Need[];
  totalGen1: number;
}

export class ReapproCalculator {
  compute(input: ReapproInput): ReapproResult {
    const { target, qty, repro, inverted } = input;
    if (!target || !BREEDING_RECIPES[target]) return { steps: [], gen1Needs: [], totalGen1: 0 };

    const needs: Record<string, { total: number; m: number; f: number }> = {};
    needs[target] = { total: qty, m: 0, f: 0 };
    const steps: ReapproStep[] = [];
    const processed = new Set<string>();
    const targetGen = RACE_GEN[target] ?? 2;

    for (let gen = targetGen; gen >= 2; gen--) {
      const racesAtGen = Object.keys(needs).filter(
        r => !processed.has(r) && (RACE_GEN[r] ?? 0) === gen && BREEDING_RECIPES[r]
      );
      for (const race of racesAtGen) {
        const Q = needs[race]!.total;
        if (Q <= 0) { processed.add(race); continue; }
        const R = repro[race] ?? 0;
        const couplesReal = (2 * R >= Q) ? Math.ceil(Q / 2) : (R > 0 ? Q - R : Q);

        const isInv = inverted[race] ?? false;
        const [rawA, rawB] = BREEDING_RECIPES[race]!;
        const a = isInv ? rawB : rawA;
        const b = isInv ? rawA : rawB;

        if (!needs[a]) needs[a] = { total: 0, m: 0, f: 0 };
        if (!needs[b]) needs[b] = { total: 0, m: 0, f: 0 };
        needs[a]!.total += couplesReal;
        needs[a]!.m += couplesReal;
        needs[b]!.total += couplesReal;
        needs[b]!.f += couplesReal;

        steps.push({ race, gen, qty: Q, couples: couplesReal, parentA: a, parentB: b, repro: R });
        processed.add(race);
      }
    }

    const gen1Needs: Gen1Need[] = Object.entries(needs)
      .filter(([n]) => !BREEDING_RECIPES[n] && needs[n]!.total > 0)
      .sort((a, b) => b[1].total - a[1].total)
      .map(([name, d]) => ({ name, total: d.total, m: d.m, f: d.f }));

    const totalGen1 = gen1Needs.reduce((s, n) => s + n.total, 0);

    return { steps, gen1Needs, totalGen1 };
  }
}
```

**Step 4: Run test — expected PASS**

**Step 5: Commit** `feat(domain): add ReapproCalculator`

---

### Task 10: InventaireCalculator

**Files:**
- Create: `src/domain/services/InventaireCalculator.ts`
- Test: `tests/unit/domain/InventaireCalculator.test.ts`

**Step 1: Write failing test**

```typescript
import { describe, it, expect } from 'vitest';
import { InventaireCalculator } from '@domain/services/InventaireCalculator';

describe('InventaireCalculator', () => {
  const calc = new InventaireCalculator();

  it('empty inventory produces nothing', () => {
    const result = calc.compute({});
    expect(result.generations).toHaveLength(0);
  });

  it('2 base races with ♂+♀ produce a gen2 baby', () => {
    const result = calc.compute({
      'Rousse': { m: 1, f: 0 },
      'Dorée': { m: 0, f: 1 },
    });
    expect(result.generations.length).toBeGreaterThanOrEqual(1);
    const gen2 = result.generations.find(g => g.gen === 2);
    expect(gen2).toBeDefined();
    expect(gen2!.crossings.some(c => c.name === 'Dorée et Rousse')).toBe(true);
  });

  it('requires ♂+♀ constraint (no same-gender breeding)', () => {
    const result = calc.compute({
      'Rousse': { m: 2, f: 0 },
      'Dorée': { m: 2, f: 0 },
    });
    // Both male — no breeding possible
    expect(result.generations).toHaveLength(0);
  });

  it('cascades babies into next generation', () => {
    const result = calc.compute({
      'Rousse': { m: 2, f: 2 },
      'Amande': { m: 2, f: 2 },
      'Dorée': { m: 2, f: 2 },
    });
    // Should produce gen 2 babies, which cascade to gen 3
    const gen2 = result.generations.find(g => g.gen === 2);
    expect(gen2).toBeDefined();
  });
});
```

**Step 2: Run test — expected FAIL**

**Step 3: Implement**

```typescript
import { BREEDING_RECIPES, RACE_GEN } from '@domain/value-objects/Race';

export interface Stock { m: number; f: number; n: number; }
export interface CrossingDetail { aSex: string; bSex: string; }
export interface CrossingResult { name: string; qty: number; parents: readonly [string, string]; gen: number; details: CrossingDetail[]; }
export interface GenerationResult { gen: number; crossings: CrossingResult[]; }
export interface InventaireResult { generations: GenerationResult[]; remaining: Record<string, Stock>; }

export class InventaireCalculator {
  compute(inventaire: Readonly<Record<string, { m: number; f: number }>>): InventaireResult {
    const avail: Record<string, Stock> = {};
    for (const [k, v] of Object.entries(inventaire)) {
      if ((v.m + v.f) > 0) avail[k] = { m: v.m, f: v.f, n: 0 };
    }

    if (Object.keys(avail).length === 0) return { generations: [], remaining: {} };

    const hasMale = (s: Stock) => s.m > 0 || s.n > 0;
    const hasFemale = (s: Stock) => s.f > 0 || s.n > 0;
    const takeMale = (s: Stock) => { if (s.m > 0) s.m--; else s.n--; };
    const takeFemale = (s: Stock) => { if (s.f > 0) s.f--; else s.n--; };
    const totalOf = (s: Stock) => s.m + s.f + s.n;

    const generations: GenerationResult[] = [];

    for (let gen = 2; gen <= 10; gen++) {
      const crossingsAtGen = Object.entries(BREEDING_RECIPES)
        .filter(([name]) => (RACE_GEN[name] ?? 0) === gen)
        .map(([name, parents]) => ({ name, parents }));

      const genResults: CrossingResult[] = [];
      let more = true;
      while (more) {
        more = false;
        for (const cr of crossingsAtGen) {
          const [a, b] = cr.parents;
          const sa = avail[a] ?? { m: 0, f: 0, n: 0 };
          const sb = avail[b] ?? { m: 0, f: 0, n: 0 };
          let ok = false, aSex = '', bSex = '';

          if (a === b) {
            if (totalOf(sa) >= 2 && hasMale(sa) && hasFemale(sa)) {
              takeMale(sa); takeFemale(sa); aSex = '♂'; bSex = '♀'; ok = true;
            }
          } else if (hasMale(sa) && hasFemale(sb)) {
            takeMale(sa); takeFemale(sb); aSex = '♂'; bSex = '♀'; ok = true;
          } else if (hasFemale(sa) && hasMale(sb)) {
            takeFemale(sa); takeMale(sb); aSex = '♀'; bSex = '♂'; ok = true;
          }

          if (ok) {
            if (!avail[a]) avail[a] = sa;
            if (!avail[b]) avail[b] = sb;
            if (!avail[cr.name]) avail[cr.name] = { m: 0, f: 0, n: 0 };
            avail[cr.name]!.n++;
            let entry = genResults.find(r => r.name === cr.name);
            if (!entry) { entry = { name: cr.name, qty: 0, parents: cr.parents as [string, string], gen, details: [] }; genResults.push(entry); }
            entry.qty++;
            entry.details.push({ aSex, bSex });
            more = true;
          }
        }
      }
      if (genResults.length > 0) generations.push({ gen, crossings: genResults });
    }

    return { generations, remaining: avail };
  }
}
```

**Step 4: Run test — expected PASS**

**Step 5: Commit** `feat(domain): add InventaireCalculator`

---

## Phase 4 — Domain Entities + Ports

### Task 11: Entités Dragodinde + Enclos

**Files:**
- Create: `src/domain/entities/Dragodinde.ts`
- Create: `src/domain/entities/Enclos.ts`
- Create: `src/domain/entities/Accouplement.ts`
- Test: `tests/unit/domain/Enclos.test.ts`

**Step 1: Write failing test**

```typescript
import { describe, it, expect } from 'vitest';
import { createEnclos, addDragodinde, removeDragodinde, MAX_DD } from '@domain/entities/Enclos';
import { createDragodinde } from '@domain/entities/Dragodinde';

describe('Enclos', () => {
  it('creates with defaults', () => {
    const enc = createEnclos(1);
    expect(enc.id).toBe(1);
    expect(enc.name).toBe('Enclos 1');
    expect(enc.dragodindes).toHaveLength(0);
    expect(enc.activeGauges).toHaveLength(0);
    expect(enc.timer.running).toBe(false);
  });

  it('adds a dragodinde', () => {
    let enc = createEnclos(1);
    enc = addDragodinde(enc);
    expect(enc.dragodindes).toHaveLength(1);
    expect(enc.dragodindes[0]!.name).toBe('DD 1');
  });

  it('limits to MAX_DD', () => {
    let enc = createEnclos(1);
    for (let i = 0; i < MAX_DD + 2; i++) enc = addDragodinde(enc);
    expect(enc.dragodindes).toHaveLength(MAX_DD);
  });

  it('removes a dragodinde', () => {
    let enc = createEnclos(1);
    enc = addDragodinde(enc);
    const ddId = enc.dragodindes[0]!.id;
    enc = removeDragodinde(enc, ddId);
    expect(enc.dragodindes).toHaveLength(0);
  });
});
```

**Step 2: Run test — expected FAIL**

**Step 3: Implement**

Dragodinde.ts:
```typescript
import type { GaugeType } from '@domain/value-objects/GaugeType';
import { DEFAULT_TARGETS } from '@domain/value-objects/GaugeType';
import type { Gender } from '@domain/value-objects/Gender';

export interface DragodindeStats {
  serenite: number;
  endurance: number;
  maturite: number;
  amour: number;
  xp: number;
}

export interface Dragodinde {
  readonly id: number;
  name: string;
  race: string;
  gender: Gender;
  stats: DragodindeStats;
  targets: Record<GaugeType, number>;
  sereniteTarget: number | null;
  levelTarget: number | null;
  reproducteur: number;
}

export function createDragodinde(id: number): Dragodinde {
  return {
    id,
    name: `DD ${id}`,
    race: '',
    gender: 'n',
    stats: { serenite: 0, endurance: 0, maturite: 0, amour: 0, xp: 1 },
    targets: { ...DEFAULT_TARGETS },
    sereniteTarget: null,
    levelTarget: null,
    reproducteur: 0,
  };
}
```

Enclos.ts:
```typescript
import type { Dragodinde } from './Dragodinde';
import { createDragodinde } from './Dragodinde';
import type { GaugeType } from '@domain/value-objects/GaugeType';

export const MAX_DD = 10;
export const MAX_ENCLOS = 6;
export const MAX_GAUGES = 2;

export interface TimerData {
  running: boolean;
  startTime: number | null;
  pausedAt: number | null;
  pausedMs: number;
  snapGauges: Record<string, number>;
  snapStats: Record<string, Record<string, number>>;
}

export interface Enclos {
  readonly id: number;
  name: string;
  activeGauges: GaugeType[];
  gaugeLevels: Record<GaugeType, number>;
  dragodindes: Dragodinde[];
  nextDdId: number;
  timer: TimerData;
  alerted: Record<string, boolean>;
}

export function createEnclos(id: number): Enclos {
  return {
    id,
    name: `Enclos ${id}`,
    activeGauges: [],
    gaugeLevels: { baffeur: 0, caresseur: 0, foudroyeur: 0, abreuvoir: 0, dragofesse: 0, mangeoire: 0 },
    dragodindes: [],
    nextDdId: 1,
    timer: { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {} },
    alerted: {},
  };
}

export function addDragodinde(enc: Enclos): Enclos {
  if (enc.dragodindes.length >= MAX_DD) return enc;
  const dd = createDragodinde(enc.nextDdId);
  return { ...enc, dragodindes: [...enc.dragodindes, dd], nextDdId: enc.nextDdId + 1 };
}

export function removeDragodinde(enc: Enclos, ddId: number): Enclos {
  return { ...enc, dragodindes: enc.dragodindes.filter(d => d.id !== ddId) };
}
```

Accouplement.ts:
```typescript
export interface Accouplement {
  readonly parentA: string;
  readonly parentB: string;
  readonly baby: string;
  readonly gen: number;
  readonly couples: number;
  readonly babiesObtained: number;
  readonly date: string;
}

export function createAccouplement(
  parentA: string, parentB: string, baby: string, gen: number,
  couples: number, babiesObtained: number
): Accouplement {
  return { parentA, parentB, baby, gen, couples, babiesObtained, date: new Date().toISOString() };
}
```

**Step 4: Run test — expected PASS**

**Step 5: Commit** `feat(domain): add Enclos, Dragodinde, Accouplement entities`

---

### Task 12: Ports (interfaces)

**Files:**
- Create: `src/domain/ports/StateRepository.ts`
- Create: `src/domain/ports/NotificationPort.ts`
- Create: `src/domain/ports/AlarmPort.ts`
- Create: `src/domain/ports/UpdatePort.ts`

**Step 1: Implement** (no tests needed — interfaces only)

StateRepository.ts:
```typescript
import type { Enclos } from '@domain/entities/Enclos';
import type { Accouplement } from '@domain/entities/Accouplement';

export interface AppState {
  enclos: Enclos[];
  activeId: number | string | null;
  nextEnclosId: number;
  alarmSound: string;
  notifsEnabled: boolean;
  ntfyTopic: string;
  archivedStats: unknown[];
  inventaire: Record<string, { m: number; f: number }>;
  workflows: unknown[];
  accouplements: Accouplement[];
}

export interface StateRepository {
  load(): Promise<AppState | null>;
  save(state: AppState): void;
}
```

NotificationPort.ts:
```typescript
export interface NotificationPort {
  showNotification(title: string, body: string): void;
  sendMobileNotification(url: string, title: string, message: string): void;
}
```

AlarmPort.ts:
```typescript
export interface AlarmPort {
  play(soundName: string): void;
  stop(): void;
}
```

UpdatePort.ts:
```typescript
export interface UpdateInfo {
  version: string;
  downloadUrl: string;
  assetName: string;
  releaseNotes: string;
}

export interface UpdatePort {
  checkForUpdates(): Promise<UpdateInfo | null>;
  downloadAndInstall(info: UpdateInfo): void;
}
```

**Step 2: Commit** `feat(domain): add port interfaces`

---

### Task 13: Domain Events

**Files:**
- Create: `src/domain/events/DomainEvent.ts`
- Create: `src/domain/events/EventBus.ts`
- Test: `tests/unit/domain/EventBus.test.ts`

**Step 1: Write failing test**

```typescript
import { describe, it, expect, vi } from 'vitest';
import { EventBus } from '@domain/events/EventBus';

describe('EventBus', () => {
  it('dispatches events to subscribers', () => {
    const bus = new EventBus();
    const handler = vi.fn();
    bus.on('timer-completed', handler);
    bus.emit({ type: 'timer-completed', enclosId: 1 });
    expect(handler).toHaveBeenCalledWith({ type: 'timer-completed', enclosId: 1 });
  });

  it('ignores unsubscribed events', () => {
    const bus = new EventBus();
    const handler = vi.fn();
    bus.on('timer-completed', handler);
    bus.emit({ type: 'accouplement-registered', data: {} });
    expect(handler).not.toHaveBeenCalled();
  });
});
```

**Step 2: Run test — expected FAIL**

**Step 3: Implement**

DomainEvent.ts:
```typescript
export type DomainEventType = 'timer-completed' | 'gauge-threshold-reached' | 'accouplement-registered' | 'enclos-deleted';

export interface DomainEvent {
  readonly type: DomainEventType;
  readonly [key: string]: unknown;
}
```

EventBus.ts:
```typescript
import type { DomainEvent, DomainEventType } from './DomainEvent';

type Handler = (event: DomainEvent) => void;

export class EventBus {
  private handlers = new Map<DomainEventType, Handler[]>();

  on(type: DomainEventType, handler: Handler): void {
    if (!this.handlers.has(type)) this.handlers.set(type, []);
    this.handlers.get(type)!.push(handler);
  }

  emit(event: DomainEvent): void {
    const handlers = this.handlers.get(event.type) ?? [];
    for (const h of handlers) h(event);
  }
}
```

**Step 4: Run test — expected PASS**

**Step 5: Commit** `feat(domain): add EventBus`

---

## Phase 5 — Application Layer (CQRS)

### Task 14: CommandBus + QueryBus

**Files:**
- Create: `src/application/handlers/CommandBus.ts`
- Create: `src/application/handlers/QueryBus.ts`
- Test: `tests/unit/application/CommandBus.test.ts`

**Step 1: Write failing test**

```typescript
import { describe, it, expect, vi } from 'vitest';
import { CommandBus } from '@application/handlers/CommandBus';

describe('CommandBus', () => {
  it('dispatches to registered handler', () => {
    const bus = new CommandBus();
    const handler = vi.fn();
    bus.register('start-timer', handler);
    bus.execute({ type: 'start-timer', enclosId: 1 });
    expect(handler).toHaveBeenCalledWith({ type: 'start-timer', enclosId: 1 });
  });

  it('throws on unknown command', () => {
    const bus = new CommandBus();
    expect(() => bus.execute({ type: 'unknown' as any })).toThrow();
  });
});
```

**Step 2: Run test — expected FAIL**

**Step 3: Implement**

CommandBus.ts:
```typescript
export interface Command {
  readonly type: string;
  readonly [key: string]: unknown;
}

type CommandHandler<T extends Command = Command> = (cmd: T) => void;

export class CommandBus {
  private handlers = new Map<string, CommandHandler>();

  register<T extends Command>(type: string, handler: CommandHandler<T>): void {
    this.handlers.set(type, handler as CommandHandler);
  }

  execute(cmd: Command): void {
    const handler = this.handlers.get(cmd.type);
    if (!handler) throw new Error(`No handler for command: ${cmd.type}`);
    handler(cmd);
  }
}
```

QueryBus.ts:
```typescript
export interface Query {
  readonly type: string;
  readonly [key: string]: unknown;
}

type QueryHandler<T extends Query = Query, R = unknown> = (query: T) => R;

export class QueryBus {
  private handlers = new Map<string, QueryHandler>();

  register<T extends Query, R>(type: string, handler: QueryHandler<T, R>): void {
    this.handlers.set(type, handler as QueryHandler);
  }

  execute<R>(query: Query): R {
    const handler = this.handlers.get(query.type);
    if (!handler) throw new Error(`No handler for query: ${query.type}`);
    return handler(query) as R;
  }
}
```

**Step 4: Run test — expected PASS**

**Step 5: Commit** `feat(application): add CommandBus and QueryBus`

---

### Task 15: Commands — Timer, Enclos, Accouplement

**Files:**
- Create: `src/application/commands/StartTimer.ts`
- Create: `src/application/commands/StopTimer.ts`
- Create: `src/application/commands/CreateEnclos.ts`
- Create: `src/application/commands/DeleteEnclos.ts`
- Create: `src/application/commands/AddDragodinde.ts`
- Create: `src/application/commands/RemoveDragodinde.ts`
- Create: `src/application/commands/UpdateGauge.ts`
- Create: `src/application/commands/RegisterAccouplement.ts`
- Create: `src/application/commands/UpdateSettings.ts`
- Create: `src/application/commands/ResetStats.ts`
- Test: `tests/unit/application/commands.test.ts`
- Test: `tests/functional/timer-workflow.test.ts`

Ce sont les command handlers qui orchestrent domaine + ports. Chaque handler :
1. Lit l'état depuis `StateRepository`
2. Applique la logique domaine
3. Sauvegarde via `StateRepository`
4. Émet des événements domaine si nécessaire

**Step 1: Write failing test (exemple StartTimer)**

```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createStartTimerHandler } from '@application/commands/StartTimer';
import { createEnclos, addDragodinde } from '@domain/entities/Enclos';
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import { EventBus } from '@domain/events/EventBus';

function makeRepo(state: AppState): StateRepository {
  return {
    load: vi.fn(async () => state),
    save: vi.fn(),
  };
}

describe('StartTimer', () => {
  it('starts timer on enclos', () => {
    let enc = createEnclos(1);
    enc = addDragodinde(enc);
    enc.activeGauges = ['baffeur'];
    enc.gaugeLevels.baffeur = 50000;

    const state: AppState = {
      enclos: [enc], activeId: 1, nextEnclosId: 2,
      alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
      archivedStats: [], inventaire: {}, workflows: [], accouplements: [],
    };

    const repo = makeRepo(state);
    const events = new EventBus();
    const handler = createStartTimerHandler(state, repo, events);

    handler({ type: 'start-timer', enclosId: 1 });

    expect(state.enclos[0]!.timer.running).toBe(true);
    expect(state.enclos[0]!.timer.startTime).not.toBeNull();
    expect(repo.save).toHaveBeenCalled();
  });
});
```

**Step 2: Run test — expected FAIL**

**Step 3: Implement StartTimer.ts (pattern pour tous les handlers)**

```typescript
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
import type { EventBus } from '@domain/events/EventBus';

export interface StartTimerCommand {
  type: 'start-timer';
  enclosId: number;
}

export function createStartTimerHandler(state: AppState, repo: StateRepository, events: EventBus) {
  return (cmd: StartTimerCommand): void => {
    const enc = state.enclos.find(e => e.id === cmd.enclosId);
    if (!enc || enc.timer.running) return;
    if (!enc.activeGauges.length || !enc.dragodindes.length) return;

    const now = Date.now();
    enc.timer.running = true;
    enc.timer.startTime = now;
    enc.timer.pausedAt = null;
    enc.timer.pausedMs = 0;

    // Snapshot gauges
    enc.timer.snapGauges = { ...enc.gaugeLevels };
    // Snapshot stats
    const snapStats: Record<string, Record<string, number>> = {};
    for (const dd of enc.dragodindes) {
      snapStats[dd.id] = { ...dd.stats };
    }
    enc.timer.snapStats = snapStats;
    enc.alerted = {};

    repo.save(state);
  };
}
```

Les autres command handlers suivent le même pattern. Implémenter chacun selon la logique existante dans index.html.

**Step 4: Run test — expected PASS**

**Step 5: Commit** `feat(application): add command handlers`

---

### Task 16: Queries — Dashboard, EnclosDetail, TimerState

**Files:**
- Create: `src/application/queries/GetDashboard.ts`
- Create: `src/application/queries/GetEnclosDetail.ts`
- Create: `src/application/queries/GetTimerState.ts`
- Create: `src/application/queries/GetBreedingOptions.ts`
- Create: `src/application/queries/GetReapproTree.ts`
- Create: `src/application/queries/GetInventaire.ts`
- Test: `tests/unit/application/queries.test.ts`

Pattern similaire aux commands mais retourne des données sans modifier l'état.

**Step 1: Write failing test (exemple GetDashboard)**

```typescript
import { describe, it, expect } from 'vitest';
import { createGetDashboardHandler } from '@application/queries/GetDashboard';
import { createEnclos, addDragodinde } from '@domain/entities/Enclos';
import type { AppState } from '@domain/ports/StateRepository';

describe('GetDashboard', () => {
  it('aggregates stats from enclos + accouplements', () => {
    let enc = createEnclos(1);
    enc = addDragodinde(enc);

    const state: AppState = {
      enclos: [enc], activeId: 'dashboard', nextEnclosId: 2,
      alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
      archivedStats: [], inventaire: {}, workflows: [],
      accouplements: [
        { parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: '2026-01-01' },
      ],
    };

    const handler = createGetDashboardHandler(state);
    const result = handler({ type: 'get-dashboard' });

    expect(result.totalCouples).toBe(5);
    expect(result.totalBabies).toBe(3);
    expect(result.enclosSummaries).toHaveLength(1);
  });
});
```

**Step 2: Run test — expected FAIL**

**Step 3: Implement** (extraire la logique depuis `renderDashboardStats` dans index.html)

**Step 4: Run test — expected PASS**

**Step 5: Commit** `feat(application): add query handlers`

---

## Phase 6 — Infrastructure

### Task 17: LocalStorageRepository

**Files:**
- Create: `src/infrastructure/persistence/LocalStorageRepository.ts`
- Test: `tests/unit/infrastructure/LocalStorageRepository.test.ts` (avec mock localStorage)

Implémenter `StateRepository` en utilisant `window.electronAPI.saveData`/`loadData` avec fallback sur `localStorage`. Inclure la logique de migration depuis index.html (`load()` lignes 1321-1356).

**Commit** `feat(infrastructure): add LocalStorageRepository`

---

### Task 18: Electron adapters (Notification, Alarm, Update)

**Files:**
- Create: `src/infrastructure/notifications/ElectronNotification.ts`
- Create: `src/infrastructure/notifications/NtfyNotification.ts`
- Create: `src/infrastructure/alarm/WebAudioAlarm.ts`
- Create: `src/infrastructure/update/GiteaUpdateAdapter.ts`

Chaque fichier implémente un port. Extraire la logique depuis index.html et main.js.

**Commit** `feat(infrastructure): add notification, alarm, update adapters`

---

### Task 19: Migrer main.ts + preload.ts

**Files:**
- Create: `src/infrastructure/electron/main.ts` (copier + convertir main.js en TypeScript)
- Create: `src/infrastructure/electron/preload.ts` (copier + convertir preload.js en TypeScript)

Le `main.ts` reste quasiment identique à `main.js` actuel — c'est déjà de l'infrastructure. Conversion TypeScript + cleanup.

**Commit** `feat(infrastructure): migrate Electron main and preload to TypeScript`

---

## Phase 7 — Presentation

### Task 20: UIState + App shell

**Files:**
- Create: `src/presentation/state/UIState.ts`
- Create: `src/presentation/components/App.ts`
- Create: `src/presentation/index.ts` (bootstrap / DI wiring)
- Modify: `src/index.html` (shell HTML minimal)

UIState gère l'état UI pur (onglet actif, sidebar ouverte, etc.) avec un pattern observable simple.

Le bootstrap (`index.ts`) connecte tout : crée les services domain, instancie les adapters infrastructure, configure les command/query bus, monte les composants.

**Commit** `feat(presentation): add UIState and App shell with DI wiring`

---

### Task 21: Composants UI — extraction progressive

**Files:**
- Create: `src/presentation/components/Sidebar.ts`
- Create: `src/presentation/components/Dashboard.ts`
- Create: `src/presentation/components/EnclosView.ts`
- Create: `src/presentation/components/DragodindeCard.ts`
- Create: `src/presentation/components/GaugePill.ts`
- Create: `src/presentation/components/AccouplementView.ts`
- Create: `src/presentation/components/ReapproView.ts`
- Create: `src/presentation/components/InventaireView.ts`
- Create: `src/presentation/components/ParametresView.ts`
- Create: `src/presentation/components/UpdateBanner.ts`

Chaque composant est une classe :
```typescript
export class DashboardComponent {
  private el: HTMLElement;
  constructor(private queryBus: QueryBus, private commandBus: CommandBus, private uiState: UIState) {}
  render(): HTMLElement { /* ... */ }
  update(): void { /* ... */ }
  destroy(): void { /* ... */ }
}
```

Extraire le HTML/CSS/JS de rendu depuis index.html pour chaque composant. Le CSS est extrait dans `src/presentation/styles/`.

**Commit** `feat(presentation): extract UI components`

---

### Task 22: Styles — extraction CSS

**Files:**
- Create: `src/presentation/styles/variables.css` (lignes 9-14 du monolithe)
- Create: `src/presentation/styles/base.css` (reset, body, wrap)
- Create: `src/presentation/styles/components.css` (tous les styles composants)
- Create: `src/presentation/styles/index.css` (imports)

**Commit** `feat(presentation): extract CSS into modular files`

---

## Phase 8 — Tests de régression

### Task 23: Tests de régression

**Files:**
- Create: `tests/regression/gauge-tier-calculation.test.ts`
- Create: `tests/regression/xp-timer-display.test.ts`
- Create: `tests/regression/stats-persistence.test.ts`
- Create: `tests/regression/breeding-deduction.test.ts`

**Tests de régression basés sur les bugs corrigés dans les versions précédentes :**

```typescript
// xp-timer-display.test.ts — Bug v1.1.4: affichait ~23h au lieu du temps réel
import { describe, it, expect } from 'vitest';
import { XpCalculator } from '@domain/services/XpCalculator';

describe('Regression: XP timer display', () => {
  it('should NOT show ~23h for a short XP gain', () => {
    const calc = new XpCalculator();
    const eta = calc.computeEta({
      currentLevel: 1,
      target: 5,
      gaugeLevels: { mangeoire: 50000 },
      activeGauges: ['mangeoire'],
    });
    // 161 XP needed (level 5), rate 20 at tier 2 = ~81 ticks = 810 sec ≈ 13.5 min
    expect(eta.seconds).toBeLessThan(3600); // Must be under 1h, NOT 23h
  });
});

// breeding-deduction.test.ts
import { describe, it, expect } from 'vitest';
import { BreedingService } from '@domain/services/BreedingService';

describe('Regression: Breeding deduction', () => {
  it('bidirectional parent order produces same baby', () => {
    const svc = new BreedingService();
    expect(svc.deduceBaby('Rousse', 'Dorée')).toBe(svc.deduceBaby('Dorée', 'Rousse'));
  });

  it('all gen 2-10 recipes are deducible', () => {
    const svc = new BreedingService();
    // Every BREEDING_RECIPES entry should be deducible from its parents
    // (tested implicitly via BreedingService constructor)
    expect(svc.deduceBaby('Rousse', 'Dorée')).toBe('Dorée et Rousse');
    expect(svc.deduceBaby('Amande', 'Dorée')).toBe('Amande et Dorée');
  });
});

// stats-persistence.test.ts
import { describe, it, expect } from 'vitest';

describe('Regression: Stats persistence', () => {
  it('deleting enclos should archive stats', () => {
    // Verify via DeleteEnclos command that archivedStats grows
    // when an enclos with history is deleted
  });
});
```

**Commit** `test: add regression tests for known bugs`

---

### Task 24: Tests fonctionnels

**Files:**
- Create: `tests/functional/timer-workflow.test.ts`
- Create: `tests/functional/breeding-workflow.test.ts`
- Create: `tests/functional/enclos-management.test.ts`

**timer-workflow.test.ts:**
```typescript
import { describe, it, expect } from 'vitest';
// Test le scénario complet: créer enclos → ajouter DD → configurer jauges → démarrer timer → vérifier progression → arrêter

describe('Timer Workflow', () => {
  it('full lifecycle: create → configure → start → progress → stop', () => {
    // 1. Create enclos via command
    // 2. Add dragodinde via command
    // 3. Toggle gauge via command
    // 4. Start timer via command
    // 5. Query timer state — verify running
    // 6. Stop timer via command
    // 7. Query timer state — verify stopped
  });
});
```

**breeding-workflow.test.ts:**
```typescript
describe('Breeding Workflow', () => {
  it('register accouplement → updates global stats', () => {
    // 1. Register accouplement via command
    // 2. Query dashboard — verify stats include new accouplement
  });
});
```

**Commit** `test: add functional workflow tests`

---

## Phase 9 — Nettoyage + intégration finale

### Task 25: Supprimer le monolithe

Une fois que tous les composants sont migrés et que tous les tests passent :

1. Renommer `src/index.html` (ancien) → `src/index.html.bak`
2. Renommer `main.js` → `main.js.bak`
3. Renommer `preload.js` → `preload.js.bak`
4. Mettre à jour `package.json` : adapter les scripts `build` pour utiliser Vite
5. Vérifier que `npm run dev` fonctionne
6. Vérifier que `npm run build` produit un installeur fonctionnel
7. Supprimer les `.bak`

**Commit** `chore: remove legacy monolith, Vite is now the sole build`

---

### Task 26: Mettre à jour package.json et electron-builder config

Adapter `package.json`:
```json
{
  "main": "dist-electron/main.js",
  "scripts": {
    "dev": "vite",
    "build": "vite build && electron-builder --win --x64",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage"
  },
  "build": {
    "files": [
      "dist-vite/**/*",
      "dist-electron/**/*",
      "icon.png"
    ]
  }
}
```

**Commit** `chore: update build config for Vite output`

---

## Résumé des phases

| Phase | Tasks | Description |
|-------|-------|-------------|
| 1 | 1 | Setup Vite + TS + Vitest |
| 2 | 2-5 | Domain Value Objects |
| 3 | 6-10 | Domain Services (logique pure) |
| 4 | 11-13 | Domain Entities + Ports + Events |
| 5 | 14-16 | Application Layer (CQRS) |
| 6 | 17-19 | Infrastructure (adapters) |
| 7 | 20-22 | Presentation (composants UI) |
| 8 | 23-24 | Tests régression + fonctionnels |
| 9 | 25-26 | Nettoyage + intégration finale |

**Total: 26 tasks, ~9 phases**

Tests attendus : ~50+ tests unitaires, ~10 fonctionnels, ~5 régression.
