import crypto from 'node:crypto';
import fs from 'node:fs';

import { BrowserWindow, safeStorage } from 'electron';

import { ipcChannels } from '../../shared/ipc/contracts';
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import type { LicensePayloadV1 } from '../../shared/license/payloadV1';
import { isProductKey } from '../../shared/license/productKey';
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';

import { clearLegacyDeviceId, getOrCreateDeviceId, readLegacyDeviceId } from './deviceId';
import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths';
import { verifyLicenseToken } from './verifyLicenseToken';

type Preferences = {
  eulaAcceptedVersion?: number;
};

type LicenseChangeListener = () => void;

const FALLBACK_MAGIC = Buffer.from('DNDLF1', 'ascii');

const licenseChangeListeners = new Set<LicenseChangeListener>();

/** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */
export function addLicenseChangeListener(fn: LicenseChangeListener): () => void {
  licenseChangeListeners.add(fn);
  return () => {
    licenseChangeListeners.delete(fn);
  };
}

function notifyLicenseChangeListeners(): void {
  for (const fn of licenseChangeListeners) {
    try {
      fn();
    } catch (err) {
      console.error('[license] change listener failed', err);
    }
  }
}

function readPreferences(userData: string): Preferences {
  try {
    const raw = fs.readFileSync(preferencesPath(userData), 'utf8');
    return JSON.parse(raw) as Preferences;
  } catch {
    return {};
  }
}

function writePreferences(userData: string, prefs: Preferences): void {
  fs.mkdirSync(userData, { recursive: true });
  fs.writeFileSync(preferencesPath(userData), `${JSON.stringify(prefs, null, 2)}\n`, 'utf8');
}

function emitLicenseStatusChanged(): void {
  for (const win of BrowserWindow.getAllWindows()) {
    win.webContents.send(ipcChannels.license.statusChanged, {});
  }
  notifyLicenseChangeListeners();
}

export class LicenseService {
  private readonly userData: string;
  private readonly deviceId: string;
  /** UUID из старого userData/device.id — только для совместимости до повторной активации. */
  private legacyDeviceId: string | null;
  private lastRemoteRevokeCheckMs = 0;
  private lastRemoteRevoked = false;

  constructor(userData: string) {
    this.userData = userData;
    this.deviceId = getOrCreateDeviceId(userData);
    const legacy = readLegacyDeviceId(userData);
    this.legacyDeviceId = legacy && legacy !== this.deviceId ? legacy : null;
  }

  private verifyOpts(nowSec: number): {
    nowSec: number;
    deviceId: string;
    alsoAcceptDeviceIds?: readonly string[];
  } {
    return {
      nowSec,
      deviceId: this.deviceId,
      ...(this.legacyDeviceId ? { alsoAcceptDeviceIds: [this.legacyDeviceId] } : {}),
    };
  }

  private isSkipLicense(): boolean {
    return process.env.DND_SKIP_LICENSE === '1' || process.env.DND_SKIP_LICENSE === 'true';
  }

  /** Только для окружений без OS keychain (WSL и т.п.); слабее safeStorage — см. licensing-spec. */
  private isInsecureFileStorageAllowed(): boolean {
    const v = process.env.DND_LICENSE_INSECURE_FILE_STORAGE?.trim().toLowerCase();
    return v === '1' || v === 'true' || v === 'yes';
  }

  private deriveFallbackKey(): Buffer {
    return crypto
      .createHash('sha256')
      .update('TTRPGPlayer.license.fallback.v1\0', 'utf8')
      .update(this.deviceId, 'utf8')
      .digest();
  }

  private readFallbackSealedToken(): string {
    const p = licenseFallbackSealedPath(this.userData);
    const buf = fs.readFileSync(p);
    if (buf.length < FALLBACK_MAGIC.length + 12 + 16 + 1) {
      throw new Error('license.fallback: файл повреждён или слишком короткий');
    }
    if (!buf.subarray(0, FALLBACK_MAGIC.length).equals(FALLBACK_MAGIC)) {
      throw new Error('license.fallback: неверный формат');
    }
    const iv = buf.subarray(FALLBACK_MAGIC.length, FALLBACK_MAGIC.length + 12);
    const tag = buf.subarray(FALLBACK_MAGIC.length + 12, FALLBACK_MAGIC.length + 12 + 16);
    const data = buf.subarray(FALLBACK_MAGIC.length + 12 + 16);
    const key = this.deriveFallbackKey();
    const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
    decipher.setAuthTag(tag);
    return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
  }

