import { describe, it, expect } from 'vitest';
import { parse, STATUS_BY_CODE, SIZE_RANK } from '../src/parser.js';

/* Kanonisches Beispiel aus docs/SPEC.md §10 — die verbindliche Fixture.
   Bei Syntaxänderungen: SPEC zuerst, dann diese Fixture + Assertions. */
const SPEC_EXAMPLE = `%% Projektstruktur – Stand Sprint 14
[~] Website-Relaunch (XL) https://wiki.example.de/relaunch
  - [x] Konzeption (M)
    - [x] Zielgruppenanalyse (S)
    - [x] Sitemap (XS)
  - [~] Umsetzung (XL)
    - [/] Frontend (S) https://git.example.de/frontend @anna
    - [ ] Backend (L) @ben @carla
    - [ ] CMS-Anbindung (M)
      | [ ] WordPress
      | [?] Headless CMS
      | [-] Eigenentwicklung  %% Aufwand zu hoch
  - [?] Hosting (M)
    | Cloud
    | On-Premise`;

describe('parse — kanonisches SPEC-§10-Beispiel', () => {
  const { roots, warnings } = parse(SPEC_EXAMPLE);

  it('erzeugt genau eine Wurzel mit allen Bestandteilen', () => {
    expect(roots).toHaveLength(1);
    const r = roots[0];
    expect(r.label).toBe('Website-Relaunch');
    expect(r.status.key).toBe('arbeit');
    expect(r.size).toBe('XL');
    expect(r.url).toBe('https://wiki.example.de/relaunch');
    expect(r.type).toBe('and');       // kein Gate-Zeichen -> and
    expect(r.tags).toEqual([]);
    expect(r.line).toBe(2);           // Zeile 1 ist der %%-Kommentar
  });

  it('bildet die Hierarchie über die Einrückung ab', () => {
    const r = roots[0];
    expect(r.children.map(c => c.label)).toEqual(['Konzeption', 'Umsetzung', 'Hosting']);
    const [konz, ums, host] = r.children;
    expect(konz.children.map(c => c.label)).toEqual(['Zielgruppenanalyse', 'Sitemap']);
    expect(ums.children.map(c => c.label)).toEqual(['Frontend', 'Backend', 'CMS-Anbindung']);
    expect(host.children.map(c => c.label)).toEqual(['Cloud', 'On-Premise']);
  });

  it('extrahiert Status, Größe, URL und Tags korrekt', () => {
    const ums = roots[0].children[1];
    const [fe, be, cms] = ums.children;
    expect(fe.status.key).toBe('durchstich');
    expect(fe.size).toBe('S');
    expect(fe.url).toBe('https://git.example.de/frontend');
    expect(fe.tags).toEqual(['anna']);
    expect(be.status.key).toBe('geplant');
    expect(be.size).toBe('L');
    expect(be.tags).toEqual(['ben', 'carla']);
    expect(cms.size).toBe('M');
  });

  it('setzt das Gate je Kind (any-of unter CMS-Anbindung)', () => {
    const cms = roots[0].children[1].children[2];
    expect(cms.children.map(c => c.type)).toEqual(['or', 'or', 'or']);
    expect(cms.children.map(c => c.status.key))
      .toEqual(['geplant', 'idee', 'verworfen']);
    // Kommentar hinter „Eigenentwicklung" ist entfernt
    expect(cms.children[2].label).toBe('Eigenentwicklung');
  });

  it('Hosting-Alternativen ohne Status bleiben neutral', () => {
    const host = roots[0].children[2];
    expect(host.status.key).toBe('idee');
    expect(host.children.every(c => c.status === null)).toBe(true);
    expect(host.children.every(c => c.type === 'or')).toBe(true);
  });

  it('liefert (noch) keine Warnungen', () => {
    expect(warnings).toEqual([]);
  });
});

