import { describe, it, expect } from 'vitest';
import {
  DEF, INDEF, PREPS, PREPS_BY_CASE, COMMON_PREPS, RARE_PREPS, ALL_PREPS,
  NOUNS, nounForm, LEVELS, CONTRACTIONS, CONTRACTABLE, CHUNK_PREPS,
  POSS_ENDINGS, combinePoss, PRONOUNS, VERBS, PREP_FRAMES,
  createSession, gen, genContractionContextQ, newState, buildChunkChoices,
  bumpStreak, currentStreak, weekDots,
} from './engine.js';

const runSession = (levelId, n) => {
  const s = createSession(levelId);
  return Array.from({ length: n }, () => s.next());
};

// ── Grammar data ────────────────────────────────────────────

describe('declension tables', () => {
  it('definite articles are correct', () => {
    expect(DEF.masculine.accusative).toBe('den');
    expect(DEF.masculine.dative).toBe('dem');
    expect(DEF.feminine.dative).toBe('der');
    expect(DEF.neuter.genitive).toBe('des');
    expect(DEF.plural.dative).toBe('den');
  });

  it('every masculine/neuter noun has a genitive form ending in -s', () => {
    for (const n of NOUNS) {
      if (n.g === 'masculine' || n.g === 'neuter') {
        expect(n.gen, `${n.w} is missing its genitive form`).toBeTruthy();
        expect(n.gen.endsWith('s'), `${n.gen} should end in -s`).toBe(true);
      }
    }
  });

  it('nounForm applies the genitive only for genitive case', () => {
    const haus = NOUNS.find(n => n.w === 'Haus');
    expect(nounForm(haus, 'genitive')).toBe('Hauses');
    expect(nounForm(haus, 'dative')).toBe('Haus');
    const frau = NOUNS.find(n => n.w === 'Frau');
    expect(nounForm(frau, 'genitive')).toBe('Frau');
  });

  it('every frame references real verbs and nouns', () => {
    const nounNames = new Set(NOUNS.map(n => n.w));
    const checkFrames = frames => {
      for (const f of frames) {
        f.verbs.forEach(v => expect(VERBS[v], `unknown verb ${v}`).toBeTruthy());
        f.nouns.forEach(n => expect(nounNames.has(n), `unknown noun ${n}`).toBe(true));
      }
    };
    for (const [prep, f] of Object.entries(PREP_FRAMES)) {
      expect(PREPS[prep], `frame for unknown prep ${prep}`).toBeTruthy();
      if (Array.isArray(f)) checkFrames(f);
      else { checkFrames(f.motion); checkFrames(f.location); }
    }
  });

  it('every sentence-level preposition has a frame', () => {
    for (const lv of LEVELS) {
      if (lv.type === 'article' && lv.preps) {
        for (const p of lv.preps) expect(PREP_FRAMES[p], `no frame for ${p} (level ${lv.id})`).toBeTruthy();
      }
    }
  });
});

// ── Article questions ───────────────────────────────────────

describe('article questions', () => {
  const articleLevels = LEVELS.filter(l => l.type === 'article').map(l => l.id);

  it.each(articleLevels)('level %s: answer is always among 4 unique choices', (id) => {
    for (const q of runSession(id, 200)) {
      expect(q.choices.length).toBe(4);
      expect(new Set(q.choices).size).toBe(4);
      expect(q.choices).toContain(q.ans);
      expect(q.sentence).toContain('_____');
    }
  });

  it('genitive sentences use the genitive noun form (des Hauses, not des Haus)', () => {
    for (const q of runSession(5, 200)) {
      if (q.type !== 'article') continue;
      expect(q.cas).toBe('genitive');
      expect(q.sentence).toContain(nounForm(q.noun, 'genitive'));
      if (q.noun.gen) expect(q.sentence).not.toContain(` ${q.noun.w}.`);
    }
  });

  it('the answer matches the declension table', () => {
    for (const q of runSession(6, 300)) {
      if (q.type === 'article') expect(q.ans).toBe(DEF[q.noun.g][q.cas]);
    }
  });

  it('never repeats a preposition twice in a row', () => {
    for (const id of [2, 3, 4, 5]) {
      const qs = runSession(id, 200);
      for (let i = 1; i < qs.length; i++) {
        if (qs[i].prepKey && qs[i-1].prepKey) {
          expect(qs[i].prepKey, `level ${id} repeated ${qs[i].prepKey} at ${i}`).not.toBe(qs[i-1].prepKey);
        }
      }
    }
  });

  it('covers every preposition of the level within one bag cycle', () => {
    const lv = LEVELS.find(l => l.id === 3);
    const seen = new Set(runSession(3, lv.preps.length).map(q => q.prepKey));
    expect([...seen].sort()).toEqual([...lv.preps].sort());
  });
});

