import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');

function eventBus() {
  const listeners = [];
  return {
    addListener(listener) { listeners.push(listener); },
    removeListener(listener) {
      const index = listeners.indexOf(listener);
      if (index >= 0) listeners.splice(index, 1);
    },
    async emit(...args) {
      const output = [];
      for (const listener of [...listeners]) output.push(await listener(...args));
      return output;
    },
    listeners
  };
}

const localStorageData = {
  settings: {
    enabled: true,
    cosmeticFiltering: true,
    showBadge: true,
    automaticUpdateChecks: true,
    lists: { easylist: true, easyprivacy: true }
  },
  allowlist: [],
  customBlocklist: [],
  diagnostics: {},
  upstreamMetadata: {}
};
const sessionStorageData = {};
const dynamicRules = [];
const sessionRules = [];
const registeredScripts = new Map();
const alarms = new Map();
const executeCalls = [];
const tabMessages = [];
const accessLevels = [];

const onInstalled = eventBus();
const onStartup = eventBus();
const onMessage = eventBus();
const onAlarm = eventBus();
const onChanged = eventBus();
const onRemoved = eventBus();
const onUpdated = eventBus();

function selectKeys(source, keys) {
  if (keys == null) return { ...source };
  if (typeof keys === 'string') return { [keys]: source[keys] };
  if (Array.isArray(keys)) return Object.fromEntries(keys.map(key => [key, source[key]]));
  return Object.fromEntries(Object.keys(keys).map(key => [key, source[key] ?? keys[key]]));
}

function updateRules(target, { removeRuleIds = [], addRules = [] }) {
  const remove = new Set(removeRuleIds);
  for (let index = target.length - 1; index >= 0; index -= 1) {
    if (remove.has(target[index].id)) target.splice(index, 1);
  }
  const ids = new Set(target.map(rule => rule.id));
  for (const rule of addRules) {
    if (ids.has(rule.id)) throw new Error(`duplicate rule ${rule.id}`);
    target.push(structuredClone(rule));
    ids.add(rule.id);
  }
}

const tabs = [{ id: 11, url: 'https://example.com/', title: 'Example' }];

const chromeMock = {
  runtime: {
    id: 'grax-test-extension',
    onInstalled,
    onStartup,
    onMessage,
    getURL(relative = '') { return `chrome-extension://grax-test-extension/${relative}`; },
    async openOptionsPage() {},
    async requestUpdateCheck() { return { status: 'no_update' }; }
  },
  storage: {
    local: {
      async get(keys) { return selectKeys(localStorageData, keys); },
      async set(values) { Object.assign(localStorageData, values); },
      async setAccessLevel(options) { accessLevels.push(options.accessLevel); }
    },
    session: {
      async get(keys) { return selectKeys(sessionStorageData, keys); },
      async set(values) { Object.assign(sessionStorageData, values); }
    },
    onChanged
  },
  alarms: {
    onAlarm,
    async get(name) { return alarms.get(name) || null; },
    async create(name, info) { alarms.set(name, { name, ...info }); },
    async clear(name) { return alarms.delete(name); }
  },
  declarativeNetRequest: {
    MAX_NUMBER_OF_DYNAMIC_RULES: 30_000,
    async getDynamicRules() { return dynamicRules.map(rule => structuredClone(rule)); },
    async updateDynamicRules(options) { updateRules(dynamicRules, options); },
    async getSessionRules() { return sessionRules.map(rule => structuredClone(rule)); },
    async updateSessionRules(options) { updateRules(sessionRules, options); },
    async setExtensionActionOptions() {}
  },
  scripting: {
    async getRegisteredContentScripts({ ids } = {}) {
      const selected = ids
        ? ids.map(id => registeredScripts.get(id)).filter(Boolean)
        : [...registeredScripts.values()];
      return selected.map(item => structuredClone(item));
    },
    async registerContentScripts(items) {
      for (const item of items) registeredScripts.set(item.id, structuredClone(item));
    },
    async unregisterContentScripts({ ids }) {
      for (const id of ids) registeredScripts.delete(id);
    },
    async executeScript(details) {
      executeCalls.push(structuredClone(details));
      return [];
    }
  },
  tabs: {
    onRemoved,
    onUpdated,
    async query(query = {}) {
      if (query.url) return tabs.map(tab => ({ ...tab }));
      return tabs.map(tab => ({ ...tab }));
    },
    async sendMessage(tabId, message) {
      tabMessages.push({ tabId, message: structuredClone(message) });
    }
  },
  action: {
    async setBadgeBackgroundColor() {},
    async setBadgeText() {},
    async setTitle() {}
  }
};

