// @vitest-environment node
/**
 * Die selbsttätige Sicherheitskopie beim Beenden.
 *
 * **Der Anlass.** Bis 0.22.0 entstand eine Sicherung ausschließlich, wenn
 * jemand die Karte „Lernstand sichern und übertragen“ aufschlug und bediente.
 * Die einzige selbsttätige Kopie entstand vor dem *Einspielen* einer fremden
 * Sicherung – also genau dann, wenn ohnehin jemand mit Sicherungen hantiert.
 * Wer die Karte nie öffnete, hatte nichts: Ein versehentliches Löschen oder
 * eine beschädigte Datei nahmen alles mit, was über Wochen gelernt worden war.
 *
 * Geprüft wird an echten Dateien, nicht an Attrappen – einschließlich der
 * Frage, ob die Kopie hinterher wirklich eine brauchbare Datenbank ist.
 */

import {
  existsSync,
  readFileSync,
  mkdirSync,
  readdirSync,
  rmSync,
  mkdtempSync,
  utimesSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';

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

import { Lernstand } from '../src/main/lernstand';
import {
  ABSTAND_MS,
  kopieVorDemVerwerfen,
  VOR_DEM_VERWERFEN,
  istFaellig,
  KOPIEN_BEHALTEN,
  selbstsicherungAnlegen,
  selbstsicherungsordner,
  SELBSTSICHERUNG_VORSATZ,
} from '../src/main/selbstsicherung';
import type { Katalog } from '../src/shared/katalog';

const KATALOG: Katalog = {
  meta: { titel: 'Test', herausgeber: 'Test', stand: '2024-12-16', quelle: 'Test' },
  kapitel: [{ id: 'I', titel: 'Recht', abschnitte: [{ id: 'I.1', titel: 'Grundlagen' }] }],
  bilder: {},
  fragen: [
    {
      id: 'I.1-1',
      kapitel: 'I',
      abschnitt: 'I.1',
      nummer: '1',
      frage: 'Eine Frage?',
      optionen: [{ label: 'a', text: 'Antwort', richtig: true }],
    },
  ],
} as unknown as Katalog;

let userData = '';
let db: Database.Database;

/** Alle Kopien im Sicherungsordner. */
function kopien(): string[] {
  const ordner = selbstsicherungsordner(userData);
  return existsSync(ordner)
    ? readdirSync(ordner).filter((name) => name.startsWith(SELBSTSICHERUNG_VORSATZ))
    : [];
}

beforeEach(() => {
  userData = mkdtempSync(join(tmpdir(), 'wsk-selbstsicherung-'));
  db = new Database(join(userData, 'lernstand.db'));
  new Lernstand(db, KATALOG);
});

afterEach(() => {
  db.close();
  rmSync(userData, { recursive: true, force: true });
});

describe('Fälligkeit', () => {
  it('ist fällig, wenn noch nie gesichert wurde', () => {
    expect(istFaellig(null, Date.now())).toBe(true);
  });

  it('ist nicht fällig, solange die letzte Kopie frisch ist', () => {
    const jetzt = Date.UTC(2026, 7, 29);
    expect(istFaellig(jetzt - ABSTAND_MS + 1000, jetzt)).toBe(false);
  });

  it('ist fällig, sobald der Abstand erreicht ist', () => {
    const jetzt = Date.UTC(2026, 7, 29);
    expect(istFaellig(jetzt - ABSTAND_MS, jetzt)).toBe(true);
  });

  it('gilt eine Kopie aus der Zukunft als überfällig', () => {
    /* Ein Uhrsprung oder ein kopierter Ordner kann einen Zeitstempel aus der
       Zukunft hinterlassen. Gälte er als frisch, unterbliebe die Sicherung
       bis zu dem Tag, den er behauptet – unter Umständen jahrelang. */
    const jetzt = Date.UTC(2026, 7, 29);
    expect(istFaellig(jetzt + ABSTAND_MS, jetzt)).toBe(true);
  });
});

describe('Kopie anlegen', () => {
  it('legt beim ersten Mal eine Kopie an', () => {
    const pfad = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00'));

    expect(pfad).not.toBeNull();
    expect(existsSync(pfad ?? '')).toBe(true);
    expect(kopien()).toHaveLength(1);
  });

  it('schreibt eine Kopie, die sich wirklich öffnen lässt', () => {
    /* Eine Sicherungsdatei, die niemand einlesen kann, ist schlimmer als
       keine: Sie wiegt in Sicherheit, bis es darauf ankommt. */
    const pfad = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00'));

    const kopie = new Database(pfad ?? '', { readonly: true });
    try {
      const zeilen = kopie.pragma('integrity_check') as { integrity_check: string }[];
      expect(zeilen[0]?.integrity_check).toBe('ok');
      const profile = kopie
        .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil')
        .get();
      expect(profile?.anzahl).toBeGreaterThan(0);
    } finally {
      kopie.close();
    }
  });

  it('legt keine zweite Kopie an, solange die erste frisch ist', () => {
    const erste = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00'));
    /* Über die Frische entscheidet die Änderungszeit der Datei, nicht ihr
       Name. Ohne dieses Zurückstellen redete die Systemuhr mit: Die Kopie
       entsteht im Augenblick des Laufs, und sobald dieser Augenblick nach
       dem zweiten Aufruf liegt, gilt sie nach `istFaellig` als aus der
       Zukunft und damit als überfällig. Der Fall war keine Theorie – ab dem
       30.08.2026, 20:00 Uhr war dieser Test rot, ohne dass sich am geprüften
       Verhalten etwas geändert hätte. */
    const angelegt = new Date('2026-08-29T20:00:00');
    utimesSync(erste ?? '', angelegt, angelegt);

    const zweite = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-30T20:00:00'));

    expect(zweite).toBeNull();
    expect(kopien()).toHaveLength(1);
  });

  it('legt wieder eine an, sobald der Abstand überschritten ist', () => {
    const erste = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00'));
    /* Entscheidend ist die Änderungszeit der Datei, nicht der Name – sie
       wird hier zurückgestellt, statt eine Woche zu warten. */
    const alt = new Date(Date.now() - ABSTAND_MS - 60_000);
    utimesSync(erste ?? '', alt, alt);

    const zweite = selbstsicherungAnlegen(db, userData, Database, new Date());

    expect(zweite).not.toBeNull();
    expect(kopien()).toHaveLength(2);
  });

  it('behält nur die jüngsten Kopien', () => {
    for (let lauf = 0; lauf < KOPIEN_BEHALTEN + 2; lauf++) {
      const pfad = selbstsicherungAnlegen(db, userData, Database, new Date(2026, 7, 1 + lauf, 20));
      if (pfad !== null) {
        const alt = new Date(Date.now() - ABSTAND_MS * (10 - lauf));
        utimesSync(pfad, alt, alt);
      }
    }

    expect(kopien().length).toBeLessThanOrEqual(KOPIEN_BEHALTEN);
  });

  it('rührt fremde Dateien im Ordner nicht an', () => {
    /* Aufgeräumt wird ausschließlich das eigene Muster. Ein Ordner, in dem
       ein Programm nach Gutdünken löscht, ist kein Ort für Sicherungen. */
    const ordner = selbstsicherungsordner(userData);
    mkdirSync(ordner, { recursive: true });
    const fremd = join(ordner, 'Wichtige-eigene-Datei.wsklernstand');
    new Database(fremd).close();

    for (let lauf = 0; lauf < KOPIEN_BEHALTEN + 2; lauf++) {
      const pfad = selbstsicherungAnlegen(db, userData, Database, new Date(2026, 7, 1 + lauf, 20));
      if (pfad !== null) {
        const alt = new Date(Date.now() - ABSTAND_MS * (10 - lauf));
        utimesSync(pfad, alt, alt);
      }
    }

    expect(existsSync(fremd)).toBe(true);
  });

  it('legt den Ordner selbst an', () => {
    expect(existsSync(selbstsicherungsordner(userData))).toBe(false);

    selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00'));

    expect(existsSync(selbstsicherungsordner(userData))).toBe(true);
  });
});

describe('Kopie vor dem Verwerfen', () => {
  /*
    Die Wache, die gefehlt hat.

    Beide Einspielwege legen vorher eine Sicherheitskopie an – das Einspielen
    einer fremden Sicherung und das Übernehmen eines Profils. Die beiden
    Wege, die tatsächlich etwas vernichten, taten es nicht: Wer „Neu
    anfangen“ drückte oder ein Profil löschte, war den Lernstand los, und
    die selbsttätige Wochenkopie war je nach Tag bis zu sieben Tage alt.
  */
  function kopien(vorsatz: string): string[] {
    const ordner = selbstsicherungsordner(userData);
    return existsSync(ordner) ? readdirSync(ordner).filter((name) => name.startsWith(vorsatz)) : [];
  }

  it('legt eine Kopie an, auch wenn eine Wochenkopie eben erst entstand', () => {
    /* Der Unterschied zur Wochenkopie in einem Fall: Sie ist ungedrosselt.
       Wäre sie es nicht, fiele sie genau dann aus, wenn sie gebraucht wird. */
    selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00'));
    const pfad = kopieVorDemVerwerfen(db, userData, Database, new Date('2026-08-29T20:00:05'));

    expect(existsSync(pfad)).toBe(true);
    expect(kopien(VOR_DEM_VERWERFEN)).toHaveLength(1);
  });

  it('schreibt eine Kopie, die sich wirklich öffnen lässt', () => {
    const pfad = kopieVorDemVerwerfen(db, userData, Database, new Date('2026-08-29T20:00:00'));

    const kopie = new Database(pfad, { readonly: true });
    try {
      expect(
        (kopie.pragma('integrity_check') as { integrity_check: string }[])[0]?.integrity_check,
      ).toBe('ok');
      expect(
        kopie.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil').get()
          ?.anzahl,
      ).toBeGreaterThan(0);
    } finally {
      kopie.close();
    }
  });

  it('räumt nur die eigenen Kopien ab, nie die der Wochensicherung', () => {
    selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00'));

    for (let lauf = 0; lauf < KOPIEN_BEHALTEN + 2; lauf += 1) {
      const pfad = kopieVorDemVerwerfen(
        db,
        userData,
        Database,
        new Date(2026, 7, 1 + lauf, 20, 0, 0),
      );
      const alt = new Date(Date.now() - 60_000 * (10 - lauf));
      utimesSync(pfad, alt, alt);
    }

    expect(kopien(VOR_DEM_VERWERFEN).length).toBeLessThanOrEqual(KOPIEN_BEHALTEN);
    /* Die Wochenkopie ist unangetastet – zwei Kontingente, zwei Wege. */
    expect(kopien(SELBSTSICHERUNG_VORSATZ)).toHaveLength(1);
  });

  it('weicht auf einen zweiten Namen aus, statt zu überschreiben', () => {
    const jetzt = new Date('2026-08-29T20:00:00');
    const erst = kopieVorDemVerwerfen(db, userData, Database, jetzt);
    const zweit = kopieVorDemVerwerfen(db, userData, Database, jetzt);

    expect(zweit).not.toBe(erst);
    expect(existsSync(erst)).toBe(true);
    expect(existsSync(zweit)).toBe(true);
  });

  it('wirft, wenn sich nichts schreiben lässt – dann wird auch nichts verworfen', () => {
    /* Der Unterschied zur Wochenkopie im zweiten Fall: Sie schweigt bei
       einem Fehlschlag, diese hier nicht. Vor einem Schritt, der Daten
       vernichtet, ist Schweigen die falsche Antwort. */
    db.close();

    expect(() => kopieVorDemVerwerfen(db, userData, Database, new Date())).toThrow();
  });
});

describe('Die Verdrahtung der Kopie vor dem Verwerfen', () => {
  /*
    Die Funktion allein nützt nichts – sie muss an den beiden zerstörenden
    Wegen auch gerufen werden, und zwar **vor** ihnen. Für die IPC-Schicht
    gibt es in diesem Projekt keinen Prüfstand; gelesen wird deshalb der
    Quelltext, so wie es `sicherheit.test.ts` mit dem Sicherheitsmodell tut.
    Eine Zusicherung an der Quelle ist schwächer als eine am laufenden
    Programm – aber ungleich stärker als gar keine.
  */
  const ipcQuelle = readFileSync(
    join(fileURLToPath(new URL('..', import.meta.url)), 'src', 'main', 'ipc.ts'),
    'utf8',
  );

  it('ruft die Kopie im Kanal „profil:loeschen“, bevor gelöscht wird', () => {
    const block = /'profil:loeschen'[\s\S]*?\n {2}\}\);/u.exec(ipcQuelle)?.[0] ?? '';

    expect(block).toContain('kopieVorVerwerfen()');
    expect(block.indexOf('kopieVorVerwerfen()')).toBeLessThan(block.indexOf('profilLoeschen('));
  });

  it('ruft die Kopie im Kanal „lernen:zuruecksetzen“, bevor zurückgesetzt wird', () => {
    const block = /'lernen:zuruecksetzen'[\s\S]*?\n {2}\}\);/u.exec(ipcQuelle)?.[0] ?? '';

    expect(block).toContain('kopieVorVerwerfen()');
    expect(block.indexOf('kopieVorVerwerfen()')).toBeLessThan(block.indexOf('zuruecksetzen('));
  });

  it('fängt den Fehlschlag nicht ab – sonst wäre die Kopie eine Zugabe', () => {
    /* Beim Beenden ist die Kopie eine Zugabe und ihr Fehlschlag wird
       geschluckt. Hier darf er das nicht: `behandeln` reicht den Fehler an
       die Oberfläche durch, und der zerstörende Schritt unterbleibt. */
    const funktion = /function kopieVorVerwerfen\(\)[\s\S]*?\n\}/u.exec(ipcQuelle)?.[0] ?? '';

    expect(funktion.length).toBeGreaterThan(0);
    expect(funktion).not.toContain('catch');
  });
});