// ── Preposition case quiz ───────────────────────────────────

describe('prep case quiz (levels 8/9/10)', () => {
  it('every common prep appears within 16 draws — zu, nach, bei included', () => {
    const seen = new Set(runSession(8, COMMON_PREPS.length).map(q => q.prepKey));
    expect([...seen].sort()).toEqual([...COMMON_PREPS].sort());
  });

  it('every prep appears within one cycle on levels 9 and 10', () => {
    expect(new Set(runSession(9, RARE_PREPS.length).map(q => q.prepKey)).size).toBe(RARE_PREPS.length);
    expect(new Set(runSession(10, ALL_PREPS.length).map(q => q.prepKey)).size).toBe(ALL_PREPS.length);
  });

  it('never shows the same preposition twice in a row', () => {
    const qs = runSession(10, 300);
    for (let i = 1; i < qs.length; i++) expect(qs[i].prepKey).not.toBe(qs[i-1].prepKey);
  });

  it('answers match PREPS and examples are well-formed', () => {
    for (const q of runSession(10, 300)) {
      expect(q.ans).toBe(PREPS[q.prepKey].c);
      expect(q.example.startsWith(q.prepKey) || q.exampleFull).toBe(true);
    }
  });
});

// ── Wechsel im Kontext ──────────────────────────────────────

describe('wechsel in context (level 18)', () => {
  it('neben appears (was underrepresented)', () => {
    const seen = new Set(runSession(18, 9).map(q => q.prepKey));
    expect(seen.has('neben')).toBe(true);
    expect(seen.size).toBe(9);
  });

  it('never repeats prep back-to-back and rarely repeats the verb', () => {
    const qs = runSession(18, 300);
    let verbRepeats = 0;
    for (let i = 1; i < qs.length; i++) {
      expect(qs[i].prepKey).not.toBe(qs[i-1].prepKey);
      if (qs[i].verb === qs[i-1].verb) verbRepeats++;
    }
    expect(verbRepeats).toBeLessThanOrEqual(6); // only possible via single-verb frames
  });

  it('case follows motion/location and the sentence article matches', () => {
    for (const q of runSession(18, 300)) {
      expect(q.ans).toBe(q.isMotion ? 'accusative' : 'dative');
      expect(q.sentence).toContain(` ${q.prepKey} `);
      expect(q.sentence.endsWith('.')).toBe(true);
    }
  });
});

// ── Chunk / contraction questions ───────────────────────────

describe('contractions', () => {
  it('buildChunkChoices always contains the correct form, 4 unique options', () => {
    for (const c of CONTRACTIONS.filter(x => x.pref)) {
      const { correct, choices } = buildChunkChoices(c.prep, c.art);
      expect(correct).toBe(c.form);
      expect(choices).toContain(c.form);
      expect(new Set(choices).size).toBe(4);
    }
  });

  it('non-contracting articles keep the two-word form', () => {
    const { correct, contracts } = buildChunkChoices('an', 'die');
    expect(contracts).toBe(false);
    expect(correct).toBe('an die');
  });

  it('dedicated Kontraktionen level always yields a preferred contraction', () => {
    const state = newState();
    for (let i = 0; i < 200; i++) {
      const q = genContractionContextQ(state);
      expect(q.contracts).toBe(true);
      expect(q.pref).toBe(true);
      const c = CONTRACTIONS.find(x => x.form === q.ans);
      expect(c, `answer ${q.ans} is a known contraction`).toBeTruthy();
      expect(DEF[q.noun.g][q.cas]).toBe(c.art);
      expect(q.choices).toContain(q.ans);
    }
  });

  it('mixed definite level routes contractable preps to chunk questions', () => {
    const qs = runSession(6, 300);
    for (const q of qs) {
      if (q.prepKey && CHUNK_PREPS.has(q.prepKey)) expect(q.type).toBe('chunk');
    }
    expect(qs.some(q => q.type === 'chunk')).toBe(true);
  });
});

// ── Possessives & pronouns ──────────────────────────────────

describe('possessive questions', () => {
  it.each([11, 12, 13])('level %s: answer matches stem + ending, genitive nouns inflected', (id) => {
    const lv = LEVELS.find(l => l.id === id);
    for (const q of runSession(id, 300)) {
      expect(lv.possessives).toContain(q.stem);
      expect(q.ans).toBe(combinePoss(q.stem, POSS_ENDINGS[q.noun.g][q.cas]));
      expect(lv.cases).toContain(q.cas);
      expect(q.sentence).toContain('_____');
      if (q.cas === 'genitive') expect(q.sentence).toContain(nounForm(q.noun, 'genitive'));
    }
  });

  it('level 13 reaches the genitive', () => {
    expect(runSession(13, 300).some(q => q.cas === 'genitive')).toBe(true);
  });

  it('dative helfen template only targets people', () => {
    for (const q of runSession(11, 400)) {
      if (q.sentence.startsWith('Ich helfe')) {
        expect(['masculine','feminine','neuter'].includes(q.noun.g)).toBe(true);
        expect(q.noun.en).not.toBe('table');
        expect(q.noun.en).not.toBe('week');
      }
    }
  });
});

