const crypto = require("crypto");
const EventEmitter = require("events");
const { db } = require("./db");
const { publishWebEvent } = require("./web-events");
const { getOverlayModuleType, normalizeModuleConfig } = require("./overlay-modules");
const { duplicateOverlayEventHooks, eventOnlyModuleIds } = require("./overlay-event-hooks");
const {
  createStoredToken,
  decryptSecret,
  encryptSecret,
  scopedSignature,
  tokenHash,
  verifyScopedSignature
} = require("./overlay-secrets");

const overlayChanges = new EventEmitter();
overlayChanges.setMaxListeners(100);

function requiredName(value, label = "Name") {
  const normalized = String(value || "").trim().slice(0, 120);
  if (!normalized) throw new Error(`${label} is required.`);
  return normalized;
}

function canvasDimension(value, fallback) {
  const parsed = Math.round(Number(value));
  return Number.isFinite(parsed) ? Math.min(7680, Math.max(240, parsed)) : fallback;
}

function parseConfig(value) {
  try {
    const parsed = typeof value === "string" ? JSON.parse(value || "{}") : value;
    return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
  } catch {
    return {};
  }
}

function nextOrder(table, parentColumn = null, parentId = null) {
  const allowed = {
    overlays: null,
    overlay_scenes: "overlay_id",
    overlay_modules: "scene_id"
  };
  if (!(table in allowed) || allowed[table] !== parentColumn) throw new Error("Invalid ordering target.");
  const where = parentColumn ? ` WHERE ${parentColumn} = ?` : "";
  const row = db.prepare(`SELECT COALESCE(MAX(sort_order), -1) + 1 AS value FROM ${table}${where}`)
    .get(...(parentColumn ? [parentId] : []));
  return Number(row?.value || 0);
}

function touchOverlay(overlayId, at = Date.now()) {
  db.prepare("UPDATE overlays SET updated_at = ? WHERE id = ?").run(at, overlayId);
}

function overlayIdForScene(sceneId) {
  return db.prepare("SELECT overlay_id FROM overlay_scenes WHERE id = ?").get(sceneId)?.overlay_id || null;
}

function overlayIdForModule(moduleId) {
  return db.prepare(
    "SELECT s.overlay_id FROM overlay_modules m JOIN overlay_scenes s ON s.id = m.scene_id WHERE m.id = ?"
  ).get(moduleId)?.overlay_id || null;
}

function notifyOverlayChanged(overlayId, source = "server") {
  const overlay = db.prepare("SELECT updated_at FROM overlays WHERE id = ?").get(overlayId);
  if (!overlay) return;
  publishWebEvent("overlay:changed", { revision: overlay.updated_at }, { scope: `overlay:${overlayId}` });
  overlayChanges.emit("changed", { overlayId, source, revision: overlay.updated_at });
}

function rowWithToken(row, includeSecrets) {
  if (!row) return null;
  const result = { ...row, enabled: Boolean(row.enabled) };
  delete result.public_token_hash;
  delete result.public_token_encrypted;
  if (includeSecrets) result.public_token = decryptSecret(row.public_token_encrypted);
  return result;
}

function listOverlays({ includeSecrets = false } = {}) {
  return db.prepare(
    `SELECT o.*,
      (SELECT COUNT(*) FROM overlay_scenes s WHERE s.overlay_id = o.id) AS scene_count,
      (SELECT COUNT(*) FROM overlay_modules m JOIN overlay_scenes s ON s.id = m.scene_id WHERE s.overlay_id = o.id) AS module_count,
      (SELECT name FROM overlay_scenes s WHERE s.id = o.active_scene_id) AS active_scene_name
     FROM overlays o ORDER BY o.sort_order, o.created_at`
  ).all().map((row) => rowWithToken(row, includeSecrets));
}

