// @vitest-environment node
/**
 * Ein Profil aus einer Sicherung dazunehmen.
 *
 * Alles hier läuft gegen echte SQLite-Dateien, nicht gegen Attrappen: Die
 * Fallen dieses Merkmals sind Fallen von SQLite selbst.
 */

import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { LERNSTAND_SCHEMA, MIGRATIONEN, SCHEMA_VERSION } from '../src/main/schema';
import {
  freierProfilname,
  profilUebernehmen,
  VOR_DEM_UEBERNEHMEN,
} from '../src/main/profil-uebernehmen';

let ordner: string;
let offene: Database.Database[];

/** Eine Datenbank mit dem echten Grundschema. */
function datenbank(pfad: string): Database.Database {
  const db = new Database(pfad);
  offene.push(db);
  db.pragma('journal_mode = WAL');
  db.exec(LERNSTAND_SCHEMA);
  /* Die später ergänzten Spalten kommen über dieselben Migrationsschritte
     wie in der Anwendung – eine handgeschriebene Kopie liefe beim nächsten
     Schritt auseinander. */
  for (let version = 2; version <= SCHEMA_VERSION; version += 1) {
    const schritt = MIGRATIONEN[version];
    if (typeof schritt === 'string') {
      db.exec(schritt);
    } else if (typeof schritt === 'function') {
      schritt(db);
    }
  }
  db.prepare('INSERT OR IGNORE INTO schema_version (version, angewendet_am) VALUES (?, ?)').run(
    SCHEMA_VERSION,
    '2026-08-23T09:00:00.000Z',
  );
  return db;
}

function profilFuellen(
  db: Database.Database,
  name: string,
  antworten: number,
  termin: string | null = null,
): number {
  const id = Number(
    db
      .prepare('INSERT INTO profil (name, pruefungstermin, erstellt_am) VALUES (?, ?, ?)')
      .run(name, termin, '2026-01-01T00:00:00.000Z').lastInsertRowid,
  );
  const log = db.prepare(
    `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms)
     VALUES (?, ?, ?, 1, 'gut', 1000)`,
  );
  const stand = db.prepare(
    'INSERT INTO frage_stand (profil_id, frage_id, versuche, richtige, gemerkt) VALUES (?, ?, 1, 1, ?)',
  );
  db.transaction(() => {
    for (let i = 0; i < antworten; i += 1) {
      const frage = `I.1-${String(i % 20).padStart(2, '0')}`;
      log.run(id, frage, `2026-08-${String(10 + (i % 10)).padStart(2, '0')}T09:00:00.000Z`);
    }
    for (let i = 0; i < 20; i += 1) {
      stand.run(id, `I.1-${String(i).padStart(2, '0')}`, i < 3 ? 1 : 0);
    }
  })();
  return id;
}

function uebernehmen(ziel: Database.Database, quellPfad: string, quellId: number) {
  return profilUebernehmen({
    ziel,
    quelle: quellPfad,
    quellProfilId: quellId,
    ordner,
    jetzt: new Date('2026-08-23T10:00:00.000Z'),
    Datenbank: Database,
  });
}

beforeEach(() => {
  ordner = mkdtempSync(join(tmpdir(), 'wsk-uebernehmen-'));
  offene = [];
});

afterEach(() => {
  for (const db of offene) {
    try {
      db.close();
    } catch {
      /* schon zu */
    }
  }
  rmSync(ordner, { recursive: true, force: true });
});

describe('freierProfilname', () => {
  it('lässt einen freien Namen stehen', () => {
    expect(freierProfilname(new Set(['Anna']), 'Olaf')).toBe('Olaf');
  });

  it('hängt bei Namensgleichheit einen Zusatz an – der vorhandene bleibt', () => {
    expect(freierProfilname(new Set(['Olaf']), 'Olaf')).toBe('Olaf (übernommen)');
    expect(freierProfilname(new Set(['Olaf', 'Olaf (übernommen)']), 'Olaf')).toBe(
      'Olaf (übernommen 2)',
    );
  });
});

