import fs from 'fs';
import path from 'path';
import { getSetting, getSettingBool, setSetting } from './appSetting.service.js';

/**
 * BLZ-/Bankdaten-Service.
 *
 * Liefert BIC + Banknamen zu einer deutschen BLZ/IBAN. Zwei Datenquellen,
 * in dieser Reihenfolge:
 *   1. VOLUME  – ein zur Laufzeit aktualisierter Datensatz unter BANKDATA_DIR
 *               (Bind-Mount, siehe docker-compose). Wird vom Auto-Updater
 *               befüllt.
 *   2. BUILTIN – die ins Image gebackenen JSON-Daten des npm-Pakets
 *               `bankdata-germany` (Fallback, immer vorhanden).
 *
 * Es wird KEIN Paketcode ausgeführt – nur die reinen JSON-Datendateien
 * (current.json = { "<BLZ>": ["Name","BIC"] }, next.json = Delta der nächsten
 * Periode) werden gelesen und mit eigener Logik indiziert.
 *
 * Datenschutz: Beim Lookup verlässt KEINE IBAN den Server. Nur der
 * Auto-Updater macht ausgehende Requests – und lädt dabei lediglich eine
 * öffentliche Datendatei (keine Kundendaten).
 */

// Kompiliert nach CommonJS – das native `require` ist zur Laufzeit verfügbar
// und wird nur genutzt, um den Pfad der gebackenen Paketdaten aufzulösen.
declare const require: NodeRequire;

// ---- Pfade / Quellen (per Env überschreibbar) ----
const BANKDATA_DIR = process.env.BANKDATA_DIR || path.join(process.cwd(), 'bankdata');
const DATASET_FILE = path.join(BANKDATA_DIR, 'blz-dataset.json');
const PACKAGE = 'bankdata-germany';
const CDN_BASE = process.env.BLZ_CDN_BASE || 'https://cdn.jsdelivr.net/npm';
const REGISTRY_BASE = process.env.BLZ_REGISTRY_BASE || 'https://registry.npmjs.org';
const FETCH_TIMEOUT_MS = 20_000;

// ---- Typen ----
type BankTuple = [string, string?]; // [bankName, bic?] – BIC fehlt bei manchen Banken
type CurrentData = Record<string, BankTuple>;
interface NextData {
  valid: string;
  upsert: Record<string, BankTuple>;
  remove: string[];
}
interface RawDataset {
  current: CurrentData;
  next: NextData;
}
interface LoadedDataset extends RawDataset {
  source: 'volume' | 'builtin';
  version: string;
  fetchedAt: string | null;
}
export interface BankInfo {
  bankName: string;
  bic: string;
  blz: string;
}

// ---- In-Memory-Cache (per mtime invalidiert) ----
let cache: { dataset: LoadedDataset; combined: CurrentData; key: string } | null = null;

function builtinDataDir(): string {
  // require.resolve liefert .../dist/cjs/main.js → data/ liegt daneben.
  const main = require.resolve(PACKAGE);
  return path.join(path.dirname(main), 'data');
}

function builtinVersion(): string {
  try {
    const main = require.resolve(PACKAGE);
    // main = <root>/dist/cjs/main.js → package.json zwei Ebenen höher.
    const pkgPath = path.join(path.dirname(main), '..', '..', 'package.json');
    const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
    return typeof pkg.version === 'string' ? pkg.version : 'unbekannt';
  } catch {
    return 'unbekannt';
  }
}

function readJson(file: string): any {
  return JSON.parse(fs.readFileSync(file, 'utf8'));
}

/**
 * Lädt den aktiven Datensatz (Volume bevorzugt, sonst Builtin). Cached anhand
 * der mtime der Volume-Datei bzw. eines statischen Keys für Builtin.
 */