function getOverlay(id, { includeSecrets = false } = {}) {
  const row = db.prepare("SELECT * FROM overlays WHERE id = ?").get(id);
  if (!row) return null;
  const overlay = rowWithToken(row, includeSecrets);
  overlay.scenes = db.prepare("SELECT * FROM overlay_scenes WHERE overlay_id = ? ORDER BY sort_order, created_at")
    .all(id)
    .map((scene) => {
      const result = rowWithToken(scene, includeSecrets);
      result.modules = db.prepare("SELECT * FROM overlay_modules WHERE scene_id = ? ORDER BY sort_order, created_at")
        .all(scene.id)
        .map((module) => ({
          ...module,
          enabled: Boolean(module.enabled),
          config: parseConfig(module.config_json)
        }));
      return result;
    });
  overlay.active_scene_id = overlay.active_scene_id || overlay.scenes[0]?.id || null;
  overlay.obs = getObsSettings(id, { includeSecret: includeSecrets });
  return overlay;
}

function createOverlay(input = {}) {
  const overlayToken = createStoredToken();
  const sceneToken = createStoredToken();
  const now = Date.now();
  const overlayId = crypto.randomUUID();
  const sceneId = crypto.randomUUID();
  const create = db.transaction(() => {
    db.prepare(
      "INSERT INTO overlays (id, name, description, enabled, canvas_width, canvas_height, sort_order, active_scene_id, public_token_hash, public_token_encrypted, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
    ).run(
      overlayId,
      requiredName(input.name, "Overlay name"),
      String(input.description || "").trim().slice(0, 1000),
      input.enabled === false ? 0 : 1,
      canvasDimension(input.canvasWidth, 1920),
      canvasDimension(input.canvasHeight, 1080),
      nextOrder("overlays"),
      sceneId,
      overlayToken.hash,
      overlayToken.encrypted,
      now,
      now
    );
    db.prepare(
      "INSERT INTO overlay_scenes (id, overlay_id, name, enabled, sort_order, public_token_hash, public_token_encrypted, obs_scene_name, created_at, updated_at) VALUES (?, ?, ?, 1, 0, ?, ?, NULL, ?, ?)"
    ).run(sceneId, overlayId, requiredName(input.sceneName || "Main", "Scene name"), sceneToken.hash, sceneToken.encrypted, now, now);
  });
  create();
  notifyOverlayChanged(overlayId, "create");
  return getOverlay(overlayId, { includeSecrets: true });
}

function updateOverlay(id, input = {}) {
  const existing = db.prepare("SELECT * FROM overlays WHERE id = ?").get(id);
  if (!existing) throw new Error("Overlay not found.");
  const now = Date.now();
  db.prepare("UPDATE overlays SET name = ?, description = ?, enabled = ?, canvas_width = ?, canvas_height = ?, updated_at = ? WHERE id = ?")
    .run(
      requiredName(input.name === undefined ? existing.name : input.name, "Overlay name"),
      String(input.description === undefined ? existing.description : input.description).trim().slice(0, 1000),
      input.enabled === undefined ? existing.enabled : input.enabled ? 1 : 0,
      canvasDimension(input.canvasWidth, existing.canvas_width || 1920),
      canvasDimension(input.canvasHeight, existing.canvas_height || 1080),
      now,
      id
    );
  notifyOverlayChanged(id, "overlay_update");
  return getOverlay(id);
}