globalThis.chrome = chromeMock;
globalThis.fetch = async url => {
  const raw = String(url);
  const prefix = 'chrome-extension://grax-test-extension/';
  if (!raw.startsWith(prefix)) throw new Error(`unexpected fetch ${raw}`);
  const relative = raw.slice(prefix.length);
  const buffer = await readFile(path.join(ROOT, relative));
  return {
    ok: true,
    status: 200,
    headers: new Map(),
    async json() { return JSON.parse(buffer.toString('utf8')); },
    async text() { return buffer.toString('utf8'); }
  };
};

await import(`${pathToFileURL(path.join(ROOT, 'background.js')).href}?test=${Date.now()}`);

async function waitFor(predicate, label, timeoutMs = 15_000) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    if (predicate()) return;
    await new Promise(resolve => setTimeout(resolve, 10));
  }
  throw new Error(`timed out waiting for ${label}`);
}

async function triggerMessage(message) {
  const listener = onMessage.listeners[0];
  if (!listener) throw new Error('message listener missing');
  return await new Promise((resolve, reject) => {
    let resolved = false;
    const sender = {
      id: chromeMock.runtime.id,
      url: chromeMock.runtime.getURL('popup.html'),
      tab: { id: 99, url: chromeMock.runtime.getURL('popup.html') }
    };
    const keepAlive = listener(message, sender, response => {
      resolved = true;
      if (!response?.ok) reject(new Error(response?.error?.message || 'message failed'));
      else resolve(response.result);
    });
    if (keepAlive !== true && !resolved) reject(new Error('listener did not keep channel alive'));
    setTimeout(() => {
      if (!resolved) reject(new Error(`message timed out: ${message.type}`));
    }, 10_000).unref?.();
  });
}

await onInstalled.emit({ reason: 'update' });
await waitFor(() => registeredScripts.size === 3 && dynamicRules.length === 6942, 'initial lifecycle');

const compatibilityRules = dynamicRules.filter(rule => rule.id >= 8_000_000 && rule.id <= 8_999_999);
const firstPartyStructuralAllow = compatibilityRules.find(rule =>
  rule.action?.type === 'allow' &&
  rule.condition?.domainType === 'firstParty' &&
  rule.condition?.resourceTypes?.includes('script') &&
  rule.condition?.resourceTypes?.includes('xmlhttprequest') &&
  rule.condition?.excludedInitiatorDomains?.includes('filma24.stream')
);
const firstPartyTelemetryRedirect = compatibilityRules.find(rule =>
  rule.action?.type === 'redirect' &&
  rule.condition?.domainType === 'firstParty' &&
  rule.condition?.urlFilter === '/api/telemetry/collect' &&
  rule.condition?.resourceTypes?.includes('xmlhttprequest') &&
  rule.action?.redirect?.extensionPath === '/mocks/success.json'
);
const firstPartyAdRedirect = compatibilityRules.find(rule =>
  rule.action?.type === 'redirect' &&
  rule.condition?.domainType === 'firstParty' &&
  rule.condition?.urlFilter === '/prebid.js' &&
  rule.condition?.resourceTypes?.includes('script') &&
  rule.action?.redirect?.extensionPath === '/mocks/telemetry.js'
);
const cspHeaderMutation = compatibilityRules.some(rule =>
  rule.action?.type === 'modifyHeaders' &&
  rule.action?.responseHeaders?.some(header =>
    String(header?.header || '').toLowerCase().includes('content-security-policy')
  )
);

const grokEntityAllow = compatibilityRules.find(rule =>
  rule.action?.type === 'allow' &&
  rule.priority === 975_000 &&
  ['grok.com', 'x.com', 'twitter.com', 'twimg.com'].every(domain =>
    rule.condition?.initiatorDomains?.includes(domain) &&
    rule.condition?.requestDomains?.includes(domain)
  ) &&
  rule.condition?.resourceTypes?.includes('script') &&
  rule.condition?.resourceTypes?.includes('xmlhttprequest') &&
  rule.condition?.resourceTypes?.includes('websocket')
);

