const crypto = require("crypto");

const DEFAULT_CAPABILITIES = Object.freeze(["transcription.capture.v1", "transcription.settings.v1", "obs.caption.native.v1"]);

class DeviceStore {
  constructor(db, options = {}) {
    this.db = db;
    this.now = options.now || Date.now;
    this.randomBytes = options.randomBytes || crypto.randomBytes;
    this.migrate();
    this.cleanup();
  }

  migrate() {
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS transcription_pairing_tokens (
        id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, lumi_user_id TEXT NOT NULL,
        host TEXT NOT NULL, expires_at INTEGER NOT NULL, activated_at INTEGER, created_at INTEGER NOT NULL
      );
      CREATE TABLE IF NOT EXISTS transcription_devices (
        id TEXT PRIMARY KEY, install_id TEXT, name TEXT NOT NULL, lumi_user_id TEXT NOT NULL,
        credential_hash TEXT NOT NULL, capabilities_json TEXT NOT NULL, metadata_json TEXT NOT NULL,
        first_connected_at INTEGER NOT NULL, last_connected_at INTEGER NOT NULL, revoked_at INTEGER,
        pairing_host TEXT
      );
      CREATE INDEX IF NOT EXISTS transcription_devices_user_idx ON transcription_devices(lumi_user_id);
    `);
    ensureColumn(this.db, "transcription_devices", "pairing_host", "TEXT");
  }

  issuePairing({ userId, host, ttlMs = 15 * 60 * 1000 }) {
    if (!userId || !host) throw new Error("A Lumi user and host are required.");
    const id = crypto.randomUUID();
    const token = tokenValue(this.randomBytes(32));
    const now = this.now();
    this.db.prepare("INSERT INTO transcription_pairing_tokens (id, token_hash, lumi_user_id, host, expires_at, activated_at, created_at) VALUES (?, ?, ?, ?, ?, NULL, ?)")
      .run(id, digest(token), String(userId), normalizeHost(host), now + ttlMs, now);
    return { pairing_id: id, token, host: normalizeHost(host), expires_at: now + ttlMs, protocol_version: 1 };
  }

  exchange({ token, device = {} }) {
    const hash = digest(token);
    const row = this.db.prepare("SELECT * FROM transcription_pairing_tokens WHERE token_hash = ?").get(hash);
    if (!row || row.activated_at || row.expires_at < this.now()) {
      const error = new Error(row?.activated_at ? "This pairing package was already activated. Download a new companion package." : "This pairing package is invalid or expired. Download a new companion package.");
      error.code = row?.activated_at ? "PAIRING_ALREADY_USED" : "PAIRING_INVALID";
      throw error;
    }
    const deviceId = crypto.randomUUID();
    const secret = tokenValue(this.randomBytes(32));
    const now = this.now();
    const capabilities = DEFAULT_CAPABILITIES.slice();
    const transaction = this.db.transaction(() => {
      const consumed = this.db.prepare("UPDATE transcription_pairing_tokens SET activated_at = ? WHERE id = ? AND activated_at IS NULL").run(now, row.id);
      if (consumed.changes !== 1) { const error = new Error("This pairing package was already activated. Download a new companion package."); error.code = "PAIRING_ALREADY_USED"; throw error; }
      this.db.prepare("INSERT INTO transcription_devices (id, install_id, name, lumi_user_id, credential_hash, capabilities_json, metadata_json, first_connected_at, last_connected_at, revoked_at, pairing_host) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)")
        .run(deviceId, clean(device.install_id, 128) || null, clean(device.name, 160) || "Lumi Companion", row.lumi_user_id, digest(secret), JSON.stringify(capabilities), JSON.stringify(safeMetadata(device)), now, now, row.host);
    });
    transaction();
    return { device_id: deviceId, device_secret: secret, host: row.host, capabilities, protocol_version: 1 };
  }

  authenticate(header, requiredCapability = null) {
    const match = /^LumiDevice\s+([0-9a-f-]{36})\.([A-Za-z0-9_-]{40,})$/i.exec(String(header || ""));
    if (!match) return { allowed: false, reason: "missing_credentials" };
    const row = this.db.prepare("SELECT * FROM transcription_devices WHERE id = ?").get(match[1]);
    if (!row || row.revoked_at || !safeEqual(row.credential_hash, digest(match[2]))) return { allowed: false, reason: row?.revoked_at ? "device_revoked" : "invalid_credentials" };
    const capabilities = parseArray(row.capabilities_json);
    if (requiredCapability && !capabilities.includes(requiredCapability)) return { allowed: false, reason: "capability_revoked" };
    this.db.prepare("UPDATE transcription_devices SET last_connected_at = ? WHERE id = ?").run(this.now(), row.id);
    return { allowed: true, device: serialize(row, capabilities) };
  }

  list(options = {}) {
    const status = options.status === "revoked" ? "revoked" : options.status === "all" ? "all" : "active";
    const where = status === "revoked" ? "WHERE revoked_at IS NOT NULL" : status === "active" ? "WHERE revoked_at IS NULL" : "";
    return this.db.prepare(`SELECT * FROM transcription_devices ${where} ORDER BY last_connected_at DESC`).all().map((row) => serialize(row, parseArray(row.capabilities_json)));
  }
  revoke(deviceId) { return this.db.prepare("UPDATE transcription_devices SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(this.now(), deviceId).changes === 1; }
  cleanup(now = this.now()) { return this.db.prepare("DELETE FROM transcription_devices WHERE revoked_at IS NOT NULL AND revoked_at <= ?").run(now - 30 * 86400000).changes; }
  setCapabilities(deviceId, capabilities) {
    const allowed = DEFAULT_CAPABILITIES.filter((capability) => new Set(capabilities || []).has(capability));
    const changed = this.db.prepare("UPDATE transcription_devices SET capabilities_json = ? WHERE id = ? AND revoked_at IS NULL").run(JSON.stringify(allowed), deviceId).changes;
    return changed ? allowed : null;
  }
  updateRuntime(deviceId, input = {}) {
    const row = this.db.prepare("SELECT metadata_json FROM transcription_devices WHERE id = ? AND revoked_at IS NULL").get(deviceId);
    if (!row) return false;
    let metadata = {};
    try { metadata = JSON.parse(row.metadata_json || "{}"); } catch { }
    if (typeof input.bridge_installed === "boolean") metadata.bridge_installed = input.bridge_installed;
    if (typeof input.bridge_connected === "boolean") metadata.bridge_connected = input.bridge_connected;
    if (input.bridge_version !== undefined) metadata.bridge_version = clean(input.bridge_version, 32) || null;
    if (typeof input.path_test_valid === "boolean") metadata.path_test_valid = input.path_test_valid;
    if (Number.isFinite(Number(input.path_test_at))) metadata.path_test_at = Math.max(0, Number(input.path_test_at));
    if (input.companion_version !== undefined) metadata.companion_version = clean(input.companion_version, 32);
    if (input.companion_plugin_version !== undefined) metadata.companion_plugin_version = clean(input.companion_plugin_version, 32);
    metadata.runtime_seen_at = this.now();
    return this.db.prepare("UPDATE transcription_devices SET metadata_json = ?, last_connected_at = ? WHERE id = ? AND revoked_at IS NULL")
      .run(JSON.stringify(metadata), this.now(), deviceId).changes === 1;
  }
  pairingAllowsHttp(token, requestOrigin) {
    const row = this.db.prepare("SELECT host, activated_at, expires_at FROM transcription_pairing_tokens WHERE token_hash = ?").get(digest(token));
    return Boolean(row && !row.activated_at && row.expires_at >= this.now() && sameLoopbackOrigin(row.host, requestOrigin));
  }
}

function digest(value) { return crypto.createHash("sha256").update(String(value || ""), "utf8").digest("hex"); }
function safeEqual(left, right) { const a = Buffer.from(String(left)); const b = Buffer.from(String(right)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
function tokenValue(buffer) { return Buffer.from(buffer).toString("base64url"); }
function normalizeHost(value) { const url = new URL(String(value)); if (url.protocol !== "https:" && !isLoopbackHttpOrigin(url)) throw new Error("Lumi Companion requires HTTPS. HTTP is allowed only when the pairing URL explicitly uses localhost or a loopback address."); return url.origin; }
function isLoopbackHttpOrigin(value) { try { const url = value instanceof URL ? value : new URL(String(value)); return url.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname); } catch { return false; } }
function sameLoopbackOrigin(left, right) { try { const a = new URL(String(left)); const b = new URL(String(right)); return isLoopbackHttpOrigin(a) && isLoopbackHttpOrigin(b) && a.origin === b.origin; } catch { return false; } }
function insecureDeviceAllowed(device, requestOrigin, remoteAddress) { return ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(String(remoteAddress || "")) && sameLoopbackOrigin(device?.pairing_host, requestOrigin); }
function ensureColumn(db, table, column, type) { if (!db.prepare(`PRAGMA table_info(${table})`).all().some((entry) => entry.name === column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); }
function clean(value, max) { return String(value || "").trim().slice(0, max); }
function parseArray(value) { try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch { return []; } }
function safeMetadata(device) { return { companion_version: clean(device.companion_version, 32), obs_version: clean(device.obs_version, 32), os: clean(device.os, 120), architecture: clean(device.architecture, 32), hardware: clean(device.hardware, 500) }; }
function serialize(row, capabilities) { return { id: row.id, install_id: row.install_id, name: row.name, lumi_user_id: row.lumi_user_id, capabilities, metadata: JSON.parse(row.metadata_json || "{}"), first_connected_at: row.first_connected_at, last_connected_at: row.last_connected_at, revoked_at: row.revoked_at, pairing_host: row.pairing_host || null }; }

module.exports = { DeviceStore, DEFAULT_CAPABILITIES, digest, normalizeHost, isLoopbackHttpOrigin, sameLoopbackOrigin, insecureDeviceAllowed };
