// Sidekick Browser Companion — background service worker.
//
// Maintains a long-lived chrome.runtime.connectNative() connection to the
// bundled `sidekick-native-host` helper binary, which forwards length-prefixed
// JSON frames over a Unix domain socket to the running Sidekick.app.
//
// Receives messages from the side panel and the on-demand selection content
// script, forwards them to the native host, and surfaces connection state +
// responses back to the side panel.

const HOST_NAME = "com.wpsuites.sidekick.browser_companion";
const SCHEMA_VERSION = 1;

let nativePort = null;
let connectionState = "stopped";
let lastError = null;
let lastSessionID = null;
let recentCaptures = [];
let debuggerTabId = null;
let pinCounter = 0;
let currentMode = "developer";
let desiredPassiveCapture = false;
let passiveCaptureTabId = null;
const VALID_MODES = ["debug", "developer", "ux-ui", "cro"];
const NO_ACTIVE_FEEDBACK_SESSION = /no active .*feedback session/i;

// Privacy mask: applied to every tab while Sidekick records the screen so
// credential-bearing inputs are blurred in the recording. The CSS between the
// markers MUST stay identical to the copy in
// Sources/Sidekick/Web/Scripts/inject.js (enforced by the workflow checks).
const PRIVACY_MASK_CSS = `
/* sidekick:privacy-mask:start */
input[type="password"],
input[type="email"],
input[autocomplete~="username"],
input[autocomplete~="email"],
input[autocomplete*="password"],
input[name*="pass" i], input[id*="pass" i],
input[name*="user" i], input[id*="user" i],
input[name*="email" i], input[id*="email" i],
input[name*="login" i], input[id*="login" i] {
  filter: blur(6px) !important;
}
/* sidekick:privacy-mask:end */
`;

const HEARTBEAT_INTERVAL_MS = 3000;
let heartbeatTimer = null;
let recordingActive = false;
const maskedTabIds = new Set();

async function applyMaskToTab(tabId) {
  try {
    await chrome.scripting.insertCSS({
      target: { tabId, allFrames: true },
      css: PRIVACY_MASK_CSS
    });
    maskedTabIds.add(tabId);
  } catch (_) {
    // Restricted page (chrome://, web store) — nothing we can mask.
  }
}

async function removeMaskFromTab(tabId) {
  try {
    await chrome.scripting.removeCSS({
      target: { tabId, allFrames: true },
      css: PRIVACY_MASK_CSS
    });
  } catch (_) {
    // Tab closed, restricted, navigated, or never had the mask.
  }
}

async function applyMaskToAllTabs() {
  try {
    const tabs = await chrome.tabs.query({});
    for (const tab of tabs) {
      if (tab.id != null) applyMaskToTab(tab.id);
    }
  } catch (err) {
    console.warn("sidekick: applyMaskToAllTabs query failed", err);
  }
}

async function removeMaskFromAllTabs() {
  for (const tabId of [...maskedTabIds]) {
    await removeMaskFromTab(tabId);
  }
  maskedTabIds.clear();
}

// Recovery from an MV3 service-worker restart mid-recording: recordingActive
// and maskedTabIds are in-memory only. On startup, sweep every open tab for the
// exact CSS block so a killed worker cannot strand blur styles. This deliberately
// avoids extension storage so the Web Store package does not request Storage.
(async () => {
  try {
    const tabs = await chrome.tabs.query({});
    await Promise.all(tabs.map((tab) => (tab.id == null ? null : removeMaskFromTab(tab.id))));
  } catch (err) {
    console.warn("sidekick: stranded privacy mask cleanup failed", err);
  }
})();

function setRecordingActive(active) {
  if (recordingActive === active) return;
  recordingActive = active;
  if (active) {
    applyMaskToAllTabs();
  } else {
    removeMaskFromAllTabs();
  }
}

chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
  // Injected CSS does not survive navigation; re-apply as pages load.
  if (recordingActive && changeInfo.status === "loading") applyMaskToTab(tabId);
});

chrome.tabs.onRemoved.addListener((tabId) => {
  maskedTabIds.delete(tabId);
});