describe('pronoun questions', () => {
  it.each([14, 15, 16, 17])('level %s: answer matches the pronoun table', (id) => {
    for (const q of runSession(id, 200)) {
      const p = PRONOUNS.find(x => x.key === q.pronKey);
      expect(q.ans).toBe(p[{accusative:'acc',dative:'dat'}[q.cas]]);
    }
  });
});

// ── Nominative is never tested ──────────────────────────────

describe('no nominative anywhere', () => {
  it('no level tests the nominative — you already know der/die/das', () => {
    expect(LEVELS.some(l => l.name === 'Nominativ')).toBe(false);
    for (const lv of LEVELS) {
      if (lv.cases) expect(lv.cases, `level ${lv.id} still lists nominative`).not.toContain('nominative');
      for (const q of runSession(lv.id, 150)) {
        if (q.cas) expect(q.cas, `level ${lv.id} generated a nominative question`).not.toBe('nominative');
        expect(q.ans).not.toBe('nominative');
      }
    }
  });
});

// ── Every level generates without crashing ──────────────────

describe('all levels', () => {
  it.each(LEVELS.map(l => [l.id]))('level %s generates 100 questions cleanly', (id) => {
    for (const q of runSession(id, 100)) {
      expect(q).toBeTruthy();
      expect(q.ans).toBeTruthy();
      expect(q.type).toBeTruthy();
    }
  });
});

// ── Streaks (multi-day simulation) ──────────────────────────

describe('streaks', () => {
  it('grows by one per consecutive day, no matter how many rounds per day', () => {
    let st = { streak: 0, ld: null };
    st = bumpStreak(st, '2026-07-01');
    expect(st.streak).toBe(1);
    st = bumpStreak(st, '2026-07-01'); // second round same day
    st = bumpStreak(st, '2026-07-01');
    expect(st.streak).toBe(1);
    st = bumpStreak(st, '2026-07-02');
    expect(st.streak).toBe(2);
    st = bumpStreak(st, '2026-07-03');
    st = bumpStreak(st, '2026-07-03');
    expect(st.streak).toBe(3);
  });

  it('resets after a missed day', () => {
    let st = { streak: 5, ld: '2026-07-01' };
    st = bumpStreak(st, '2026-07-04');
    expect(st.streak).toBe(1);
  });

  it('survives month and year boundaries', () => {
    let st = bumpStreak({ streak: 0, ld: null }, '2026-01-31');
    st = bumpStreak(st, '2026-02-01');
    expect(st.streak).toBe(2);
    st = { streak: 9, ld: '2026-12-31' };
    st = bumpStreak(st, '2027-01-01');
    expect(st.streak).toBe(10);
  });

  it('records practice days and prunes old ones', () => {
    let st = bumpStreak({ streak: 0, ld: null }, '2026-07-01');
    expect(st.days['2026-07-01']).toBe(true);
    st = bumpStreak(st, '2026-07-01'); // same day, already recorded
    expect(Object.keys(st.days).length).toBe(1);
    st = bumpStreak(st, '2026-07-02');
    expect(st.days['2026-07-01']).toBe(true);
    expect(st.days['2026-07-02']).toBe(true);
    st = bumpStreak(st, '2026-07-20'); // far in the future → old days pruned
    expect(st.days['2026-07-01']).toBeUndefined();
    expect(st.days['2026-07-20']).toBe(true);
  });

  it('weekDots reports the last 7 days, today last', () => {
    const st = { days: { '2026-07-10': true, '2026-07-12': true, '2026-07-14': true } };
    const dots = weekDots(st, '2026-07-14');
    expect(dots.length).toBe(7);
    expect(dots[0].date).toBe('2026-07-08');
    expect(dots[6].date).toBe('2026-07-14');
    expect(dots[6].isToday).toBe(true);
    expect(dots.map(d => d.practiced)).toEqual([false, false, true, false, true, false, true]);
  });

  it('displays 0 once the streak has lapsed, without needing a new answer', () => {
    const st = { streak: 7, ld: '2026-07-01' };
    expect(currentStreak(st, '2026-07-01')).toBe(7);
    expect(currentStreak(st, '2026-07-02')).toBe(7); // still alive: practiced yesterday
    expect(currentStreak(st, '2026-07-03')).toBe(0); // lapsed
    expect(currentStreak({}, '2026-07-03')).toBe(0);
    expect(currentStreak(null, '2026-07-03')).toBe(0);
  });
});
