/**
 * Background script — message router + Discord token capture
 * (webRequest) + Pixiv PKCE OAuth. Direct port of GS background.js;
 * api.js client points at FC instead of GS.
 */

let discordToken = null;
let discordTokenCapturedAt = null;

let pixivRefreshToken = null;
let pixivTokenCapturedAt = null;
let pixivOAuthPending = null;

const PIXIV_CLIENT_ID = 'MOBrBDS8blbauoSck0ZfDbtuzpyT';
const PIXIV_CLIENT_SECRET = 'lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj';
const PIXIV_OAUTH_URL = 'https://app-api.pixiv.net/web/v1/login';
const PIXIV_TOKEN_URL = 'https://oauth.secure.pixiv.net/auth/token';
const PIXIV_REDIRECT_URI = 'https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback';

let initialized = false;

async function ensureInitialized() {
  if (initialized) return;
  await api.init();
  await loadDiscordToken();
  await loadPixivToken();
  initialized = true;
}

browser.runtime.onInstalled.addListener(() => ensureInitialized());
browser.runtime.onStartup.addListener(() => ensureInitialized());
ensureInitialized().catch(e => console.error('init failed:', e));

// ---- Extension self-update check (#1489) ----
// Installed per-instance from the operator's FC host, so Firefox's static
// update_url can't apply (each instance has a different host). Instead ask the
// configured backend for the latest published version and nudge the operator to
// reinstall the freshly-signed XPI — surfaced as a popup banner (on demand) and
// a toolbar badge (daily). /api/extension/manifest is public and returns
// {version, latest_url, sha256} plus an OPTIONAL {channel} naming which channel
// that instance serves ("dev"/"main", #3113); the XPI is served from the web
// root (not /api).
//
// The channel IS the instance: Firefox's static update_url cannot apply here
// because every FC install is a different host, so the extension asks its
// configured backend — which means switching channel is repointing apiUrl in
// options and reinstalling from that host. There is no separate channel
// setting to build, and building one would contradict each server build
// shipping its own extension.

function versionIsNewer(candidate, current) {
  // Dotted numeric compare so 1.0.10 > 1.0.9 (a plain string compare wouldn't).
  const a = String(candidate).split('.').map(n => parseInt(n, 10) || 0);
  const b = String(current).split('.').map(n => parseInt(n, 10) || 0);
  for (let i = 0; i < Math.max(a.length, b.length); i++) {
    if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
  }
  return false;
}

async function checkForUpdateInfo() {
  await ensureInitialized();
  if (!api.isConfigured()) return { updateAvailable: false, configured: false };
  let info;
  try {
    info = await api.getExtensionManifest();
  } catch (e) {
    return { updateAvailable: false, error: e.message };
  }
  const currentVersion = browser.runtime.getManifest().version;
  const latestVersion = info && info.version ? info.version : null;
  // Which channel the configured instance serves — reported ALONGSIDE the
  // version, never folded into it. A `-dev` suffix would have to survive
  // versionIsNewer's parseInt above, and it wouldn't: the segment would read
  // as 0 and every dev build would compare equal to every other.
  //
  // null is a normal answer, not a failure — an instance built before the
  // field existed, or one built locally with no channel declared. Nothing
  // below branches on it except the label.
  const channel = info && info.channel ? info.channel : null;
  // latest_url is served from the web root, not the JSON API.
  const base = api.webRoot();
  return {
    updateAvailable: !!latestVersion && versionIsNewer(latestVersion, currentVersion),
    currentVersion,
    latestVersion,
    channel,
    xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null,
  };
}

async function refreshUpdateBadge() {
  let r;
  try { r = await checkForUpdateInfo(); } catch { return; }
  try {
    await browser.action.setBadgeText({ text: r.updateAvailable ? '↑' : '' });
    if (r.updateAvailable) {
      await browser.action.setBadgeBackgroundColor({ color: '#F4BA7A' });
      // Channel first, version second, and the channel dropped entirely when
      // the instance doesn't report one — so the tooltip reads exactly as it
      // did before the field existed rather than saying "(unknown ...)".
      const label = r.channel ? `${r.channel} v${r.latestVersion}` : `v${r.latestVersion}`;
      await browser.action.setTitle({ title: `FabledCurator — update available (${label})` });
    } else {
      await browser.action.setTitle({ title: 'FabledCurator' });
    }
  } catch { /* action API unavailable — non-fatal */ }
}