function startHeartbeat() {
  if (heartbeatTimer) return;
  heartbeatTimer = setInterval(() => {
    if (!nativePort) return;
    sendToNative({
      schema: SCHEMA_VERSION,
      sessionID: lastSessionID,
      kind: "hello",
      timestamp: Date.now() / 1000,
      tab: null,
      payload: { extension: "sidekick-chrome", heartbeat: true }
    });
  }, HEARTBEAT_INTERVAL_MS);
}

function stopHeartbeat() {
  if (heartbeatTimer) {
    clearInterval(heartbeatTimer);
    heartbeatTimer = null;
  }
}

chrome.action.onClicked.addListener(async (tab) => {
  try {
    await chrome.sidePanel.open({ tabId: tab.id });
  } catch (err) {
    console.warn("sidekick: side panel open failed", err);
  }
});

chrome.runtime.onInstalled.addListener(() => {
  chrome.sidePanel
    .setPanelBehavior({ openPanelOnActionClick: true })
    .catch((err) => console.warn("sidekick: setPanelBehavior failed", err));
});

function setState(state, error = null) {
  connectionState = state;
  lastError = error;
  broadcast({ type: "state", state, error, lastSessionID, recentCaptures, mode: currentMode });
}

function pushMode(mode) {
  if (!VALID_MODES.includes(mode)) return false;
  currentMode = mode;
  // Best-effort — if the native port isn't connected yet, the next hello will
  // be followed by a sessionState mode push on side-panel reload.
  sendToNative({
    schema: SCHEMA_VERSION,
    sessionID: lastSessionID,
    kind: "session_state",
    timestamp: Date.now() / 1000,
    tab: null,
    payload: { mode }
  });
  broadcast({ type: "state", state: connectionState, error: lastError, lastSessionID, recentCaptures, mode: currentMode });
  return true;
}

function broadcast(message) {
  chrome.runtime.sendMessage(message).catch(() => {
    /* no side panel listener — fine. */
  });
}

function rememberCapture(capture) {
  recentCaptures = [capture, ...recentCaptures].slice(0, 10);
  broadcast({ type: "state", state: connectionState, error: lastError, lastSessionID, recentCaptures, mode: currentMode });
}

function updateSessionFromResponse(response) {
  if (!response) return;
  if (Object.prototype.hasOwnProperty.call(response, "sessionID")) {
    lastSessionID = response.sessionID || null;
  } else if (response.message && NO_ACTIVE_FEEDBACK_SESSION.test(response.message)) {
    lastSessionID = null;
  }
}

function ensureNativeConnection() {
  if (nativePort) return nativePort;
  try {
    nativePort = chrome.runtime.connectNative(HOST_NAME);
  } catch (err) {
    setState("failed", String(err && err.message ? err.message : err));
    return null;
  }
  setState("connecting");

  nativePort.onMessage.addListener((response) => {
    updateSessionFromResponse(response);
    setRecordingActive(Boolean(response && response.recordingActive === true));
    if (response && response.accepted === false) {
      setState("rejected", response.message || "Sidekick rejected the message");
    } else {
      setState("connected");
    }
    broadcast({ type: "response", response });
  });

  nativePort.onDisconnect.addListener(() => {
    const err = chrome.runtime.lastError;
    nativePort = null;
    lastSessionID = null;
    stopHeartbeat();
    setRecordingActive(false);
    if (err) {
      setState("disconnected", err.message || String(err));
    } else {
      setState("disconnected", null);
    }
  });

  // Greet the host. The Sidekick controller answers with the active session ID
  // and whether browser feedback is currently armed.
  sendToNative({
    schema: SCHEMA_VERSION,
    sessionID: null,
    kind: "hello",
    timestamp: Date.now() / 1000,
    tab: null,
    payload: {
      extension: "sidekick-chrome",
      userAgent: navigator.userAgent,
      mode: currentMode
    }
  });

  // Push the current mode immediately after hello so a freshly-armed Sidekick
  // session inherits whatever the user last selected in this side panel.
  sendToNative({
    schema: SCHEMA_VERSION,
    sessionID: null,
    kind: "session_state",
    timestamp: Date.now() / 1000,
    tab: null,
    payload: { mode: currentMode }
  });

  startHeartbeat();

  return nativePort;
}

function sendToNative(message) {
  const port = ensureNativeConnection();
  if (!port) return false;
  try {
    port.postMessage(message);
    return true;
  } catch (err) {
    setState("failed", String(err && err.message ? err.message : err));
    return false;
  }
}

