// Pure-Funktions-API für CLI- und RPC-Wrapper.
// Eine Session hält die entschlüsselte cfg + Passwort im Speicher und sperrt
// nach AUTO_LOCK_MS Inaktivität automatisch (für GUI-Sessions).
import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { spawn } from 'node:child_process';
import { browserDataDir } from './paths.mjs';
import {
  configExists, loadConfig, saveConfig, newConfig,
  addAccount as cfgAddAccount, removeAccount as cfgRemoveAccount,
  renameAccount as cfgRenameAccount, setAccountOption as cfgSetAccountOption,
  findAccount, getLayout, getMailConfig, exportConfigFile, importConfigFile,
  SUPPORTED_SOURCES, SOURCE_DEFAULT_FOLDER,
} from './config.mjs';
import { runAll as runnerRunAll, runAccount as runnerRunAccount, loginOnly as runnerLoginOnly } from './runner.mjs';
import { sendTaxMail, sendMonthMail, assessMonth, validateZipPassword, testSmtpConnection } from './mail.mjs';

const AUTO_LOCK_MS = 30 * 60 * 1000;

function withoutCredentials(account) {
  const { credentials, ...rest } = account;
  return { ...rest, hasCredentials: !!credentials && Object.keys(credentials).length > 0 };
}

export class Session {
  constructor() {
    this.cfg = null;
    this.password = null;
    this.timer = null;
    // AbortController pro laufendem Account-Run. Erlaubt parallele Runs (verschiedene
    // Accounts gleichzeitig) und gezieltes oder globales Abbrechen.
    this.runControllers = new Map();
  }

  isUnlocked() { return this.cfg !== null; }

  unlock(password) {
    if (!configExists()) throw new Error('Keine Config gefunden — zuerst init.');
    this.cfg = loadConfig(password);
    this.password = password;
    this._resetTimer();
  }

  lock() {
    this.cfg = null;
    this.password = null;
    if (this.timer) clearTimeout(this.timer);
    this.timer = null;
  }

  _resetTimer() {
    if (this.timer) clearTimeout(this.timer);
    this.timer = setTimeout(() => this.lock(), AUTO_LOCK_MS);
  }

  _ensureUnlocked() {
    if (!this.isUnlocked()) throw new Error('Session ist gesperrt — zuerst unlock().');
    this._resetTimer();
  }

  _persist() {
    saveConfig(this.cfg, this.password);
  }

  // === Account-Operationen ===

  listAccounts() {
    this._ensureUnlocked();
    return this.cfg.accounts.map(withoutCredentials);
  }

  getAccount(id) {
    this._ensureUnlocked();
    const acc = findAccount(this.cfg, id);
    if (!acc) throw new Error(`Account "${id}" nicht gefunden.`);
    return withoutCredentials(acc);
  }

  addAccount(account) {
    this._ensureUnlocked();
    cfgAddAccount(this.cfg, account);
    this._persist();
  }

  removeAccount(id) {
    this._ensureUnlocked();
    cfgRemoveAccount(this.cfg, id);
    this._persist();
  }

  renameAccount(oldId, newId) {
    this._ensureUnlocked();
    cfgRenameAccount(this.cfg, oldId, newId);
    this._persist();
  }

  setAccountOption(id, field, rawValue) {
    this._ensureUnlocked();
    const v = cfgSetAccountOption(this.cfg, id, field, rawValue);
    this._persist();
    return v;
  }

  setCredentials(id, credentials) {
    this._ensureUnlocked();
    const acc = findAccount(this.cfg, id);
    if (!acc) throw new Error(`Account "${id}" nicht gefunden.`);
    acc.credentials = credentials;
    this._persist();
  }

  // === Subfolder-Discovery (für GUI taxKontos-UI) ===