const claudeEntityAllow = compatibilityRules.find(rule =>
  rule.action?.type === 'allow' &&
  rule.priority === 976_000 &&
  ['claude.ai', 'anthropic.com'].every(domain =>
    rule.condition?.initiatorDomains?.includes(domain) &&
    rule.condition?.requestDomains?.includes(domain)
  ) &&
  rule.condition?.resourceTypes?.includes('script') &&
  rule.condition?.resourceTypes?.includes('xmlhttprequest') &&
  rule.condition?.resourceTypes?.includes('websocket')
);

const deepseekEntityAllow = compatibilityRules.find(rule =>
  rule.action?.type === 'allow' &&
  rule.priority === 977_000 &&
  ['deepseek.com', 'chat.deepseek.com', 'api.deepseek.com', 'static.deepseek.com', 'cdn.deepseek.com'].every(domain =>
    rule.condition?.initiatorDomains?.includes(domain) &&
    rule.condition?.requestDomains?.includes(domain)
  ) &&
  rule.condition?.resourceTypes?.includes('script') &&
  rule.condition?.resourceTypes?.includes('xmlhttprequest') &&
  rule.condition?.resourceTypes?.includes('websocket')
);

const initial = {
  registered: registeredScripts.size,
  rules: dynamicRules.length,
  compatibilityRules: compatibilityRules.length,
  redirects: compatibilityRules.filter(rule => rule.action?.type === 'redirect').length,
  firstPartyStructuralAllow: Boolean(firstPartyStructuralAllow),
  firstPartyTelemetryRedirect: Boolean(firstPartyTelemetryRedirect),
  firstPartyAdRedirect: Boolean(firstPartyAdRedirect),
  filma24Excluded: compatibilityRules
    .filter(rule => rule.condition?.domainType === 'firstParty' && rule.id >= 8_000_000)
    .every(rule => !rule.condition?.excludedInitiatorDomains || rule.condition.excludedInitiatorDomains.includes('filma24.stream')),
  cspHeaderMutation,
  grokEntityAllow: Boolean(grokEntityAllow),
  claudeEntityAllow: Boolean(claudeEntityAllow),
  deepseekEntityAllow: Boolean(deepseekEntityAllow),
  compatibilityExcludesClaude: registeredScripts.get('grax-main-compatibility-shim')?.excludeMatches?.includes('*://claude.ai/*') === true,
  nativeAppsExcludedFromDomGuards: registeredScripts.get('grax-main-page-guard')?.excludeMatches?.includes('*://*.deepseek.com/*') === true && registeredScripts.get('grax-isolated-content-engine')?.excludeMatches?.includes('*://*.claude.ai/*') === true,
  alarm: alarms.has('grax-list-update-check'),
  accessLevel: accessLevels.at(-1),
  injections: executeCalls.length,
  compatibilityRegistered: registeredScripts.get('grax-main-compatibility-shim')?.world === 'MAIN'
};

await triggerMessage({
  type: 'GRAX_SET_SITE_ENABLED',
  hostname: 'example.com',
  tabId: 11,
  enabled: false
});
const sitePaused = {
  allowlisted: localStorageData.allowlist.includes('example.com'),
  sessionRules: sessionRules.length,
  offMessage: tabMessages.some(item => item.tabId === 11 && item.message?.type === 'GRAX_SITE_STATE_CHANGED' && item.message.enabled === false),
  dynamicRules: dynamicRules.length
};

await triggerMessage({
  type: 'GRAX_SET_SITE_ENABLED',
  hostname: 'example.com',
  tabId: 11,
  enabled: true
});
const siteResumed = {
  allowlisted: localStorageData.allowlist.includes('example.com'),
  sessionRules: sessionRules.length,
  onMessage: tabMessages.some(item => item.tabId === 11 && item.message?.type === 'GRAX_SITE_STATE_CHANGED' && item.message.enabled === true),
  dynamicRules: dynamicRules.length
};

await triggerMessage({ type: 'GRAX_SET_ENABLED', enabled: false });
const paused = {
  registered: registeredScripts.size,
  rules: dynamicRules.length,
  alarm: alarms.has('grax-list-update-check'),
  settingsEnabled: localStorageData.settings.enabled,
  offBroadcast: tabMessages.some(item => item.message?.type === 'GRAX_PROTECTION_STATE_CHANGED' && item.message.enabled === false)
};