function refreshNativeSessionState() {
  return sendToNative({
    schema: SCHEMA_VERSION,
    sessionID: lastSessionID,
    kind: "hello",
    timestamp: Date.now() / 1000,
    tab: null,
    payload: {
      extension: "sidekick-chrome",
      userAgent: navigator.userAgent,
      mode: currentMode
    }
  });
}

async function capturePinFromTab(tab, pinPayload) {
  if (!tab || !tab.id) return null;
  pinCounter += 1;
  const index = pinCounter;

  // Capture the visible region. Cropping by element bounds happens in the
  // content script which has the actual page coordinates and device pixel
  // ratio — here we just attach the data URL for Sidekick to keep alongside
  // the structured pin record.
  let screenshotDataUrl = null;
  try {
    screenshotDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
      format: "png"
    });
  } catch (err) {
    console.warn("sidekick: captureVisibleTab failed", err);
  }

  const message = {
    schema: SCHEMA_VERSION,
    sessionID: lastSessionID,
    kind: "pin",
    timestamp: Date.now() / 1000,
    tab: { id: tab.id, url: tab.url, title: tab.title },
    payload: {
      index,
      tagName: pinPayload.tagName,
      selector: pinPayload.selector,
      role: pinPayload.role,
      ariaName: pinPayload.ariaName,
      bounds: pinPayload.bounds,
      viewport: pinPayload.viewport,
      computedStyles: pinPayload.computedStyles || {},
      outerHTML: pinPayload.outerHTML,
      visibleText: pinPayload.visibleText,
      screenshotPath: `browser/pins/pin-${String(index).padStart(3, "0")}.png`,
      screenshotDataURL: screenshotDataUrl,
      url: tab.url,
      title: tab.title
    }
  };

  sendToNative(message);

  rememberCapture({
    kind: "pin",
    index,
    selector: pinPayload.selector,
    url: tab.url,
    title: tab.title,
    capturedAt: new Date().toISOString()
  });

  return { index };
}

async function setPassiveCaptureForTab(tab, enabled) {
  if (!tab || !tab.id || !/^https?:|^file:/.test(tab.url || "")) return false;
  try {
    await chrome.scripting.executeScript({
      target: { tabId: tab.id, allFrames: false },
      files: ["content-script.js"]
    });
    await chrome.tabs.sendMessage(tab.id, { type: "sidekick.setPassiveCapture", enabled });
    return true;
  } catch (err) {
    console.warn("sidekick: setPassiveCapture failed", err);
    return false;
  }
}

async function applyDesiredPassiveCaptureToTab(tab) {
  if (passiveCaptureTabId && (!tab || passiveCaptureTabId !== tab.id)) {
    try {
      const previousTab = await chrome.tabs.get(passiveCaptureTabId);
      await setPassiveCaptureForTab(previousTab, false);
    } catch (_) {
      /* tab may have closed or navigated somewhere unsupported. */
    }
    passiveCaptureTabId = null;
  }

  if (!desiredPassiveCapture) {
    if (tab && tab.id) await setPassiveCaptureForTab(tab, false);
    passiveCaptureTabId = null;
    return false;
  }

  const ok = await setPassiveCaptureForTab(tab, true);
  if (ok) passiveCaptureTabId = tab.id;
  return ok;
}

async function captureBrowserClickFromTab(tab, clickPayload) {
  if (!tab || !tab.id) return null;
  pinCounter += 1;
  const index = pinCounter;

  await new Promise((resolve) => setTimeout(resolve, 250));

  let screenshotDataUrl = null;
  try {
    screenshotDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: "png" });
  } catch (err) {
    console.warn("sidekick: browser click screenshot failed", err);
  }

  sendToNative({
    schema: SCHEMA_VERSION,
    sessionID: lastSessionID,
    kind: "browser_click",
    timestamp: clickPayload.clickedAt || Date.now() / 1000,
    tab: { id: tab.id, url: tab.url, title: tab.title },
    payload: {
      ...clickPayload,
      index,
      screenshotPath: `browser/pins/pin-${String(index).padStart(3, "0")}.png`,
      screenshotDataURL: screenshotDataUrl,
      url: tab.url,
      title: tab.title
    }
  });

  rememberCapture({
    kind: "browser-click",
    index,
    selector: clickPayload.selector,
    url: tab.url,
    title: tab.title,
    capturedAt: new Date().toISOString()
  });

  return { index };
}