  private writeFallbackSealedToken(token: string): void {
    const key = this.deriveFallbackKey();
    const iv = crypto.randomBytes(12);
    const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
    const enc = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
    const tag = cipher.getAuthTag();
    const payload = Buffer.concat([FALLBACK_MAGIC, iv, tag, enc]);
    fs.writeFileSync(licenseFallbackSealedPath(this.userData), payload, { mode: 0o600 });
  }

  private readSealedToken(): string | null {
    const sealedPath = licenseEncryptedPath(this.userData);
    const fallbackPath = licenseFallbackSealedPath(this.userData);

    if (fs.existsSync(sealedPath)) {
      if (!safeStorage.isEncryptionAvailable()) {
        throw new Error(
          'safeStorage недоступен: есть license.sealed, но расшифровать нельзя (часто перенос профиля или WSL без keyring). Удалите файл лицензии в настройках приложения или используйте DND_LICENSE_INSECURE_FILE_STORAGE=1 и активируйте заново.',
        );
      }
      const buf = fs.readFileSync(sealedPath);
      return safeStorage.decryptString(buf);
    }

    if (fs.existsSync(fallbackPath)) {
      return this.readFallbackSealedToken();
    }

    return null;
  }

  private writeSealedToken(token: string): void {
    fs.mkdirSync(this.userData, { recursive: true });
    if (safeStorage.isEncryptionAvailable()) {
      const enc = safeStorage.encryptString(token);
      fs.writeFileSync(licenseEncryptedPath(this.userData), enc);
      try {
        fs.unlinkSync(licenseFallbackSealedPath(this.userData));
      } catch {
        /* ok */
      }
      return;
    }
    if (this.isInsecureFileStorageAllowed()) {
      this.writeFallbackSealedToken(token);
      try {
        fs.unlinkSync(licenseEncryptedPath(this.userData));
      } catch {
        /* ok */
      }
      return;
    }
    throw new Error(
      'safeStorage недоступен: нельзя сохранить лицензию на этой системе (типично WSL без gnome-keyring). Запустите с переменной DND_LICENSE_INSECURE_FILE_STORAGE=1 — токен будет сохранён в зашифрованном файле (слабее OS-хранилища); либо настройте Secret Service / gnome-keyring.',
    );
  }

  private clearSealedTokenFile(): void {
    try {
      fs.unlinkSync(licenseEncryptedPath(this.userData));
    } catch {
      /* ok */
    }
    try {
      fs.unlinkSync(licenseFallbackSealedPath(this.userData));
    } catch {
      /* ok */
    }
  }

  /** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */
  private resolveLicenseActivateBaseUrl(): string {
    const raw = process.env.DND_LICENSE_STATUS_URL?.trim();
    if (raw) return raw.endsWith('/') ? raw : `${raw}/`;
    return 'https://license.ttrpgplayer.ru/';
  }