function duplicateOverlay(id, name) {
  const source = getOverlay(id);
  if (!source) throw new Error("Overlay not found.");
  const copy = createOverlay({
    name: name || `${source.name} copy`,
    description: source.description,
    enabled: source.enabled,
    canvasWidth: source.canvas_width,
    canvasHeight: source.canvas_height,
    sceneName: source.scenes[0]?.name || "Main"
  });
  const copyId = copy.id;
  const moduleIds = new Map();
  const clone = db.transaction(() => {
    db.prepare("DELETE FROM overlay_modules WHERE scene_id IN (SELECT id FROM overlay_scenes WHERE overlay_id = ?)").run(copyId);
    db.prepare("DELETE FROM overlay_scenes WHERE overlay_id = ?").run(copyId);
    let firstSceneId = null;
    for (const scene of source.scenes) {
      const newSceneId = crypto.randomUUID();
      const storedToken = createStoredToken();
      if (!firstSceneId) firstSceneId = newSceneId;
      db.prepare(
        "INSERT INTO overlay_scenes (id, overlay_id, name, enabled, sort_order, public_token_hash, public_token_encrypted, obs_scene_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
      ).run(newSceneId, copyId, scene.name, scene.enabled ? 1 : 0, scene.sort_order, storedToken.hash, storedToken.encrypted, scene.obs_scene_name, Date.now(), Date.now());
      for (const module of scene.modules) {
        const newModuleId = crypto.randomUUID();
        db.prepare(
          "INSERT INTO overlay_modules (id, scene_id, type, name, enabled, sort_order, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
        ).run(newModuleId, newSceneId, module.type, module.name, module.enabled ? 1 : 0, module.sort_order, module.config_json, Date.now(), Date.now());
        moduleIds.set(module.id, newModuleId);
      }
    }
    duplicateOverlayEventHooks(id, copyId, moduleIds);
    db.prepare("UPDATE overlays SET active_scene_id = ?, updated_at = ? WHERE id = ?").run(firstSceneId, Date.now(), copyId);
  });
  clone();
  notifyOverlayChanged(copyId, "duplicate");
  return getOverlay(copyId, { includeSecrets: true });
}

function reorderRows(table, ids, parentColumn = null, parentId = null) {
  const allowed = {
    overlays: { parent: null, idQuery: "SELECT id FROM overlays" },
    overlay_scenes: { parent: "overlay_id", idQuery: "SELECT id FROM overlay_scenes WHERE overlay_id = ?" },
    overlay_modules: { parent: "scene_id", idQuery: "SELECT id FROM overlay_modules WHERE scene_id = ?" }
  };
  const target = allowed[table];
  if (!target || target.parent !== parentColumn) throw new Error("Invalid reorder target.");
  const existing = db.prepare(target.idQuery).all(...(parentColumn ? [parentId] : [])).map((row) => row.id);
  const requested = Array.isArray(ids) ? ids.map(String) : [];
  if (requested.length !== existing.length || new Set(requested).size !== existing.length || existing.some((id) => !requested.includes(id))) {
    throw new Error("Reorder list must contain every item exactly once.");
  }
  const update = db.prepare(`UPDATE ${table} SET sort_order = ?, updated_at = ? WHERE id = ?`);
  db.transaction(() => requested.forEach((id, index) => update.run(index, Date.now(), id)))();
}

function reorderOverlays(ids) {
  reorderRows("overlays", ids);
  ids.forEach((id) => notifyOverlayChanged(id, "reorder"));
}

function deleteOverlay(id) {
  const exists = db.prepare("SELECT id FROM overlays WHERE id = ?").get(id);
  if (!exists) throw new Error("Overlay not found.");
  db.transaction(() => {
    db.prepare("DELETE FROM overlay_event_hooks WHERE overlay_id = ?").run(id);
    db.prepare("DELETE FROM overlay_modules WHERE scene_id IN (SELECT id FROM overlay_scenes WHERE overlay_id = ?)").run(id);
    db.prepare("DELETE FROM overlay_scenes WHERE overlay_id = ?").run(id);
    db.prepare("DELETE FROM overlay_obs_settings WHERE overlay_id = ?").run(id);
    db.prepare("DELETE FROM overlays WHERE id = ?").run(id);
  })();
  publishWebEvent("overlay:changed", { deleted: true, revision: Date.now() }, { scope: `overlay:${id}` });
  overlayChanges.emit("changed", { overlayId: id, source: "delete", deleted: true, revision: Date.now() });
}