describe('profilUebernehmen', () => {
  it('nimmt Profil und Zeilen mit und lässt das Vorhandene unberührt', () => {
    const zielPfad = join(ordner, 'lernstand.db');
    const ziel = datenbank(zielPfad);
    profilFuellen(ziel, 'Standard', 30);

    const quellPfad = join(ordner, 'quelle.db');
    const quelle = datenbank(quellPfad);
    const quellId = profilFuellen(quelle, 'Notebook', 45, '2026-12-01');
    quelle.close();

    const ergebnis = uebernehmen(ziel, quellPfad, quellId);

    expect(ergebnis.art).toBe('uebernommen');
    if (ergebnis.art !== 'uebernommen') return;
    expect(ergebnis.name).toBe('Notebook');
    expect(ergebnis.antworten).toBe(45);
    expect(ergebnis.bearbeiteteFragen).toBe(20);

    /* Das vorhandene Profil ist zeichengenau, wie es war. */
    expect(ziel.prepare('SELECT name FROM profil ORDER BY id').all()).toEqual([
      { name: 'Standard' },
      { name: 'Notebook' },
    ]);
    expect(ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = 1').get()).toEqual(
      { n: 30 },
    );
    expect(
      ziel
        .prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = ?')
        .get(ergebnis.profilId),
    ).toEqual({ n: 45 });
    /* Der Prüfungstermin kommt mit – er gehört zum Profil. */
    expect(
      ziel.prepare('SELECT pruefungstermin AS t FROM profil WHERE id = ?').get(ergebnis.profilId),
    ).toEqual({ t: '2026-12-01' });
  });

  /*
    Die Wache, die gefehlt hat.

    `kapitel_ausschluss` steht als Spalte am Profil, nicht in einer
    Kindtabelle – und wurde deshalb von der Schleife über `KINDTABELLEN`
    nicht erfasst. Das `SELECT` daneben las bis 0.26.4 nur `name` und
    `pruefungstermin`; die Spalte fiel auf ihren Vorgabewert `'[]'` zurück.

    Die Folge trifft genau den Weg, den das Handbuch für zwei Geräte
    empfiehlt: Wer auf dem Rechner ein Kapitel abgewählt hat – etwa „Not- und
    Seenotsignalmittel“, das nicht jede Prüfung verlangt – bekommt es auf dem
    zweiten Rechner stillschweigend zurück. Die Prüfungsreife fällt dabei um
    zwei Ampelstufen, nachgerechnet in `shared/verlust.ts`: derselbe Bestand
    von 85,0 auf 71,9 Prozent. Nichts sagt es, nichts fragt nach.
  */
  it('nimmt die Kapitelabwahl mit – sie gehört zum Profil wie der Termin', () => {
    const ziel = datenbank(join(ordner, 'lernstand.db'));
    profilFuellen(ziel, 'Standard', 5);

    const quellPfad = join(ordner, 'quelle.db');
    const quelle = datenbank(quellPfad);
    const quellId = profilFuellen(quelle, 'Notebook', 12);
    quelle.prepare('UPDATE profil SET kapitel_ausschluss = ? WHERE id = ?').run('["IV"]', quellId);
    quelle.close();

    const ergebnis = uebernehmen(ziel, quellPfad, quellId);

    expect(ergebnis.art).toBe('uebernommen');
    if (ergebnis.art !== 'uebernommen') return;
    expect(
      ziel
        .prepare('SELECT kapitel_ausschluss AS k FROM profil WHERE id = ?')
        .get(ergebnis.profilId),
    ).toEqual({ k: '["IV"]' });

    /* Und das vorhandene Profil behält seine eigene Abwahl – hier keine. */
    expect(ziel.prepare('SELECT kapitel_ausschluss AS k FROM profil WHERE id = 1').get()).toEqual({
      k: '[]',
    });
  });

  it('benennt ein gleichnamiges Profil um, statt es anzutasten', () => {
    const ziel = datenbank(join(ordner, 'lernstand.db'));
    profilFuellen(ziel, 'Olaf', 10);

    const quellPfad = join(ordner, 'quelle.db');
    const quelle = datenbank(quellPfad);
    const quellId = profilFuellen(quelle, 'Olaf', 7);
    quelle.close();

    const ergebnis = uebernehmen(ziel, quellPfad, quellId);

    expect(ergebnis.art === 'uebernommen' && ergebnis.name).toBe('Olaf (übernommen)');
    expect(ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = 1').get()).toEqual(
      { n: 10 },
    );
  });

  /*
    Der Einwand, an dem der erste Entwurf durchgefallen ist: Benutzten beide
    Wege denselben Dateivorsatz, löschten drei harmlose Übernahmen den
    einzigen Rückweg eines vorangegangenen Ersetzens – nachgestellt mit 900
    Antworten, die danach unwiederbringlich fort waren.
  */
  it('räumt nur die eigenen Sicherheitskopien ab, nie die des Ersetzens', () => {
    const ziel = datenbank(join(ordner, 'lernstand.db'));
    profilFuellen(ziel, 'Standard', 5);

    const quellPfad = join(ordner, 'quelle.db');
    const quelle = datenbank(quellPfad);
    const quellId = profilFuellen(quelle, 'Gast', 3);
    quelle.close();

    /* Eine Kopie des ANDEREN Weges, wie sie das Ersetzen anlegt. */
    const fremde = join(ordner, 'Lernstand-vor-dem-Einspielen-2026-08-22-120000.wsklernstand');
    writeFileSync(fremde, 'egal');

    for (let i = 0; i < 4; i += 1) {
      const ergebnis = uebernehmen(ziel, quellPfad, quellId);
      expect(ergebnis.art).toBe('uebernommen');
    }

    const dateien = readdirSync(ordner);
    expect(dateien).toContain('Lernstand-vor-dem-Einspielen-2026-08-22-120000.wsklernstand');
    expect(dateien.filter((n) => n.startsWith(VOR_DEM_UEBERNEHMEN)).length).toBeLessThanOrEqual(3);
  });

  it('lässt bei einer unbekannten Profilnummer alles unverändert', () => {
    const ziel = datenbank(join(ordner, 'lernstand.db'));
    profilFuellen(ziel, 'Standard', 12);

    const quellPfad = join(ordner, 'quelle.db');
    const quelle = datenbank(quellPfad);
    profilFuellen(quelle, 'Gast', 3);
    quelle.close();

    const ergebnis = uebernehmen(ziel, quellPfad, 999);

    expect(ergebnis.art).toBe('gescheitert');
    expect(ziel.prepare('SELECT COUNT(*) AS n FROM profil').get()).toEqual({ n: 1 });
    expect(ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 12 });
  });

  it('meldet eine fehlende Datei, ohne etwas anzufassen', () => {
    const ziel = datenbank(join(ordner, 'lernstand.db'));
    profilFuellen(ziel, 'Standard', 4);

    const ergebnis = uebernehmen(ziel, join(ordner, 'gibt-es-nicht.db'), 1);

    expect(ergebnis.art).toBe('gescheitert');
    expect(ziel.prepare('SELECT COUNT(*) AS n FROM profil').get()).toEqual({ n: 1 });
    expect(readdirSync(ordner).filter((n) => n.startsWith(VOR_DEM_UEBERNEHMEN))).toHaveLength(0);
  });

  /*
    Eine ältere Sicherung kennt die später ergänzten Spalten nicht. Sie zu
    verlangen bräche die Übernahme; sie zu erfinden wäre schlechter, als die
    Vorgabewerte des Schemas greifen zu lassen.
  */
  it('kommt mit einer Quelle ohne die später ergänzten Spalten zurecht', () => {
    const ziel = datenbank(join(ordner, 'lernstand.db'));
    profilFuellen(ziel, 'Standard', 5);

    const quellPfad = join(ordner, 'alt.db');
    const quelle = new Database(quellPfad);
    offene.push(quelle);
    quelle.exec(`
      CREATE TABLE profil (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL UNIQUE,
        pruefungstermin TEXT,
        erstellt_am TEXT NOT NULL
      );
      CREATE TABLE antwort_log (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        profil_id INTEGER NOT NULL,
        frage_id TEXT NOT NULL,
        zeitpunkt TEXT NOT NULL,
        richtig INTEGER NOT NULL,
        bewertung TEXT NOT NULL,
        dauer_ms INTEGER NOT NULL
      );
    `);
    quelle
      .prepare('INSERT INTO profil (name, pruefungstermin, erstellt_am) VALUES (?, NULL, ?)')
      .run('Alt', '2025-01-01T00:00:00.000Z');
    quelle
      .prepare(
        `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms)
         VALUES (1, 'I.1-01', '2025-06-01T09:00:00.000Z', 1, 'gut', 500)`,
      )
      .run();
    quelle.close();

    const ergebnis = uebernehmen(ziel, quellPfad, 1);

    expect(ergebnis.art).toBe('uebernommen');
    if (ergebnis.art !== 'uebernommen') return;
    expect(ergebnis.antworten).toBe(1);
    /* Die fehlenden Spalten stehen auf ihrem Vorgabewert, nicht auf Geratenem. */
    expect(
      ziel
        .prepare('SELECT nur_historie AS h, auswahl AS a FROM antwort_log WHERE profil_id = ?')
        .get(ergebnis.profilId),
    ).toEqual({ h: 0, a: '[]' });
  });

  it('legt vor jeder Übernahme eine Sicherheitskopie an', () => {
    const ziel = datenbank(join(ordner, 'lernstand.db'));
    profilFuellen(ziel, 'Standard', 8);

    const quellPfad = join(ordner, 'quelle.db');
    const quelle = datenbank(quellPfad);
    const quellId = profilFuellen(quelle, 'Gast', 2);
    quelle.close();

    const ergebnis = uebernehmen(ziel, quellPfad, quellId);

    expect(ergebnis.art === 'uebernommen' && ergebnis.sicherheitskopie).toMatch(
      /^Lernstand-vor-dem-Uebernehmen-/u,
    );
    const kopie = readdirSync(ordner).find((n) => n.startsWith(VOR_DEM_UEBERNEHMEN));
    expect(kopie).toBeDefined();

    /* Die Kopie enthält den Stand VOR der Übernahme. */
    const alt = new Database(join(ordner, kopie ?? ''), { readonly: true });
    offene.push(alt);
    expect(alt.prepare('SELECT COUNT(*) AS n FROM profil').get()).toEqual({ n: 1 });
  });
});