  getSubfolders(accountId) {
    this._ensureUnlocked();
    const acc = findAccount(this.cfg, accountId);
    if (!acc) throw new Error(`Account "${accountId}" nicht gefunden.`);
    // Fallback auf SOURCE_DEFAULT_FOLDER analog mail.mjs/runner.mjs — sonst TypeError
    // bei Accounts ohne expliziten folder-Wert.
    const folder = acc.folder || SOURCE_DEFAULT_FOLDER[acc.source] || acc.source;
    const root = join(this.cfg.rootDir, folder);
    if (!existsSync(root)) return [];
    return readdirSync(root, { withFileTypes: true })
      .filter((e) => e.isDirectory()).map((e) => e.name).sort();
  }

  // === Globale Config ===

  getRootDir() { this._ensureUnlocked(); return this.cfg.rootDir; }

  setRootDir(rootDir) {
    this._ensureUnlocked();
    this.cfg.rootDir = rootDir;
    this._persist();
  }

  getSmtp() {
    this._ensureUnlocked();
    const s = this.cfg.smtp;
    if (!s) return null;
    // Passwort nicht ausgeben
    const { password, ...rest } = s;
    return { ...rest, hasPassword: !!password };
  }

  // Pattern-konsistent zu setMailSettings:
  //   smtp.password === undefined  -> aktuelles behalten
  //   smtp.password === null / ''  -> Passwort entfernen
  //   smtp.password === <string>   -> neu setzen
  // Wichtig fuer GUI: Passwort wird nicht ans Frontend zurueckgegeben (hasPassword-Flag).
  setSmtp(smtp) {
    this._ensureUnlocked();
    const current = this.cfg.smtp || {};
    const next = { ...current, ...smtp };
    if (smtp.password === undefined) {
      next.password = current.password || '';
    } else if (smtp.password === null || smtp.password === '') {
      delete next.password;
    }
    this.cfg.smtp = next;
    this._persist();
  }

  // Verifiziert SMTP-Verbindung + Auth. Wird aus der GUI vor dem Speichern
  // aufgerufen, damit der User Tippfehler in Host/Port/User/Passwort sofort sieht
  // statt erst nach dem ersten Mail-Versand. Wenn smtpInput.password leer ist,
  // wird das gespeicherte Passwort verwendet — analog zu setSmtp.
  async testSmtp(smtpInput) {
    this._ensureUnlocked();
    const current = this.cfg.smtp || {};
    const merged = { ...current, ...(smtpInput || {}) };
    if (!smtpInput?.password) merged.password = current.password;
    return await testSmtpConnection(merged);
  }

  changeMasterPassword(newPassword) {
    this._ensureUnlocked();
    if (!newPassword || newPassword.length < 1) throw new Error('Passwort darf nicht leer sein.');
    // Revert-on-failure: wirft _persist (z.B. Disk-Voll), bleibt sonst Session mit
    // neuem PW aktiv, Disk-Datei aber mit altem PW verschluesselt → Auto-Lock + Re-Unlock
    // mit "neuem" PW schlaegt fehl.
    const old = this.password;
    this.password = newPassword;
    try {
      this._persist();
    } catch (e) {
      this.password = old;
      throw e;
    }
  }

  // === Layout ===

  getLayout() {
    this._ensureUnlocked();
    return getLayout(this.cfg);
  }

  setLayout(layout) {
    this._ensureUnlocked();
    this.cfg.layout = { ...(this.cfg.layout || {}), ...layout };
    this._persist();
  }

  // === Mail-Settings (zipLayout, maxSizeMB, zipPassword) ===

  getMailSettings() {
    this._ensureUnlocked();
    return {
      zipLayout: this.cfg.mail?.zipLayout || 'flat-date-prefix',
      maxSizeMB: this.cfg.mail?.maxSizeMB ?? null,
      // Passwort nicht ans Frontend zurueckgeben (gleicher Pattern wie SMTP).
      hasZipPassword: !!this.cfg.mail?.zipPassword,
    };
  }