function regenerateOverlayToken(id) {
  const token = createStoredToken();
  const result = db.prepare("UPDATE overlays SET public_token_hash = ?, public_token_encrypted = ?, updated_at = ? WHERE id = ?")
    .run(token.hash, token.encrypted, Date.now(), id);
  if (!result.changes) throw new Error("Overlay not found.");
  notifyOverlayChanged(id, "token_regenerated");
  return token.token;
}

function addScene(overlayId, input = {}) {
  if (!db.prepare("SELECT id FROM overlays WHERE id = ?").get(overlayId)) throw new Error("Overlay not found.");
  const token = createStoredToken();
  const id = crypto.randomUUID();
  const now = Date.now();
  db.prepare(
    "INSERT INTO overlay_scenes (id, overlay_id, name, enabled, sort_order, public_token_hash, public_token_encrypted, obs_scene_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
  ).run(
    id,
    overlayId,
    requiredName(input.name, "Scene name"),
    input.enabled === false ? 0 : 1,
    nextOrder("overlay_scenes", "overlay_id", overlayId),
    token.hash,
    token.encrypted,
    String(input.obsSceneName || "").trim().slice(0, 256) || null,
    now,
    now
  );
  touchOverlay(overlayId, now);
  notifyOverlayChanged(overlayId, "scene_create");
  return getOverlay(overlayId, { includeSecrets: true }).scenes.find((scene) => scene.id === id);
}

function updateScene(sceneId, input = {}) {
  const scene = db.prepare("SELECT * FROM overlay_scenes WHERE id = ?").get(sceneId);
  if (!scene) throw new Error("Scene not found.");
  const now = Date.now();
  db.prepare("UPDATE overlay_scenes SET name = ?, enabled = ?, obs_scene_name = ?, updated_at = ? WHERE id = ?")
    .run(
      requiredName(input.name === undefined ? scene.name : input.name, "Scene name"),
      input.enabled === undefined ? scene.enabled : input.enabled ? 1 : 0,
      String(input.obsSceneName === undefined ? scene.obs_scene_name || "" : input.obsSceneName).trim().slice(0, 256) || null,
      now,
      sceneId
    );
  touchOverlay(scene.overlay_id, now);
  notifyOverlayChanged(scene.overlay_id, "scene_update");
}

function duplicateScene(sceneId, name) {
  const source = db.prepare("SELECT * FROM overlay_scenes WHERE id = ?").get(sceneId);
  if (!source) throw new Error("Scene not found.");
  const scene = addScene(source.overlay_id, { name: name || `${source.name} copy`, enabled: Boolean(source.enabled), obsSceneName: source.obs_scene_name });
  const modules = db.prepare("SELECT * FROM overlay_modules WHERE scene_id = ? ORDER BY sort_order, created_at").all(sceneId);
  const insert = db.prepare(
    "INSERT INTO overlay_modules (id, scene_id, type, name, enabled, sort_order, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
  );
  db.transaction(() => modules.forEach((module) => insert.run(crypto.randomUUID(), scene.id, module.type, module.name, module.enabled, module.sort_order, module.config_json, Date.now(), Date.now())))();
  touchOverlay(source.overlay_id);
  notifyOverlayChanged(source.overlay_id, "scene_duplicate");
  return getOverlay(source.overlay_id, { includeSecrets: true }).scenes.find((item) => item.id === scene.id);
}

function reorderScenes(overlayId, ids) {
  reorderRows("overlay_scenes", ids, "overlay_id", overlayId);
  touchOverlay(overlayId);
  notifyOverlayChanged(overlayId, "scene_reorder");
}