describe('parse — Randfälle', () => {
  it('URL mit @ kollidiert nicht mit Personen-Tags', () => {
    const { roots } = parse('- Deploy https://user@host.example.com/path @ben');
    expect(roots[0].url).toBe('https://user@host.example.com/path');
    expect(roots[0].tags).toEqual(['ben']);
    expect(roots[0].label).toBe('Deploy');
  });

  it('%% am Zeilenanfang und am Zeilenende', () => {
    const { roots } = parse('%% ganze Zeile Kommentar\n- Aufgabe %% Rest weg');
    expect(roots).toHaveLength(1);
    expect(roots[0].label).toBe('Aufgabe');
  });

  it('leere Labels (nur Zeichen/Status) werden ignoriert', () => {
    const { roots } = parse('- [x]\n- Echt');
    expect(roots.map(r => r.label)).toEqual(['Echt']);
  });

  it('mehrere Wurzeln stehen nebeneinander', () => {
    const { roots } = parse('Baum A\nBaum B\n  - Kind von B');
    expect(roots.map(r => r.label)).toEqual(['Baum A', 'Baum B']);
    expect(roots[1].children.map(c => c.label)).toEqual(['Kind von B']);
  });

  it('Tab zählt als zwei Leerzeichen (gleiche Ebene)', () => {
    const { roots } = parse('- P\n\t- Tab-Kind\n  - Space-Kind');
    expect(roots).toHaveLength(1);
    expect(roots[0].children.map(c => c.label)).toEqual(['Tab-Kind', 'Space-Kind']);
  });

  it('ungleichmäßige Einrückung: Elternknoten ist die nächste kleinere Breite', () => {
    const { roots } = parse('- P\n     - tief eingerückt\n       - noch tiefer');
    const p = roots[0];
    expect(p.children.map(c => c.label)).toEqual(['tief eingerückt']);
    expect(p.children[0].children.map(c => c.label)).toEqual(['noch tiefer']);
  });

  it('gemischte Gates werden je Zeile treu übernommen', () => {
    const { roots } = parse('- P\n  - und-Kind\n  | oder-Kind');
    expect(roots[0].children.map(c => c.type)).toEqual(['and', 'or']);
  });

  it('Status akzeptiert x und X; Größe ist case-insensitive', () => {
    const { roots } = parse('- [X] Fertig groß (xxl)\n- [x] Fertig klein (m)');
    expect(roots[0].status.key).toBe('fertig');
    expect(roots[0].size).toBe('XXL');
    expect(roots[1].status.key).toBe('fertig');
    expect(roots[1].size).toBe('M');
  });

  /* Größen-Token (SPEC §1 Schritt 4, D68): das LETZTE alleinstehend
     angesetzte `(L)`-Token ist die Größe; frühere bleiben im Label, und die
     Zitier-Konventionen `"(L)"`/`((L))` halten ein Literal heraus. */
  it('das letzte alleinstehende Größen-Token gewinnt, frühere bleiben im Label', () => {
    const { roots } = parse('- Variante (L) bauen (M)');
    expect(roots[0].size).toBe('M');
    expect(roots[0].label).toBe('Variante (L) bauen');
  });

  it('Größe nur alleinstehend angesetzt: Backend(L) bleibt Label', () => {
    const { roots } = parse('- Backend(L)');
    expect(roots[0].size).toBe(null);
    expect(roots[0].label).toBe('Backend(L)');
  });

  it('Zitier-Konventionen: "(L)" und ((L)) bleiben Label', () => {
    const { roots } = parse('- Merch "(L)" drucken\n- Auswahl ((L)) treffen (S)');
    expect(roots[0].size).toBe(null);
    expect(roots[0].label).toBe('Merch "(L)" drucken');
    expect(roots[1].size).toBe('S');
    expect(roots[1].label).toBe('Auswahl ((L)) treffen');
  });

  it('Größe am Zeilenanfang des Rests zählt weiterhin', () => {
    const { roots } = parse('- (L) Backend');
    expect(roots[0].size).toBe('L');
    expect(roots[0].label).toBe('Backend');
  });

  it('ohne Angaben: status/url/size null, tags leer', () => {
    const { roots } = parse('- Nackt');
    expect(roots[0]).toMatchObject({ status: null, url: null, size: null, tags: [] });
  });

  it('leerer/Whitespace-Text ergibt keine Wurzeln', () => {
    expect(parse('').roots).toEqual([]);
    expect(parse('   \n\t\n  ').roots).toEqual([]);
  });
});

describe('parse — fehlertolerant: unbekannte Statuszeichen', () => {
  it('meldet unbekannten Code, stellt Knoten neutral dar, Label bleibt sauber', () => {
    const {roots, warnings} = parse('- [z] Aufgabe');
    expect(roots).toHaveLength(1);
    expect(roots[0].status).toBeNull();
    expect(roots[0].label).toBe('Aufgabe');          // Box aus dem Label entfernt
    expect(warnings).toEqual([{type: 'unknownStatus', line: 1, code: 'z'}]);
  });

  it('bricht nachfolgende Zeilen nicht (Geschwister/Kinder bleiben heil)', () => {
    const {roots, warnings} = parse('- [z] Kaputt\n- [x] Heil\n  - [~] Kind');
    expect(roots.map(r => r.label)).toEqual(['Kaputt', 'Heil']);
    expect(roots[1].status.key).toBe('fertig');
    expect(roots[1].children[0].status.key).toBe('arbeit');
    expect(warnings).toEqual([{type: 'unknownStatus', line: 1, code: 'z'}]);
  });

  it('gültige Codes (inkl. X) lösen keine Warnung aus', () => {
    const {warnings} = parse('- [X] a\n- [ ] b\n- [~] c\n- [/] d\n- [^] e\n- [-] f\n- [?] g');
    expect(warnings).toEqual([]);
  });

  it('mehrzeichige Klammern sind keine Statusbox — kein Fehlalarm', () => {
    const {roots, warnings} = parse('- [xyz] Text');
    expect(warnings).toEqual([]);
    expect(roots[0].label).toBe('[xyz] Text');       // bleibt im Label
    expect(roots[0].status).toBeNull();
  });

  it('sammelt mehrere unbekannte Codes mit ihren Zeilennummern', () => {
    const {warnings} = parse('- [x] ok\n- [q] eins\n- [ ] ok\n- [w] zwei');
    expect(warnings).toEqual([
      {type: 'unknownStatus', line: 2, code: 'q'},
      {type: 'unknownStatus', line: 4, code: 'w'},
    ]);
  });
});

describe('Vokabular-Exporte', () => {
  it('SIZE_RANK ist aufsteigend geordnet (SPEC §5)', () => {
    expect(SIZE_RANK).toEqual({ XS: 0, S: 1, M: 2, L: 3, XL: 4, XXL: 5 });
  });

  it('STATUS_BY_CODE deckt alle acht Codes ab (SPEC §4)', () => {
    expect(Object.keys(STATUS_BY_CODE).sort())
      .toEqual([' ', '!', '-', '/', '?', '^', 'x', '~'].sort());
    expect(STATUS_BY_CODE['-'].key).toBe('verworfen');
    expect(STATUS_BY_CODE['!'].key).toBe('highrisk');
  });
});