async function captureViewportFromTab(tab, label) {
  if (!tab || !tab.id) return null;
  let screenshotDataUrl = null;
  try {
    screenshotDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
      format: "png"
    });
  } catch (err) {
    console.warn("sidekick: captureVisibleTab failed", err);
  }

  const viewport = {
    width: tab.width || 0,
    height: tab.height || 0
  };

  const message = {
    schema: SCHEMA_VERSION,
    sessionID: lastSessionID,
    kind: "viewport_snapshot",
    timestamp: Date.now() / 1000,
    tab: { id: tab.id, url: tab.url, title: tab.title },
    payload: {
      label: label || "Viewport",
      width: viewport.width,
      height: viewport.height,
      url: tab.url,
      screenshotDataURL: screenshotDataUrl
    }
  };
  sendToNative(message);

  rememberCapture({
    kind: "viewport",
    label,
    url: tab.url,
    title: tab.title,
    capturedAt: new Date().toISOString()
  });
  return true;
}

async function startSelection(tab) {
  if (!tab || !tab.id) return;
  try {
    await chrome.scripting.executeScript({
      target: { tabId: tab.id, allFrames: false },
      files: ["content-script.js"]
    });
    await chrome.tabs.sendMessage(tab.id, { type: "sidekick.startSelection" });
  } catch (err) {
    console.warn("sidekick: startSelection failed", err);
  }
}

async function stopSelection(tab) {
  if (!tab || !tab.id) return;
  try {
    await chrome.tabs.sendMessage(tab.id, { type: "sidekick.stopSelection" });
  } catch (err) {
    /* tab might have navigated; not fatal. */
  }
}

async function getActiveTab() {
  const tabs = await chrome.tabs.query({ active: true });
  if (tabs.length === 0) return null;

  try {
    const lastFocused = await chrome.windows.getLastFocused({ populate: true, windowTypes: ["normal"] });
    if (lastFocused && lastFocused.tabs) {
      const activeTab = lastFocused.tabs.find((t) => t.active);
      if (activeTab) return activeTab;
    }
  } catch (err) {
    console.warn("sidekick: getLastFocused failed", err);
  }

  for (const tab of tabs) {
    if (tab.url && /^https?:|^file:/.test(tab.url)) {
      return tab;
    }
  }

  return tabs[0];
}

async function attachDebuggerToTab(tab) {
  if (!tab || !tab.id) return false;
  if (debuggerTabId === tab.id) return true;
  try {
    await chrome.debugger.attach({ tabId: tab.id }, "1.3");
    debuggerTabId = tab.id;
    await chrome.debugger.sendCommand({ tabId: tab.id }, "Console.enable", {});
    await chrome.debugger.sendCommand({ tabId: tab.id }, "Network.enable", {});
    return true;
  } catch (err) {
    console.warn("sidekick: chrome.debugger.attach failed", err);
    setState(connectionState, String(err && err.message ? err.message : err));
    return false;
  }
}

async function detachDebugger() {
  if (debuggerTabId == null) return;
  try {
    await chrome.debugger.detach({ tabId: debuggerTabId });
  } catch (err) {
    /* already gone; ignore. */
  }
  debuggerTabId = null;
}