function deleteScene(sceneId) {
  const scene = db.prepare("SELECT * FROM overlay_scenes WHERE id = ?").get(sceneId);
  if (!scene) throw new Error("Scene not found.");
  const count = db.prepare("SELECT COUNT(*) AS count FROM overlay_scenes WHERE overlay_id = ?").get(scene.overlay_id).count;
  if (count <= 1) throw new Error("Every overlay must have at least one scene.");
  db.transaction(() => {
    db.prepare("DELETE FROM overlay_event_hooks WHERE module_id IN (SELECT id FROM overlay_modules WHERE scene_id = ?)").run(sceneId);
    db.prepare("DELETE FROM overlay_modules WHERE scene_id = ?").run(sceneId);
    db.prepare("DELETE FROM overlay_scenes WHERE id = ?").run(sceneId);
    if (db.prepare("SELECT active_scene_id FROM overlays WHERE id = ?").get(scene.overlay_id)?.active_scene_id === sceneId) {
      const fallback = db.prepare("SELECT id FROM overlay_scenes WHERE overlay_id = ? ORDER BY enabled DESC, sort_order, created_at LIMIT 1").get(scene.overlay_id);
      db.prepare("UPDATE overlays SET active_scene_id = ?, updated_at = ? WHERE id = ?").run(fallback.id, Date.now(), scene.overlay_id);
    } else {
      touchOverlay(scene.overlay_id);
    }
  })();
  notifyOverlayChanged(scene.overlay_id, "scene_delete");
}

function regenerateSceneToken(sceneId) {
  const scene = db.prepare("SELECT overlay_id FROM overlay_scenes WHERE id = ?").get(sceneId);
  if (!scene) throw new Error("Scene not found.");
  const token = createStoredToken();
  db.prepare("UPDATE overlay_scenes SET public_token_hash = ?, public_token_encrypted = ?, updated_at = ? WHERE id = ?")
    .run(token.hash, token.encrypted, Date.now(), sceneId);
  touchOverlay(scene.overlay_id);
  notifyOverlayChanged(scene.overlay_id, "scene_token_regenerated");
  return token.token;
}

function setActiveScene(overlayId, sceneId, { source = "server" } = {}) {
  const scene = db.prepare("SELECT id, enabled FROM overlay_scenes WHERE id = ? AND overlay_id = ?").get(sceneId, overlayId);
  if (!scene) throw new Error("Scene not found for this overlay.");
  if (!scene.enabled) throw new Error("Disabled scenes cannot be activated.");
  db.prepare("UPDATE overlays SET active_scene_id = ?, updated_at = ? WHERE id = ?").run(sceneId, Date.now(), overlayId);
  notifyOverlayChanged(overlayId, source);
}

function addModule(sceneId, input = {}) {
  const overlayId = overlayIdForScene(sceneId);
  if (!overlayId) throw new Error("Scene not found.");
  const type = String(input.type || "text");
  const config = normalizeModuleConfig(type, input.config || input);
  const id = crypto.randomUUID();
  const now = Date.now();
  db.prepare(
    "INSERT INTO overlay_modules (id, scene_id, type, name, enabled, sort_order, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
  ).run(id, sceneId, type, requiredName(input.name || "Module", "Module name"), input.enabled === false ? 0 : 1, nextOrder("overlay_modules", "scene_id", sceneId), JSON.stringify(config), now, now);
  touchOverlay(overlayId, now);
  notifyOverlayChanged(overlayId, "module_create");
  return id;
}

function updateModule(moduleId, input = {}) {
  const module = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId);
  if (!module) throw new Error("Overlay module not found.");
  const overlayId = overlayIdForModule(moduleId);
  const type = String(input.type || module.type);
  const config = normalizeModuleConfig(type, input.config || input);
  const now = Date.now();
  db.prepare("UPDATE overlay_modules SET type = ?, name = ?, enabled = ?, config_json = ?, updated_at = ? WHERE id = ?")
    .run(type, requiredName(input.name === undefined ? module.name : input.name, "Module name"), input.enabled === undefined ? module.enabled : input.enabled ? 1 : 0, JSON.stringify(config), now, moduleId);
  touchOverlay(overlayId, now);
  notifyOverlayChanged(overlayId, "module_update");
}