  // Wenn zipPassword undefined/leer, bleibt das aktuelle erhalten — analog SMTP.
  // Wenn explizit null gesetzt, wird die Verschluesselung deaktiviert (Frontend-Reset-Knopf).
  setMailSettings(mail) {
    this._ensureUnlocked();
    const current = this.cfg.mail || {};
    // zipPassword separat behandeln, damit { ...mail }-Spread es nicht versehentlich auf
    // undefined setzt (Spread filtert undefined-Values nicht).
    const { zipPassword: incoming, ...mailRest } = mail;
    // Strength-Validation beim Setzen statt erst beim ZIP-Bauen — sonst merkt der User
    // erst nach dem naechsten Mail-Versand dass das Passwort abgelehnt wird.
    if (incoming && incoming !== null) validateZipPassword(incoming);
    // maxSizeMB-Coercion: GUI liefert <input type="number"> als string, Backend
    // erwartet number fuer Vergleichsoperationen. NaN/Negativ ablehnen (haette die
    // pre-Send-Groessenpruefung sinnlos gemacht).
    if (mailRest.maxSizeMB !== undefined && mailRest.maxSizeMB !== null && mailRest.maxSizeMB !== '') {
      const n = Number(mailRest.maxSizeMB);
      if (!Number.isFinite(n) || n <= 0) {
        throw new Error('maxSizeMB muss eine positive Zahl sein.');
      }
      mailRest.maxSizeMB = n;
    } else if (mailRest.maxSizeMB === '' || mailRest.maxSizeMB === null) {
      // Leerer String / null = "kein Limit" → entfernen statt 0 zu speichern.
      delete mailRest.maxSizeMB;
    }
    const next = { ...current, ...mailRest };
    if (incoming === undefined) {
      if (current.zipPassword !== undefined) next.zipPassword = current.zipPassword;
    } else if (incoming === null || incoming === '') {
      delete next.zipPassword;
    } else {
      next.zipPassword = incoming;
    }
    this.cfg.mail = next;
    this._persist();
  }

  // === Monats-Steuermail (filesystem-basiert, nicht run-result-basiert) ===

  assessMonth(year, month) {
    this._ensureUnlocked();
    return assessMonth(this.cfg, year, month);
  }

  async sendMonthMail(year, month, note) {
    this._ensureUnlocked();
    return await sendMonthMail(this.cfg, year, month, note);
  }

  // === Config Export/Import ===

  exportConfig(targetPath) {
    this._ensureUnlocked();
    return exportConfigFile(targetPath);
  }

  // Oeffnet den Konto-Ordner im OS-Datei-Manager. Legt das Verzeichnis bei Bedarf an
  // (frisches Konto, das noch nie gelaufen ist) — Explorer kann sonst keinen leeren
  // Pfad anzeigen. Detached + unref, damit der Sub-Prozess nicht am Sidecar haengt.
  openAccountFolder(id) {
    this._ensureUnlocked();
    const acc = findAccount(this.cfg, id);
    if (!acc) throw new Error(`Account "${id}" nicht gefunden.`);
    const folder = acc.folder || SOURCE_DEFAULT_FOLDER[acc.source] || acc.source;
    const dir = join(this.cfg.rootDir, folder);
    mkdirSync(dir, { recursive: true });
    // Plattform-spezifischer Aufruf. Windows-Explorer gibt regelmaessig Exit 1 zurueck
    // obwohl er erfolgreich oeffnet — deshalb keine Result-Promise, nur fire-and-forget.
    const cmd = process.platform === 'win32' ? 'explorer'
      : process.platform === 'darwin' ? 'open'
      : 'xdg-open';
    const child = spawn(cmd, [dir], { detached: true, stdio: 'ignore' });
    child.on('error', () => {}); // ENOENT-Crashes nicht nach oben werfen
    child.unref();
    return dir;
  }

  // Loescht das Persistent-Browser-Profile fuer ein Konto (browser-data/<source>/<id>).
  // Sinnvoll nach abgebrochenen Runs, anti-bot-Lockouts oder kaputtem Cookie-State.
  // Nicht-fatal wenn das Verzeichnis schon weg oder Files locked sind.
  resetBrowserSession(id) {
    this._ensureUnlocked();
    const acc = findAccount(this.cfg, id);
    if (!acc) throw new Error(`Account "${id}" nicht gefunden.`);
    if (this.runControllers.has(id)) {
      throw new Error(`Konto "${id}" laeuft — bitte erst abbrechen.`);
    }
    const dir = browserDataDir(acc.source, acc.id);
    rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
    return dir;
  }