// Daily proactive check (needs the "alarms" permission). create() is idempotent
// by name, so re-running it on each event-page load is safe.
browser.alarms.create('fc-update-check', { periodInMinutes: 24 * 60, delayInMinutes: 1 });
browser.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'fc-update-check') refreshUpdateBadge();
});
browser.runtime.onStartup.addListener(() => refreshUpdateBadge());
browser.runtime.onInstalled.addListener(() => refreshUpdateBadge());

// ---- Discord token capture via webRequest ----

browser.webRequest.onBeforeSendHeaders.addListener(
  (details) => {
    const auth = details.requestHeaders?.find(h => h.name.toLowerCase() === 'authorization');
    if (auth?.value && auth.value !== discordToken) {
      saveDiscordToken(auth.value);
    }
  },
  { urls: ['https://discord.com/api/*'] },
  ['requestHeaders'],
);

async function loadDiscordToken() {
  const s = await browser.storage.local.get(['discordToken', 'discordTokenCapturedAt']);
  discordToken = s.discordToken || null;
  discordTokenCapturedAt = s.discordTokenCapturedAt || null;
}

async function saveDiscordToken(token) {
  discordToken = token;
  discordTokenCapturedAt = new Date().toISOString();
  await browser.storage.local.set({ discordToken: token, discordTokenCapturedAt });
}

// ---- Pixiv PKCE OAuth ----

async function loadPixivToken() {
  const s = await browser.storage.local.get(['pixivRefreshToken', 'pixivTokenCapturedAt']);
  pixivRefreshToken = s.pixivRefreshToken || null;
  pixivTokenCapturedAt = s.pixivTokenCapturedAt || null;
}

async function savePixivToken(token) {
  pixivRefreshToken = token;
  pixivTokenCapturedAt = new Date().toISOString();
  await browser.storage.local.set({ pixivRefreshToken: token, pixivTokenCapturedAt });
}

function generateCodeVerifier() {
  const a = new Uint8Array(32);
  crypto.getRandomValues(a);
  return base64UrlEncode(a);
}

async function generateCodeChallenge(verifier) {
  const data = new TextEncoder().encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(new Uint8Array(hash));
}

function base64UrlEncode(buf) {
  return btoa(String.fromCharCode(...buf)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

async function initiatePixivOAuth() {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);

  const params = new URLSearchParams({
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    client: 'pixiv-android',
  });
  const tab = await browser.tabs.create({ url: `${PIXIV_OAUTH_URL}?${params}` });

  return new Promise((resolve, reject) => {
    pixivOAuthPending = { codeVerifier, tabId: tab.id, resolve, reject };
    setTimeout(() => {
      if (pixivOAuthPending && pixivOAuthPending.tabId === tab.id) {
        pixivOAuthPending = null;
        reject(new Error('Pixiv OAuth timed out (5 min)'));
      }
    }, 5 * 60 * 1000);
  });
}

browser.webRequest.onBeforeRedirect.addListener(
  async (details) => {
    if (!pixivOAuthPending) return;
    if (details.tabId !== pixivOAuthPending.tabId) return;
    const url = new URL(details.redirectUrl);
    const code = url.searchParams.get('code');
    if (!code) return;
    const verifier = pixivOAuthPending.codeVerifier;
    const resolve = pixivOAuthPending.resolve;
    const reject = pixivOAuthPending.reject;
    pixivOAuthPending = null;
    try {
      const tokenResp = await fetch(PIXIV_TOKEN_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
          client_id: PIXIV_CLIENT_ID,
          client_secret: PIXIV_CLIENT_SECRET,
          code,
          code_verifier: verifier,
          grant_type: 'authorization_code',
          include_policy: 'true',
          redirect_uri: PIXIV_REDIRECT_URI,
        }),
      });
      const body = await tokenResp.json();
      if (!body.refresh_token) {
        reject(new Error(`Pixiv token exchange failed: ${JSON.stringify(body)}`));
        return;
      }
      await savePixivToken(body.refresh_token);
      try { await browser.tabs.remove(details.tabId); } catch {}
      resolve(body.refresh_token);
    } catch (e) {
      reject(e);
    }
  },
  { urls: ['https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback*'] },
);

// Extract → verify → upload one cookie-auth platform. Returns a structured
// outcome so the two callers (EXPORT_COOKIES single, EXPORT_ALL_COOKIES) shape
// their own response + skip semantics. Verifies the captured cookies are
// actually live BEFORE uploading, so a confirmed-stale session doesn't overwrite
// good FC-side credentials; platforms with no verify config (v.ok === null) fall
// through to upload.
async function exportPlatformCookies(key) {
  const cookies = await extractCookiesForPlatform(key);
  if (cookies.length === 0) return { status: 'empty' };
  const v = await verifyCookiesForPlatform(key);
  if (v.ok === false) return { status: 'stale', reason: v.reason, cookieCount: cookies.length };
  await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
  return { status: 'ok', cookieCount: cookies.length, verified: v.ok === true };
}