function updateModuleTransform(moduleId, input = {}) {
  const module = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId);
  if (!module) throw new Error("Overlay source not found.");
  const current = parseConfig(module.config_json);
  updateModule(moduleId, {
    type: module.type,
    name: module.name,
    enabled: Boolean(module.enabled),
    config: {
      ...current,
      x: input.x === undefined ? current.x : input.x,
      y: input.y === undefined ? current.y : input.y,
      width: input.width === undefined ? current.width : input.width,
      height: input.height === undefined ? current.height : input.height,
      anchor: input.anchor === undefined ? current.anchor : input.anchor
    }
  });
  const updated = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId);
  return { ...updated, enabled: Boolean(updated.enabled), config: parseConfig(updated.config_json) };
}

function refreshModule(moduleId) {
  const overlayId = overlayIdForModule(moduleId);
  if (!overlayId) throw new Error("Overlay source not found.");
  publishWebEvent("overlay:module-refresh", { module_id: moduleId }, { scope: `overlay:${overlayId}` });
  return { overlayId, moduleId };
}

function duplicateModule(moduleId, name) {
  const module = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId);
  if (!module) throw new Error("Overlay module not found.");
  return addModule(module.scene_id, {
    type: module.type,
    name: name || `${module.name} copy`,
    enabled: Boolean(module.enabled),
    config: parseConfig(module.config_json)
  });
}

function reorderModules(sceneId, ids) {
  const overlayId = overlayIdForScene(sceneId);
  if (!overlayId) throw new Error("Scene not found.");
  reorderRows("overlay_modules", ids, "scene_id", sceneId);
  touchOverlay(overlayId);
  notifyOverlayChanged(overlayId, "module_reorder");
}

function deleteModule(moduleId) {
  const overlayId = overlayIdForModule(moduleId);
  if (!overlayId) throw new Error("Overlay module not found.");
  db.transaction(() => {
    db.prepare("DELETE FROM overlay_event_hooks WHERE module_id = ?").run(moduleId);
    db.prepare("DELETE FROM overlay_modules WHERE id = ?").run(moduleId);
  })();
  touchOverlay(overlayId);
  notifyOverlayChanged(overlayId, "module_delete");
}

function resolvePublicOverlay(overlayToken, sceneToken = null) {
  const overlay = db.prepare("SELECT * FROM overlays WHERE public_token_hash = ?").get(tokenHash(overlayToken));
  if (!overlay) return null;
  let fixedScene = null;
  if (sceneToken) {
    fixedScene = db.prepare("SELECT * FROM overlay_scenes WHERE overlay_id = ? AND public_token_hash = ?").get(overlay.id, tokenHash(sceneToken));
    if (!fixedScene) return null;
  }
  return { overlay, fixedScene };
}

function webSourceTicketRecord(moduleId) {
  return db.prepare(
    `SELECT m.*, s.overlay_id, s.public_token_hash AS scene_token_hash, s.enabled AS scene_enabled,
      o.public_token_hash AS overlay_token_hash, o.enabled AS overlay_enabled
     FROM overlay_modules m
     JOIN overlay_scenes s ON s.id = m.scene_id
     JOIN overlays o ON o.id = s.overlay_id
     WHERE m.id = ?`
  ).get(moduleId);
}

function webSourceTicketValue(row) {
  return [row.id, row.scene_id, row.overlay_id, row.overlay_token_hash, row.scene_token_hash].join(":");
}

function webSourceRenderUrl(moduleId) {
  const row = webSourceTicketRecord(moduleId);
  if (!row || getOverlayModuleType(row.type)?.renderType !== "web") return null;
  const signature = scopedSignature("overlay-web-source", webSourceTicketValue(row));
  return `/overlay-web/${row.id}.${row.scene_id}.${signature}`;
}