  private async activateWithProductKey(productKey: string): Promise<string> {
    const base = this.resolveLicenseActivateBaseUrl();
    const url = new URL('v1/activate', base);
    const body: { productKey: string; deviceId: string; retireDeviceId?: string } = {
      productKey: productKey.trim(),
      deviceId: this.deviceId,
    };
    if (this.legacyDeviceId) {
      body.retireDeviceId = this.legacyDeviceId;
    }
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(20_000),
    });
    const text = await res.text();
    let parsed: unknown;
    try {
      parsed = JSON.parse(text) as unknown;
    } catch {
      throw new Error(`LICENSE_ACTIVATE_FAILED:${text.slice(0, 200)}`);
    }
    const obj = parsed as { token?: string; error?: string; message?: string };
    if (!res.ok) {
      throw new Error(`LICENSE_ACTIVATE_FAILED:${obj.error ?? obj.message ?? String(res.status)}`);
    }
    const token = obj.token;
    if (!token || typeof token !== 'string') {
      throw new Error('LICENSE_ACTIVATE_FAILED:token_missing');
    }
    if (this.legacyDeviceId) {
      clearLegacyDeviceId(this.userData);
      this.legacyDeviceId = null;
    }
    return normalizeLicenseTokenInput(token);
  }

  /**
   * Онлайн-проверка отзыва. Не вызывать через `await` из UI-пути: без VPN/DNS до сервера
   * лицензий TCP может висеть до таймаута (см. fetch), из‑за чего главное окно долго «чёрное».
   */
  private async maybeRefreshRemoteRevocation(payload: LicensePayloadV1): Promise<void> {
    const base = process.env.DND_LICENSE_STATUS_URL?.trim();
    if (!base) return;
    const now = Date.now();
    if (now - this.lastRemoteRevokeCheckMs < 60_000) return;
    this.lastRemoteRevokeCheckMs = now;
    const wasRevoked = this.lastRemoteRevoked;
    try {
      const u = new URL('v1/status', base.endsWith('/') ? base : `${base}/`);
      u.searchParams.set('sub', payload.sub);
      const res = await fetch(u, { method: 'GET', signal: AbortSignal.timeout(8000) });
      if (!res.ok) return;
      const j = (await res.json()) as { revoked?: boolean };
      this.lastRemoteRevoked = Boolean(j.revoked);
    } catch {
      /* offline: не блокируем */
    }
    if (wasRevoked !== this.lastRemoteRevoked) {
      emitLicenseStatusChanged();
    }
  }

  getStatusSync(): LicenseSnapshot {
    if (this.isSkipLicense()) {
      return {
        active: true,
        devSkip: true,
        reason: 'ok',
        summary: null,
        eulaAcceptedVersion: readPreferences(this.userData).eulaAcceptedVersion ?? null,
        deviceId: this.deviceId,
      };
    }

    const eulaAcceptedVersion = readPreferences(this.userData).eulaAcceptedVersion ?? null;
    const nowSec = Math.floor(Date.now() / 1000);
    let token: string | null = null;
    try {
      token = this.readSealedToken();
    } catch {
      return {
        active: false,
        devSkip: false,
        reason: 'none',
        summary: null,
        eulaAcceptedVersion,
        deviceId: this.deviceId,
      };
    }
    if (!token?.trim()) {
      return {
        active: false,
        devSkip: false,
        reason: 'none',
        summary: null,
        eulaAcceptedVersion,
        deviceId: this.deviceId,
      };
    }

    const v = verifyLicenseToken(token, this.verifyOpts(nowSec));
    if (!v.ok) {
      return {
        active: false,
        devSkip: false,
        reason: v.reason,
        summary: null,
        eulaAcceptedVersion,
        deviceId: this.deviceId,
      };
    }

    if (this.lastRemoteRevoked) {
      return {
        active: false,
        devSkip: false,
        reason: 'revoked_remote',
        summary: {
          sub: v.payload.sub,
          pid: v.payload.pid,
          exp: v.payload.exp,
          did: v.payload.did,
        },
        eulaAcceptedVersion,
        deviceId: this.deviceId,
      };
    }

    return {
      active: true,
      devSkip: false,
      reason: 'ok',
      summary: {
        sub: v.payload.sub,
        pid: v.payload.pid,
        exp: v.payload.exp,
        did: v.payload.did,
      },
      eulaAcceptedVersion,
      deviceId: this.deviceId,
    };
  }

  /** Снимок для UI/IPC: без ожидания сети (проверка отзыва уходит в фон). */
  getStatus(): LicenseSnapshot {
    if (this.isSkipLicense()) return this.getStatusSync();
    const base = this.getStatusSync();
    if (!base.active || !base.summary) return base;
    const token = this.readSealedToken();
    if (!token?.trim()) return base;
    const v = verifyLicenseToken(token, this.verifyOpts(Math.floor(Date.now() / 1000)));
    if (!v.ok) return this.getStatusSync();
    void this.maybeRefreshRemoteRevocation(v.payload);
    return this.getStatusSync();
  }

  async setToken(token: string): Promise<LicenseSnapshot> {
    if (this.isSkipLicense()) {
      return this.getStatusSync();
    }
    let trimmed = normalizeLicenseTokenInput(token);
    if (isProductKey(trimmed)) {
      trimmed = await this.activateWithProductKey(trimmed);
    }
    const nowSec = Math.floor(Date.now() / 1000);
    const v = verifyLicenseToken(trimmed, this.verifyOpts(nowSec));
    if (!v.ok) {
      throw new Error(`LICENSE_INVALID:${v.reason}`);
    }
    this.writeSealedToken(trimmed);
    this.lastRemoteRevoked = false;
    emitLicenseStatusChanged();
    return this.getStatusSync();
  }

  clearToken(): LicenseSnapshot {
    this.clearSealedTokenFile();
    this.lastRemoteRevoked = false;
    emitLicenseStatusChanged();
    return this.getStatusSync();
  }

  acceptEula(version: number): { ok: true } {
    if (version !== EULA_CURRENT_VERSION) {
      throw new Error('EULA_BAD_VERSION');
    }
    const prefs = readPreferences(this.userData);
    prefs.eulaAcceptedVersion = version;
    writePreferences(this.userData, prefs);
    emitLicenseStatusChanged();
    return { ok: true };
  }

  assertForIpc(): void {
    if (this.isSkipLicense()) return;
    const s = this.getStatusSync();
    if (!s.active) {
      throw new Error(`LICENSE_REQUIRED:${s.reason}`);
    }
  }
}
