/**
 * Ein einzelnes Profil aus einer Sicherung dazunehmen.
 *
 * ## Was das ist – und was ausdrücklich nicht
 *
 * Kopiert. Nicht verschmolzen. Das Profil kommt als **neues** Profil dazu; ein
 * gleichnamiges hier bleibt unberührt und behält seinen Namen, das
 * dazugekommene bekommt einen Zusatz. Zwei Historien desselben Profils
 * zusammenzurechnen ist begründet abgelehnt – siehe
 * `docs/entscheidung-lernstand-verschmelzen.md`.
 *
 * ## Warum es einen eigenen Dateivorsatz für die Sicherheitskopie gibt
 *
 * Das Ersetzen legt vor jedem Durchgang eine Sicherheitskopie unter dem
 * Vorsatz `Lernstand-vor-dem-Einspielen` an und behält davon drei. Würde das
 * Übernehmen denselben Vorsatz benutzen, löschten **drei harmlose
 * Übernahmen** den einzigen Rückweg eines vorangegangenen Ersetzens – ohne
 * dass es jemand erführe. Nachgestellt: 900 Antworten ersetzt, danach dreimal
 * übernommen, und keine der drei Dateien mit dem Namen „vor dem Einspielen“
 * enthält noch den Stand von vor dem Einspielen.
 *
 * Die beiden Wege teilen sich deshalb nichts: eigener Vorsatz, eigenes
 * Kontingent.
 *
 * ## Warum ATTACH und eine einzige Transaktion
 *
 * `ATTACH` legt die Quelldatei neben die laufende Datenbank, ohne sie zu
 * öffnen; alles Weitere sind `INSERT … SELECT` in einer Transaktion. Ein Wurf
 * mittendrin rollt vollständig zurück – nachgemessen bleibt der Fingerabdruck
 * der laufenden Datenbank dabei unverändert.
 *
 * `VACUUM INTO` weigert sich innerhalb einer Transaktion; die Reihenfolge
 * „Sicherheitskopie, dann BEGIN“ ist damit erzwungen und nicht bloss gewählt.
 */

import { existsSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';

import type BetterSqlite3 from 'better-sqlite3';

import type { Uebernahmeergebnis } from '../shared/sicherung';
import { aufraeumen, sicherungsDateiname, sicherungSchreiben } from './sicherung';
import type { DatenbankKonstruktor } from './sicherung';

/** Vorsatz der Sicherheitskopie vor dem Übernehmen – bewusst ein anderer. */
export const VOR_DEM_UEBERNEHMEN = 'Lernstand-vor-dem-Uebernehmen';

/** Eigenes Kontingent, damit die Wege sich nicht gegenseitig aufräumen. */
const KOPIEN_BEHALTEN = 3;

/** Tabellen, die an einem Profil hängen, in der Reihenfolge des Einfügens. */
const KINDTABELLEN = ['frage_stand', 'antwort_log', 'pruefung_lauf'] as const;

/**
 * Ein freier Name für die Kopie eines gleichnamigen Profils.
 *
 * „Olaf“ wird zu „Olaf (übernommen)“, dann „Olaf (übernommen 2)“. Ein
 * fortlaufender Zusatz statt eines Zeitstempels: Wer zwei Geräte hat, will
 * sie unterscheiden können, nicht Sekunden ablesen.
 */
export function freierProfilname(vorhanden: ReadonlySet<string>, name: string): string {
  if (!vorhanden.has(name)) {
    return name;
  }
  const erster = `${name} (übernommen)`;
  if (!vorhanden.has(erster)) {
    return erster;
  }
  for (let zaehler = 2; zaehler < 1000; zaehler += 1) {
    const kandidat = `${name} (übernommen ${String(zaehler)})`;
    if (!vorhanden.has(kandidat)) {
      return kandidat;
    }
  }
  throw new Error(`Für „${name}“ ließ sich kein freier Profilname finden.`);
}

/** Spaltennamen einer Tabelle in einer bestimmten Datenbank. */
function spalten(db: BetterSqlite3.Database, bereich: string, tabelle: string): string[] {
  return db
    .prepare<[], { name: string }>(`PRAGMA ${bereich}.table_info(${tabelle})`)
    .all()
    .map((z) => z.name);
}

/** Existiert die Tabelle im angehängten Bereich? */
function hatTabelle(db: BetterSqlite3.Database, bereich: string, tabelle: string): boolean {
  return (
    (db
      .prepare<[string], { anzahl: number }>(
        `SELECT COUNT(*) AS anzahl FROM ${bereich}.sqlite_master WHERE type = 'table' AND name = ?`,
      )
      .get(tabelle)?.anzahl ?? 0) > 0
  );
}

export interface Uebernahmeauftrag {
  /** Die laufende, geöffnete Datenbank. */
  readonly ziel: BetterSqlite3.Database;
  /** Pfad der geprüften Arbeitskopie. */
  readonly quelle: string;
  /** Nummer des Profils IN DER QUELLE. */
  readonly quellProfilId: number;
  /** Verzeichnis für die Sicherheitskopie. */
  readonly ordner: string;
  readonly jetzt: Date;
  readonly Datenbank: DatenbankKonstruktor;
}

/**
 * Führt die Übernahme aus.
 *
 * Reihenfolge, und jeder Schritt ist Bedingung für den nächsten:
 * Sicherheitskopie schreiben, anhängen, in **einer** Transaktion Profil und
 * Kindzeilen einfügen, abhängen. Misslingt die Sicherheitskopie, wird nichts
 * angefasst.
 */
export function profilUebernehmen(auftrag: Uebernahmeauftrag): Uebernahmeergebnis {
  const { ziel, quelle, quellProfilId, ordner, jetzt, Datenbank } = auftrag;

  if (!existsSync(quelle)) {
    return { art: 'gescheitert', grund: 'Die geprüfte Datei ist nicht mehr da.' };
  }

  // ── Sicherheitskopie. Ohne sie wird nichts angefasst. ────────────────
  let sicherheitskopie: string;
  try {
    sicherheitskopie = freierName(ordner, jetzt);
    sicherungSchreiben(ziel, sicherheitskopie, Datenbank);
  } catch (fehler) {
    return {
      art: 'gescheitert',
      grund:
        'Es ließ sich keine Sicherung Ihres jetzigen Lernstands anlegen, deshalb wurde nichts ' +
        `übernommen. Möglicherweise ist der Speicherplatz erschöpft. (${fehlertext(fehler)})`,
    };
  }

  let name = '';
  let antworten = 0;
  let staende = 0;
  let neueId = 0;

  try {
    ziel.prepare('ATTACH DATABASE ? AS quelle').run(quelle);
  } catch (fehler) {
    return {
      art: 'gescheitert',
      grund: `Die Datei ließ sich nicht öffnen. (${fehlertext(fehler)})`,
    };
  }

  try {
    /* `kapitel_ausschluss` hängt am Profil und nicht in einer Kindtabelle –
       die Schleife über KINDTABELLEN erfasst sie deshalb nicht. Bis 0.26.4
       stand sie auch hier nicht, und die Abwahl fiel beim Übernehmen auf
       den Vorgabewert zurück: Ein abgewähltes Kapitel kam auf dem zweiten
       Rechner stillschweigend wieder, und die Prüfungsreife fiel um zwei
       Ampelstufen (`shared/verlust.ts`: 85,0 auf 71,9 Prozent).

       Abgefragt wird sie nur, wenn die Quelle sie kennt – eine Sicherung
       von vor 0.20.0 hat die Spalte nicht, und dort ist der Vorgabewert
       „nichts abgewählt“ die richtige Annahme. */
    const kennteAusschluss = spalten(ziel, 'quelle', 'profil').includes('kapitel_ausschluss');

    const quellprofil = ziel
      .prepare<
        [number],
        { name: string; pruefungstermin: string | null; kapitel_ausschluss?: string }
      >(
        `SELECT name, pruefungstermin${kennteAusschluss ? ', kapitel_ausschluss' : ''}
         FROM quelle.profil WHERE id = ?`,
      )
      .get(quellProfilId);

    if (quellprofil === undefined) {
      return { art: 'gescheitert', grund: 'Dieses Profil steht nicht in der Datei.' };
    }

    const vorhanden = new Set(
      ziel
        .prepare<[], { name: string }>('SELECT name FROM main.profil')
        .all()
        .map((z) => z.name),
    );
    name = freierProfilname(vorhanden, quellprofil.name);

    /* Eine Transaktion für alles. Ein Wurf mittendrin lässt die laufende
       Datenbank unverändert – nachgemessen bleibt ihr Fingerabdruck gleich. */
    const uebernehmen = ziel.transaction(() => {
      const eingefuegt = ziel
        .prepare<[string, string | null, string, string]>(
          `INSERT INTO main.profil (name, pruefungstermin, kapitel_ausschluss, erstellt_am)
           VALUES (?, ?, ?, ?)`,
        )
        .run(
          name,
          quellprofil.pruefungstermin,
          quellprofil.kapitel_ausschluss ?? '[]',
          jetzt.toISOString(),
        );
      neueId = Number(eingefuegt.lastInsertRowid);

      for (const tabelle of KINDTABELLEN) {
        if (!hatTabelle(ziel, 'quelle', tabelle)) {
          continue;
        }
        /* Nur die Spalten, die BEIDE Seiten kennen. Eine ältere Sicherung
           kennt `bestaetigt` und `nur_historie` nicht; sie zu verlangen
           bräche die Übernahme, und sie zu erfinden wäre schlechter als sie
           wegzulassen – die Vorgabewerte des Schemas greifen dann. */
        const gemeinsam = spalten(ziel, 'main', tabelle).filter(
          (spalte) => spalte !== 'id' && spalten(ziel, 'quelle', tabelle).includes(spalte),
        );
        const felder = gemeinsam.map((s) => (s === 'profil_id' ? String(neueId) : `"${s}"`));
        const anzahl = ziel
          .prepare<[number]>(
            `INSERT INTO main.${tabelle} (${gemeinsam.map((s) => `"${s}"`).join(', ')})
             SELECT ${felder.join(', ')} FROM quelle.${tabelle} WHERE profil_id = ?`,
          )
          .run(quellProfilId).changes;

        if (tabelle === 'antwort_log') {
          antworten = anzahl;
        } else if (tabelle === 'frage_stand') {
          staende = anzahl;
        }
      }
    });

    uebernehmen();
  } catch (fehler) {
    return {
      art: 'gescheitert',
      grund:
        `Das Profil ließ sich nicht übernehmen; es wurde nichts verändert. (${fehlertext(fehler)}) ` +
        `Eine Sicherung Ihres Stands liegt als „${blossName(sicherheitskopie)}“ bereit.`,
    };
  } finally {
    try {
      ziel.prepare('DETACH DATABASE quelle').run();
    } catch {
      /* Ein hängengebliebener Anhang wäre schlimmer als eine Meldung, aber
         abbrechen lässt sich hier nichts mehr – die Arbeit ist getan. */
    }
  }

  kopienAufraeumen(ordner);

  return {
    art: 'uebernommen',
    profilId: neueId,
    name,
    antworten,
    bearbeiteteFragen: staende,
    sicherheitskopie: blossName(sicherheitskopie),
  };
}

function blossName(pfad: string): string {
  return pfad.split(/[\\/]/u).pop() ?? pfad;
}

function fehlertext(fehler: unknown): string {
  return fehler instanceof Error ? fehler.message : String(fehler);
}

function freierName(ordner: string, jetzt: Date): string {
  const grund = join(ordner, sicherungsDateiname(jetzt, VOR_DEM_UEBERNEHMEN));
  if (!existsSync(grund)) {
    return grund;
  }
  for (let zaehler = 2; zaehler < 100; zaehler += 1) {
    const kandidat = grund.replace(/\.wsklernstand$/u, `-${String(zaehler)}.wsklernstand`);
    if (!existsSync(kandidat)) {
      return kandidat;
    }
  }
  throw new Error('Es ließ sich kein freier Name für die Sicherheitskopie finden.');
}

/** Räumt ausschliesslich die eigenen Kopien ab – nie die des Ersetzens. */
function kopienAufraeumen(ordner: string): void {
  try {
    const kopien = readdirSync(ordner)
      .filter(
        (name) => name.startsWith(`${VOR_DEM_UEBERNEHMEN}-`) && name.endsWith('.wsklernstand'),
      )
      .map((name) => ({ name, zeit: statSync(join(ordner, name)).mtimeMs }))
      .sort((a, b) => b.zeit - a.zeit);
    for (const alt of kopien.slice(KOPIEN_BEHALTEN)) {
      aufraeumen(join(ordner, alt.name));
    }
  } catch {
    /* Aufräumen ist Komfort. Misslingt es, bleiben ein paar Dateien mehr
       liegen – kein Grund, einen erfolgreichen Vorgang anders zu melden. */
  }
}