function loadDataset(): LoadedDataset {
  // Volume vorhanden?
  let volumeMtime: number | null = null;
  try {
    volumeMtime = fs.statSync(DATASET_FILE).mtimeMs;
  } catch {
    volumeMtime = null;
  }

  const key = volumeMtime !== null ? `volume:${volumeMtime}` : 'builtin';
  if (cache && cache.key === key) return cache.dataset;

  let dataset: LoadedDataset;
  if (volumeMtime !== null) {
    try {
      const raw: any = readJson(DATASET_FILE);
      // Metadaten VOR der Assertion lesen (die narrowt raw auf RawDataset).
      const version = typeof raw.version === 'string' ? raw.version : 'unbekannt';
      const fetchedAt = typeof raw.fetchedAt === 'string' ? raw.fetchedAt : null;
      assertValidRawDataset(raw);
      dataset = {
        source: 'volume',
        version,
        fetchedAt,
        current: raw.current,
        next: raw.next,
      };
    } catch (err) {
      console.error('[BLZ] Volume-Datensatz unlesbar, nutze Builtin:', err);
      dataset = loadBuiltin();
    }
  } else {
    dataset = loadBuiltin();
  }

  const combined = combine(dataset);
  cache = { dataset, combined, key };
  return dataset;
}

function loadBuiltin(): LoadedDataset {
  const dir = builtinDataDir();
  const current = readJson(path.join(dir, 'current.json')) as CurrentData;
  const next = readJson(path.join(dir, 'next.json')) as NextData;
  return { source: 'builtin', version: builtinVersion(), fetchedAt: null, current, next };
}

/**
 * Kombiniert current + next-Delta, wenn das aktuelle Datum den Gültig-ab-
 * Zeitpunkt der nächsten Periode erreicht hat (identische Logik wie das
 * npm-Paket).
 */
function combine(ds: RawDataset): CurrentData {
  const validFrom = new Date(ds.next?.valid);
  if (ds.next && !Number.isNaN(validFrom.getTime()) && new Date() >= validFrom) {
    const merged: CurrentData = { ...ds.current, ...ds.next.upsert };
    for (const blz of ds.next.remove || []) delete merged[blz];
    return merged;
  }
  return ds.current;
}

function combinedData(): CurrentData {
  loadDataset();
  return cache!.combined;
}

// ---- Lookup ----

export function lookupBlz(blz: string): BankInfo | null {
  if (!/^[1-9]\d{7}$/.test(blz)) return null;
  const entry = combinedData()[blz];
  if (!entry) return null;
  return { bankName: entry[0], bic: entry[1] ?? '', blz };
}

/** Extrahiert die BLZ aus einer deutschen IBAN und schlägt sie nach. */
export function lookupByIban(iban: string): BankInfo | null {
  if (!/^DE\d{20}$/i.test(iban)) return null;
  // IBAN: DE + 2 Prüfziffern + 8 BLZ + 10 Kontonummer
  return lookupBlz(iban.slice(4, 12));
}

// ---- Validierung des Roh-Datensatzes (gegen Müll/HTML-Antworten) ----

// Prüft eine BLZ→[Name,BIC]-Map vollständig: JEDER Schlüssel eine 8-stellige
// BLZ, JEDER Wert genau [string, string] (BIC darf leer sein). Nicht nur die
// erste Zeile (Pentest R148, Code-Note) – sonst käme ein Set mit 1 echten +
// 999 Fake-Einträgen durch.
function assertValidBankMap(map: Record<string, unknown>, label: string): void {
  for (const [blz, entry] of Object.entries(map)) {
    if (!/^\d{8}$/.test(blz)) {
      throw new Error(`${label}: ungültige BLZ "${blz}"`);
    }
    // Wert ist [Name] (Bank ohne BIC) ODER [Name, BIC]. Beides ist in der
    // echten Bundesbank-Datei vorhanden (z.B. reine Zahlungsverkehr-BLZ).
    if (
      !Array.isArray(entry) ||
      entry.length < 1 ||
      entry.length > 2 ||
      typeof entry[0] !== 'string' ||
      (entry.length === 2 && typeof entry[1] !== 'string')
    ) {
      throw new Error(`${label}: Eintrag ${blz} hat unerwartetes Format`);
    }
  }
}