// ---- Message router ----

browser.runtime.onMessage.addListener(async (msg) => {
  await ensureInitialized();
  switch (msg.type) {
    case 'GET_CONFIG':
      return { apiUrl: api.baseUrl, apiKey: api.apiKey };

    case 'TEST_CONNECTION':
      try {
        await api.testConnection();
        return { connected: true };
      } catch (e) {
        return { connected: false, error: e.message };
      }

    case 'GET_PLATFORM_STATUS': {
      const status = {};
      for (const key of Object.keys(PLATFORMS)) {
        if (PLATFORMS[key].authType === 'cookies') {
          status[key] = { hasCookies: false, cookieCount: 0 };
          try {
            const n = await getCookieCount(key);
            status[key] = { hasCookies: n > 0, cookieCount: n };
          } catch (e) {
            status[key] = { error: e.message };
          }
        } else if (key === 'discord') {
          status[key] = { hasToken: !!discordToken, capturedAt: discordTokenCapturedAt };
        } else if (key === 'pixiv') {
          status[key] = { hasToken: !!pixivRefreshToken, capturedAt: pixivTokenCapturedAt };
        } else {
          status[key] = {};
        }
      }
      return status;
    }

    case 'EXPORT_COOKIES': {
      const key = msg.platform;
      const platform = PLATFORMS[key];
      if (!platform) return { error: `Unknown platform: ${key}` };
      try {
        if (platform.authType === 'cookies') {
          const r = await exportPlatformCookies(key);
          if (r.status === 'empty') return { error: 'No cookies found — log in first.' };
          if (r.status === 'stale') {
            return {
              error: `Captured ${r.cookieCount} ${platform.name} cookies but they don't appear authenticated (${r.reason}). Log in again in this browser, then retry.`,
            };
          }
          return { success: true, cookieCount: r.cookieCount, verified: r.verified };
        }
        if (key === 'discord') {
          if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
          await api.uploadCredentials('discord', 'token', discordToken);
          return { success: true };
        }
        if (key === 'pixiv') {
          if (!pixivRefreshToken) {
            await initiatePixivOAuth();
          }
          await api.uploadCredentials('pixiv', 'token', pixivRefreshToken);
          return { success: true };
        }
        return { error: 'Unsupported platform.' };
      } catch (e) {
        return { error: e.message };
      }
    }

    case 'EXPORT_ALL_COOKIES': {
      const results = {};
      for (const key of Object.keys(PLATFORMS)) {
        if (PLATFORMS[key].authType !== 'cookies') {
          results[key] = { skipped: true };
          continue;
        }
        try {
          const r = await exportPlatformCookies(key);
          if (r.status === 'empty') results[key] = { skipped: true, reason: 'no cookies' };
          else if (r.status === 'stale') results[key] = { error: `verify failed: ${r.reason}` };
          else results[key] = { success: true, cookieCount: r.cookieCount, verified: r.verified };
        } catch (e) {
          results[key] = { error: e.message };
        }
      }
      return results;
    }

    case 'LIST_SOURCES':
      try {
        return { sources: await api.listSources() };
      } catch (e) {
        return { error: e.message };
      }

    case 'CHECK_SOURCE':
      try {
        return await api.triggerSourceCheck(msg.sourceId);
      } catch (e) {
        return { error: e.message };
      }

    case 'ADD_AS_SOURCE':
      try {
        return await api.quickAddSource(msg.url);
      } catch (e) {
        return { error: e.message };
      }

    case 'PROBE_SOURCE':
      try {
        return await api.probeSource(msg.url);
      } catch (e) {
        return { error: e.message };
      }

    case 'OPEN_ARTIST_PAGE': {
      // The SPA artist route (/artist/:slug) is served from the web root, not
      // the JSON API — see api.webRoot().
      const base = api.webRoot();
      const slug = encodeURIComponent(msg.slug || '');
      if (!base || !slug) return { error: 'apiUrl or slug missing' };
      try {
        await browser.tabs.create({ url: `${base}/artist/${slug}` });
        return { success: true };
      } catch (e) {
        return { error: e.message };
      }
    }

    case 'CHECK_UPDATE':
      return await checkForUpdateInfo();

    default:
      return { error: `Unknown message type: ${msg.type}` };
  }
});