function chatDockTicketValue(row) {
  return [row.id, row.scene_id, row.overlay_id, row.overlay_token_hash, row.scene_token_hash].join(":");
}

function chatDockRenderUrl(moduleId) {
  const row = webSourceTicketRecord(moduleId);
  if (!row || getOverlayModuleType(row.type)?.renderType !== "chat") return null;
  const signature = scopedSignature("overlay-chat-dock", chatDockTicketValue(row));
  return `/overlay-chat/${row.id}.${row.scene_id}.${signature}`;
}

function resolveChatDockTicket(ticket) {
  const [moduleId, sceneId, signature, ...extra] = String(ticket || "").split(".");
  if (!moduleId || !sceneId || !signature || extra.length) return null;
  const row = webSourceTicketRecord(moduleId);
  if (!row || row.scene_id !== sceneId || !row.overlay_enabled || !row.scene_enabled || !row.enabled) return null;
  if (getOverlayModuleType(row.type)?.renderType !== "chat") return null;
  if (!verifyScopedSignature("overlay-chat-dock", chatDockTicketValue(row), signature)) return null;
  return {
    overlayId: row.overlay_id,
    sceneId: row.scene_id,
    module: {
      id: row.id,
      type: row.type,
      renderType: "chat",
      name: row.name,
      config: parseConfig(row.config_json)
    }
  };
}

function resolveWebSourceTicket(ticket) {
  const [moduleId, sceneId, signature, ...extra] = String(ticket || "").split(".");
  if (!moduleId || !sceneId || !signature || extra.length) return null;
  const row = webSourceTicketRecord(moduleId);
  if (!row || row.scene_id !== sceneId || !row.overlay_enabled || !row.scene_enabled || !row.enabled) return null;
  if (getOverlayModuleType(row.type)?.renderType !== "web") return null;
  if (!verifyScopedSignature("overlay-web-source", webSourceTicketValue(row), signature)) return null;
  return {
    overlayId: row.overlay_id,
    sceneId: row.scene_id,
    moduleId: row.id,
    config: parseConfig(row.config_json)
  };
}

function buildPublicState(overlayId, fixedSceneId = null) {
  const overlay = db.prepare("SELECT id, enabled, active_scene_id, canvas_width, canvas_height, updated_at FROM overlays WHERE id = ?").get(overlayId);
  if (!overlay) return { exists: false, enabled: false, revision: Date.now(), scene: null };
  const canvas = { width: overlay.canvas_width || 1920, height: overlay.canvas_height || 1080 };
  if (!overlay.enabled) return { exists: true, enabled: false, revision: overlay.updated_at, canvas, scene: null };
  const sceneId = fixedSceneId || overlay.active_scene_id;
  const scene = sceneId
    ? db.prepare("SELECT id, name, enabled FROM overlay_scenes WHERE id = ? AND overlay_id = ?").get(sceneId, overlayId)
    : null;
  if (!scene || !scene.enabled) return { exists: true, enabled: true, revision: overlay.updated_at, canvas, scene: null };
  const eventOnlyModules = eventOnlyModuleIds(scene.id);
  const modules = db.prepare("SELECT id, type, name, config_json FROM overlay_modules WHERE scene_id = ? AND enabled = 1 ORDER BY sort_order, created_at")
    .all(scene.id)
    .map((module) => {
      const renderType = getOverlayModuleType(module.type)?.renderType || null;
      const config = parseConfig(module.config_json);
      if (eventOnlyModules.has(module.id)) config.eventOnly = true;
      if (renderType === "web" && config.injectPageCss !== false) config.renderUrl = webSourceRenderUrl(module.id);
      return { id: module.id, type: module.type, renderType, name: module.name, config };
    })
    .filter((module) => module.renderType);
  return {
    exists: true,
    enabled: true,
    revision: overlay.updated_at,
    canvas,
    scene: { id: scene.id, name: scene.name, modules }
  };
}