function assertValidRawDataset(raw: any): asserts raw is RawDataset {
  if (!raw || typeof raw !== 'object') throw new Error('Datensatz ist kein Objekt');
  const cur = raw.current;
  if (!cur || typeof cur !== 'object' || Array.isArray(cur)) throw new Error('current fehlt/ungültig');
  const keys = Object.keys(cur);
  if (keys.length < 1000) throw new Error(`current zu klein (${keys.length} Einträge)`);
  // VOLLSTÄNDIGE Formatprüfung aller Einträge, nicht nur des ersten.
  assertValidBankMap(cur, 'current');

  const next = raw.next;
  if (!next || typeof next !== 'object' || typeof next.valid !== 'string' || typeof next.upsert !== 'object' || Array.isArray(next.upsert) || !Array.isArray(next.remove)) {
    throw new Error('next fehlt/ungültig');
  }
  // Datum plausibel?
  if (Number.isNaN(new Date(next.valid).getTime())) {
    throw new Error('next.valid ist kein gültiges Datum');
  }
  // upsert-Einträge ebenso streng; remove muss aus 8-stelligen BLZ bestehen.
  assertValidBankMap(next.upsert, 'next.upsert');
  for (const blz of next.remove) {
    if (typeof blz !== 'string' || !/^\d{8}$/.test(blz)) {
      throw new Error(`next.remove enthält ungültige BLZ "${blz}"`);
    }
  }
}

// ---- Ausgehende Requests (nur Updater) ----

async function fetchJson(url: string): Promise<any> {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
  try {
    const res = await fetch(url, {
      signal: ctrl.signal,
      headers: { Accept: 'application/json', 'User-Agent': 'OpenCRM-BLZ-Updater' },
    });
    if (!res.ok) throw new Error(`HTTP ${res.status} bei ${url}`);
    return await res.json();
  } finally {
    clearTimeout(t);
  }
}

// Erlaubtes Versionsformat (semver-Kern, rein numerisch). Die Version wird in
// die CDN-URL interpoliert – ohne strikte Prüfung könnte eine manipulierte
// Registry-Antwort (`../`, Slashes, Query-Zeichen) den Pfad verbiegen. Der Host
// bleibt zwar fix (CDN_BASE), aber wir lassen nur `1.2.3` zu.
const VERSION_RE = /^\d{1,6}\.\d{1,6}\.\d{1,6}$/;

/** Ermittelt die neueste verfügbare Paket-Version (npm dist-tag latest). */
export async function fetchLatestVersion(): Promise<string> {
  const manifest = await fetchJson(`${REGISTRY_BASE}/${PACKAGE}/latest`);
  if (!manifest || typeof manifest.version !== 'string') throw new Error('Registry lieferte keine Version');
  if (!VERSION_RE.test(manifest.version)) {
    throw new Error(`Unerwartetes Versionsformat: ${manifest.version}`);
  }
  return manifest.version;
}

// ---- Versionsvergleich (numerische Segmente, z.B. 2.2603.0) ----

export function compareVersions(a: string, b: string): number {
  const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
  const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
    const d = (pa[i] || 0) - (pb[i] || 0);
    if (d !== 0) return d > 0 ? 1 : -1;
  }
  return 0;
}

function activeVersion(): string {
  return loadDataset().version;
}

// ---- Update-Durchführung ----

let updating = false;

export interface UpdateResult {
  changed: boolean;
  version: string;
  message: string;
}

/**
 * Lädt (falls neuer) den aktuellen Datensatz von der CDN ins Volume.
 * `force` lädt auch bei gleicher Version neu.
 */