const injectionsBeforeResume = executeCalls.length;
await triggerMessage({ type: 'GRAX_SET_ENABLED', enabled: true });
await waitFor(() => registeredScripts.size === 3 && dynamicRules.length === 6942, 'resumed lifecycle');
const resumed = {
  registered: registeredScripts.size,
  rules: dynamicRules.length,
  alarm: alarms.has('grax-list-update-check'),
  settingsEnabled: localStorageData.settings.enabled,
  newInjections: executeCalls.length - injectionsBeforeResume,
  onBroadcast: tabMessages.some(item => item.message?.type === 'GRAX_PROTECTION_STATE_CHANGED' && item.message.enabled === true),
  compatibilityWorldRegistered: registeredScripts.get('grax-main-compatibility-shim')?.world === 'MAIN',
  mainWorldRegistered: registeredScripts.get('grax-main-page-guard')?.world === 'MAIN',
  isolatedWorldRegistered: registeredScripts.get('grax-isolated-content-engine')?.world === 'ISOLATED'
};

const failures = [];
if (initial.registered !== 3) failures.push('initial registered scripts');
if (initial.rules !== 6942) failures.push(`initial rules ${initial.rules}`);
if (initial.compatibilityRules !== 194) failures.push(`compatibility rules ${initial.compatibilityRules}`);
if (initial.redirects !== 139) failures.push(`compatibility redirects ${initial.redirects}`);
if (!initial.alarm) failures.push('initial alarm');
if (initial.accessLevel !== 'TRUSTED_AND_UNTRUSTED_CONTEXTS') failures.push('content storage access');
if (initial.injections < 3) failures.push('initial injection');
if (!initial.compatibilityRegistered) failures.push('compatibility shim registration');
if (!initial.firstPartyStructuralAllow) failures.push('first-party structural allow');
if (!initial.firstPartyTelemetryRedirect) failures.push('first-party telemetry redirect');
if (!initial.firstPartyAdRedirect) failures.push('first-party ad redirect');
if (!initial.filma24Excluded) failures.push('filma24 compatibility exclusion');
if (initial.cspHeaderMutation) failures.push('unexpected CSP header mutation');
if (!initial.grokEntityAllow) failures.push('grok trusted entity allow');
if (!initial.claudeEntityAllow) failures.push('claude trusted entity allow');
if (!initial.deepseekEntityAllow) failures.push('deepseek trusted entity allow');
if (!initial.compatibilityExcludesClaude) failures.push('claude compatibility exclusion');
if (!initial.nativeAppsExcludedFromDomGuards) failures.push('trusted app DOM guard exclusions');
if (!sitePaused.allowlisted) failures.push('site allowlist persistence');
if (sitePaused.sessionRules !== 2) failures.push(`site session rules ${sitePaused.sessionRules}`);
if (!sitePaused.offMessage) failures.push('immediate site-off message');
if (sitePaused.dynamicRules !== 6944) failures.push(`site paused dynamic rules ${sitePaused.dynamicRules}`);
if (siteResumed.allowlisted) failures.push('site allowlist removal');
if (siteResumed.sessionRules !== 0) failures.push('site session rules not cleared');
if (!siteResumed.onMessage) failures.push('immediate site-on message');
if (siteResumed.dynamicRules !== 6942) failures.push(`site resumed dynamic rules ${siteResumed.dynamicRules}`);
if (paused.registered !== 0) failures.push('paused scripts still registered');
if (paused.rules !== 0) failures.push(`paused rules ${paused.rules}`);
if (paused.alarm) failures.push('paused alarm still active');
if (paused.settingsEnabled !== false) failures.push('paused setting');
if (!paused.offBroadcast) failures.push('off broadcast');
if (resumed.registered !== 3) failures.push('resumed scripts');
if (resumed.rules !== 6942) failures.push(`resumed rules ${resumed.rules}`);
if (!resumed.alarm) failures.push('resumed alarm');
if (resumed.settingsEnabled !== true) failures.push('resumed setting');
if (resumed.newInjections < 3) failures.push('resumed reinjection');
if (!resumed.onBroadcast) failures.push('on broadcast');
if (!resumed.compatibilityWorldRegistered || !resumed.mainWorldRegistered || !resumed.isolatedWorldRegistered) failures.push('world registrations');

if (failures.length) {
  throw new Error(`background lifecycle failures: ${failures.join(', ')}\n${JSON.stringify({ initial, sitePaused, siteResumed, paused, resumed }, null, 2)}`);
}

console.log(JSON.stringify({ initial, sitePaused, siteResumed, paused, resumed }, null, 2));