chrome.debugger.onEvent.addListener((source, method, params) => {
  if (!debuggerTabId || source.tabId !== debuggerTabId) return;
  if (method === "Console.messageAdded" && params && params.message) {
    const m = params.message;
    sendToNative({
      schema: SCHEMA_VERSION,
      sessionID: lastSessionID,
      kind: "console",
      timestamp: Date.now() / 1000,
      tab: { id: debuggerTabId, url: m.url || null, title: null },
      payload: {
        level: m.level || "log",
        text: String(m.text || ""),
        source: m.url || m.source || null,
        stack: m.stackTrace ? JSON.stringify(m.stackTrace).slice(0, 4000) : null
      }
    });
  } else if (method === "Network.loadingFailed" && params) {
    sendToNative({
      schema: SCHEMA_VERSION,
      sessionID: lastSessionID,
      kind: "network_failure",
      timestamp: Date.now() / 1000,
      tab: { id: debuggerTabId, url: null, title: null },
      payload: {
        method: "GET",
        url: null,
        status: 0,
        resourceType: params.type || null,
        failureReason: params.errorText || "loadingFailed"
      }
    });
  } else if (method === "Network.responseReceived" && params && params.response) {
    const status = params.response.status || 0;
    if (status >= 400) {
      sendToNative({
        schema: SCHEMA_VERSION,
        sessionID: lastSessionID,
        kind: "network_failure",
        timestamp: Date.now() / 1000,
        tab: { id: debuggerTabId, url: null, title: null },
        payload: {
          method: (params.response && params.response.requestHeaders && params.response.requestHeaders[":method"]) || "GET",
          url: params.response.url || null,
          status,
          resourceType: params.type || null,
          failureReason: `HTTP ${status}`
        }
      });
    }
  }
});

chrome.debugger.onDetach.addListener((source) => {
  if (debuggerTabId && source.tabId === debuggerTabId) {
    debuggerTabId = null;
  }
});

chrome.tabs.onActivated.addListener(async (activeInfo) => {
  if (!desiredPassiveCapture) return;
  try {
    const tab = await chrome.tabs.get(activeInfo.tabId);
    await applyDesiredPassiveCaptureToTab(tab);
  } catch (err) {
    console.warn("sidekick: passive capture tab activation failed", err);
  }
});

chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
  if (!desiredPassiveCapture || changeInfo.status !== "complete") return;
  if (!tab || !tab.active || !tab.id) return;
  try {
    await applyDesiredPassiveCaptureToTab(tab);
  } catch (err) {
    console.warn("sidekick: passive capture navigation failed", err);
  }
});

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  (async () => {
    try {
      switch (msg && msg.type) {
        case "sidekick.getState": {
          sendResponse({ state: connectionState, error: lastError, lastSessionID, recentCaptures, mode: currentMode });
          break;
        }
        case "sidekick.setMode": {
          const ok = pushMode(msg.mode);
          sendResponse({ ok, mode: currentMode });
          break;
        }
        case "sidekick.connect": {
          ensureNativeConnection();
          sendResponse({ ok: true });
          break;
        }
        case "sidekick.refreshState": {
          const ok = refreshNativeSessionState();
          sendResponse({ ok });
          break;
        }
        case "sidekick.startSelection": {
          const tab = await getActiveTab();
          await startSelection(tab);
          sendResponse({ ok: true });
          break;
        }
        case "sidekick.stopSelection": {
          const tab = await getActiveTab();
          await stopSelection(tab);
          sendResponse({ ok: true });
          break;
        }
        case "sidekick.setPassiveCapture": {
          const tab = await getActiveTab();
          desiredPassiveCapture = !!msg.enabled;
          const ok = await applyDesiredPassiveCaptureToTab(tab);
          sendResponse({ ok });
          break;
        }
        case "sidekick.captureViewport": {
          const tab = await getActiveTab();
          await captureViewportFromTab(tab, msg.label || "Viewport");
          sendResponse({ ok: true });
          break;
        }
        case "sidekick.attachDebugger": {
          const tab = await getActiveTab();
          const ok = await attachDebuggerToTab(tab);
          sendResponse({ ok });
          break;
        }
        case "sidekick.detachDebugger": {
          await detachDebugger();
          sendResponse({ ok: true });
          break;
        }
        case "sidekick.pinFromPage": {
          // Content-script forwarded a pin payload.
          if (!sender.tab) {
            sendResponse({ ok: false, error: "no sender tab" });
            break;
          }
          const result = await capturePinFromTab(sender.tab, msg.payload || {});
          sendResponse({ ok: true, result });
          break;
        }
        case "sidekick.browserClickFromPage": {
          if (!sender.tab) {
            sendResponse({ ok: false, error: "no sender tab" });
            break;
          }
          const result = await captureBrowserClickFromTab(sender.tab, msg.payload || {});
          sendResponse({ ok: true, result });
          break;
        }
        default:
          sendResponse({ ok: false, error: "unknown message type" });
      }
    } catch (err) {
      sendResponse({ ok: false, error: String(err && err.message ? err.message : err) });
    }
  })();
  return true; // async response
});