  // Import ueberschreibt die aktuelle config.enc und sperrt die Session — der User muss
  // mit dem Master-PW der importierten Datei neu entsperren.
  // Sicherheits-Verriegelung:
  //   1) Session muss entsperrt sein (sonst koennte jemand vor der gesperrten GUI eine
  //      fremde Config einschleusen → Account-Uebernahme beim naechsten Unlock).
  //   2) Keine laufenden Runs (sonst schreibt _persist die alte cfg ueber die importierte).
  importConfig(sourcePath) {
    this._ensureUnlocked();
    if (this.runControllers.size > 0) {
      throw new Error('Erst alle laufenden Runs abbrechen, dann importieren.');
    }
    const dest = importConfigFile(sourcePath);
    this.lock();
    return dest;
  }

  // === Event-Subscription für Run-Live-Updates ===

  onEvent(fn) { this._eventListener = fn; }
  _emit(type, payload) { if (this._eventListener) try { this._eventListener(type, payload); } catch { /* ignore */ } }

  // === Run-Operationen (delegieren an runner.mjs / mail.mjs) ===

  async runAll(filter) {
    this._ensureUnlocked();
    // Controller pro Account erst beim *Start* anlegen, nicht eager — sonst zeigt
    // listRunning() waehrend sequenziellen Runs alle als "running" und abortAll kann
    // bereits-fertige aborten. Runner ruft acquireSignal vor jedem Account-Start,
    // releaseSignal danach (auch im Error-Fall).
    const acquired = new Set();
    try {
      return await runnerRunAll(this.cfg, filter, (t, p) => this._emit(t, p), {
        acquireSignal: (id) => {
          const ctl = new AbortController();
          this.runControllers.set(id, ctl);
          acquired.add(id);
          return ctl.signal;
        },
        releaseSignal: (id) => {
          this.runControllers.delete(id);
          acquired.delete(id);
        },
      });
    } finally {
      // Safety-Net: falls runner unerwartet wirft bevor releaseSignal feuert,
      // raeumen wir nur unsere eigenen IDs auf (parallel laufende runAccount nicht antasten).
      for (const id of acquired) this.runControllers.delete(id);
    }
  }

  async runAccount(id) {
    this._ensureUnlocked();
    const acc = findAccount(this.cfg, id);
    if (!acc) throw new Error(`Account "${id}" nicht gefunden.`);
    if (this.runControllers.has(id)) throw new Error(`Konto "${id}" läuft bereits.`);
    const ctl = new AbortController();
    this.runControllers.set(id, ctl);
    try {
      return await runnerRunAccount(this.cfg, acc, (t, p) => this._emit(t, p), { signal: ctl.signal });
    } finally {
      this.runControllers.delete(id);
    }
  }

  abortRun(id) {
    const ctl = this.runControllers.get(id);
    if (!ctl) return false;
    ctl.abort();
    return true;
  }

  abortAll() {
    const ids = [...this.runControllers.keys()];
    for (const ctl of this.runControllers.values()) ctl.abort();
    return ids;
  }

  listRunning() {
    return [...this.runControllers.keys()];
  }

  async loginOnly(id) {
    this._ensureUnlocked();
    return await runnerLoginOnly(this.cfg, id, (t, p) => this._emit(t, p));
  }

  async sendTaxMail(accountResults, note) {
    this._ensureUnlocked();
    return await sendTaxMail(this.cfg, accountResults, note);
  }
}

// Convenience-Factory
export function createSession() { return new Session(); }

// Re-exports der statischen Helfer (für CLI/RPC ohne Session)
export {
  configExists, newConfig, saveConfig, loadConfig,
  SUPPORTED_SOURCES, SOURCE_DEFAULT_FOLDER,
};