export async function runUpdate(force = false): Promise<UpdateResult> {
  if (updating) {
    return { changed: false, version: activeVersion(), message: 'Update läuft bereits.' };
  }
  updating = true;
  const nowIso = new Date().toISOString();
  try {
    const latest = await fetchLatestVersion();
    await setSetting('blzLatestVersion', latest);
    await setSetting('blzLastCheckedAt', nowIso);

    const current = activeVersion();
    if (!force && current !== 'unbekannt' && compareVersions(latest, current) <= 0) {
      await setSetting('blzLastError', '');
      return { changed: false, version: current, message: `Bereits aktuell (${current}).` };
    }

    // Datendateien der Zielversion laden.
    const base = `${CDN_BASE}/${PACKAGE}@${latest}/dist/cjs/data`;
    const [currentData, nextData] = await Promise.all([
      fetchJson(`${base}/current.json`),
      fetchJson(`${base}/next.json`),
    ]);

    const raw = { current: currentData, next: nextData };
    assertValidRawDataset(raw); // wirft bei Müll → kein Überschreiben

    const payload = JSON.stringify({
      version: latest,
      fetchedAt: nowIso,
      source: 'cdn',
      current: currentData,
      next: nextData,
    });

    fs.mkdirSync(BANKDATA_DIR, { recursive: true });
    const tmp = `${DATASET_FILE}.tmp`;
    fs.writeFileSync(tmp, payload, 'utf8');
    fs.renameSync(tmp, DATASET_FILE); // atomar
    cache = null; // Cache invalidieren → nächster Lookup lädt neu

    await setSetting('blzLastUpdatedAt', nowIso);
    await setSetting('blzLastError', '');

    console.log(`[BLZ] Datensatz aktualisiert auf ${latest} (${Object.keys(currentData).length} Einträge).`);
    return { changed: true, version: latest, message: `Aktualisiert auf ${latest}.` };
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    await setSetting('blzLastError', msg).catch(() => {});
    await setSetting('blzLastCheckedAt', nowIso).catch(() => {});
    console.error('[BLZ] Update fehlgeschlagen:', msg);
    throw err instanceof Error ? err : new Error(msg);
  } finally {
    updating = false;
  }
}

// ---- Status für die Einstellungen-Seite ----

export interface BlzStatus {
  activeSource: 'volume' | 'builtin';
  activeVersion: string;
  builtinVersion: string;
  entryCount: number;
  nextValidFrom: string | null;
  fetchedAt: string | null;
  autoUpdateEnabled: boolean;
  intervalDays: number;
  lastCheckedAt: string | null;
  lastUpdatedAt: string | null;
  latestVersion: string | null;
  updateAvailable: boolean;
  lastError: string | null;
}

export async function getStatus(): Promise<BlzStatus> {
  const ds = loadDataset();
  const combined = combinedData();

  const autoUpdateEnabled = await getSettingBool('blzAutoUpdateEnabled');
  const intervalDays = parseInt((await getSetting('blzUpdateIntervalDays')) || '30', 10) || 30;
  const lastCheckedAt = (await getSetting('blzLastCheckedAt')) || null;
  const lastUpdatedAt = (await getSetting('blzLastUpdatedAt')) || null;
  const latestVersion = (await getSetting('blzLatestVersion')) || null;
  const lastErrorRaw = (await getSetting('blzLastError')) || '';

  const updateAvailable =
    !!latestVersion && ds.version !== 'unbekannt' && compareVersions(latestVersion, ds.version) > 0;

  return {
    activeSource: ds.source,
    activeVersion: ds.version,
    builtinVersion: builtinVersion(),
    entryCount: Object.keys(combined).length,
    nextValidFrom: ds.next?.valid || null,
    fetchedAt: ds.fetchedAt,
    autoUpdateEnabled,
    intervalDays,
    lastCheckedAt,
    lastUpdatedAt,
    latestVersion,
    updateAvailable,
    lastError: lastErrorRaw || null,
  };
}