function getObsSettings(overlayId, { includeSecret = false } = {}) {
  const row = db.prepare("SELECT * FROM overlay_obs_settings WHERE overlay_id = ?").get(overlayId);
  const result = row || {
    overlay_id: overlayId,
    enabled: 0,
    provider: "local_obs_websocket",
    endpoint: "ws://127.0.0.1:4455",
    password_encrypted: null,
    sync_direction: "none",
    reconnect: 1
  };
  const output = { ...result, enabled: Boolean(result.enabled), reconnect: Boolean(result.reconnect), has_password: Boolean(result.password_encrypted) };
  delete output.password_encrypted;
  if (includeSecret) output.password = result.password_encrypted ? decryptSecret(result.password_encrypted) : "";
  return output;
}

function saveObsSettings(overlayId, input = {}) {
  if (!db.prepare("SELECT id FROM overlays WHERE id = ?").get(overlayId)) throw new Error("Overlay not found.");
  const requestedProvider = String(input.provider || "local_obs_websocket").trim();
  const provider = /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(requestedProvider)
    ? requestedProvider
    : "local_obs_websocket";
  const endpoint = String(input.endpoint || "ws://127.0.0.1:4455").trim().slice(0, 2048);
  if (provider === "local_obs_websocket" && !/^wss?:\/\//i.test(endpoint)) {
    throw new Error("OBS endpoint must start with ws:// or wss://.");
  }
  const syncDirection = ["none", "obs_to_overlay", "overlay_to_obs", "bidirectional"].includes(input.syncDirection) ? input.syncDirection : "none";
  const existing = db.prepare("SELECT password_encrypted FROM overlay_obs_settings WHERE overlay_id = ?").get(overlayId);
  let passwordEncrypted = existing?.password_encrypted || null;
  if (input.clearPassword) passwordEncrypted = null;
  else if (input.password) passwordEncrypted = encryptSecret(input.password);
  const now = Date.now();
  db.prepare(
    `INSERT INTO overlay_obs_settings (overlay_id, enabled, provider, endpoint, password_encrypted, sync_direction, reconnect, created_at, updated_at)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
     ON CONFLICT(overlay_id) DO UPDATE SET enabled = excluded.enabled, provider = excluded.provider, endpoint = excluded.endpoint,
       password_encrypted = excluded.password_encrypted, sync_direction = excluded.sync_direction, reconnect = excluded.reconnect, updated_at = excluded.updated_at`
  ).run(overlayId, input.enabled ? 1 : 0, provider, endpoint, passwordEncrypted, syncDirection, input.reconnect === false ? 0 : 1, now, now);
  return getObsSettings(overlayId, { includeSecret: true });
}

function listEnabledObsSettings() {
  return db.prepare("SELECT overlay_id FROM overlay_obs_settings WHERE enabled = 1").all().map((row) => getObsSettings(row.overlay_id, { includeSecret: true }));
}

function onOverlayChanged(listener) {
  overlayChanges.on("changed", listener);
  return () => overlayChanges.off("changed", listener);
}

module.exports = {
  addModule,
  addScene,
  buildPublicState,
  chatDockRenderUrl,
  createOverlay,
  deleteModule,
  deleteOverlay,
  deleteScene,
  duplicateModule,
  duplicateOverlay,
  duplicateScene,
  getObsSettings,
  getOverlay,
  listEnabledObsSettings,
  listOverlays,
  notifyOverlayChanged,
  onOverlayChanged,
  regenerateOverlayToken,
  regenerateSceneToken,
  refreshModule,
  reorderModules,
  reorderOverlays,
  reorderScenes,
  resolvePublicOverlay,
  resolveChatDockTicket,
  resolveWebSourceTicket,
  saveObsSettings,
  setActiveScene,
  updateModule,
  updateModuleTransform,
  updateOverlay,
  updateScene,
  webSourceRenderUrl
};
