/**
 * Sender authorization for privileged extension messages.
 *
 * A content script runs in web-page context and can be influenced by page
 * content; a foreign extension is not us. Neither may read or spend the
 * browse server's auth token or port through background.js's message
 * surface. PR #1822 (@punksterlabs) found getPort handing the token to any
 * caller that passed the type allowlist; this suite pins the reimplemented
 * gate BEHAVIORALLY — it drives the real background.js onMessage listener
 * under a chrome stub with four sender shapes (own extension page, own
 * content script, foreign extension, url-less) and asserts denied responses
 * are { error: 'unauthorized' } with no token/port fields at all.
 */
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';

const EXT_DIR = path.join(import.meta.dir, '..', '..', 'extension');
const BG_SRC = fs.readFileSync(path.join(EXT_DIR, 'background.js'), 'utf-8');

// eslint-disable-next-line @typescript-eslint/no-require-imports
const senderAuth = require(path.join(EXT_DIR, 'sender-auth.js'));

// The pinned production id (derivable via browse/scripts/extension-id.ts) —
// the policy only compares it against sender.id, so any stable value works.
const OWN_ID = 'dgbkdbjebeiblbajiilljmhjdpmiglep';
const FOREIGN_ID = 'ffffffffffffffffffffffffffffffff';

// ─── The four sender shapes ─────────────────────────────────────
const PAGE_SENDER = { id: OWN_ID, url: `chrome-extension://${OWN_ID}/sidepanel.html` };
const CONTENT_SCRIPT_SENDER = { id: OWN_ID, url: 'https://evil.example/page', tab: { id: 42 } };
const FOREIGN_SENDER = { id: FOREIGN_ID, url: `chrome-extension://${FOREIGN_ID}/background.html` };
const NO_URL_SENDER = { id: OWN_ID };

// 'sidebar-command' is no longer a message type at all — the chat-queue path
// was ripped along with the /sidebar-command endpoint, so background.js now
// rejects it pre-gate as an unknown type (no response, nothing to leak). It is
// pinned separately below as a representative unknown type.
const PRIVILEGED = [
  'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs',
  'command', 'getTabState',
];
// Content-script-originated flows that must keep working.
const CONTENT_SCRIPT_TYPES = ['openSidePanel', 'elementPicked', 'pickerCancelled', 'inspectResult'];
// Sidepanel-originated, non-privileged (page effects only, no token/port).
const PAGE_EFFECT_TYPES = ['sidebarOpened', 'startInspector', 'stopInspector', 'applyStyle', 'toggleClass', 'injectCSS', 'resetAll'];

const LEAK_FIELDS = ['token', 'authToken', 'port', 'url', 'connected', 'tabs', 'active', 'ok'];

// ─── Unit: the policy predicate ─────────────────────────────────

describe('sender-auth policy (unit)', () => {
  test('own extension page is allowed for every privileged type', () => {
    for (const type of PRIVILEGED) {
      expect(senderAuth.denialFor(type, PAGE_SENDER, OWN_ID)).toBeNull();
    }
    expect(senderAuth.isExtensionPageSender(PAGE_SENDER, OWN_ID)).toBe(true);
  });

  test('own popup page is allowed (any own-extension page path)', () => {
    const popup = { id: OWN_ID, url: `chrome-extension://${OWN_ID}/popup.html` };
    expect(senderAuth.denialFor('getPort', popup, OWN_ID)).toBeNull();
  });

  test('own content script (sender.tab + page URL) is denied for every privileged type', () => {
    for (const type of PRIVILEGED) {
      const denial = senderAuth.denialFor(type, CONTENT_SCRIPT_SENDER, OWN_ID);
      expect(denial).toEqual({ error: 'unauthorized' });
      expect(Object.keys(denial)).toEqual(['error']);
    }
  });

  test('foreign extension id is denied for every privileged type', () => {
    for (const type of PRIVILEGED) {
      expect(senderAuth.denialFor(type, FOREIGN_SENDER, OWN_ID)).toEqual({ error: 'unauthorized' });
    }
  });

  test('missing sender.url is denied (no provenance)', () => {
    for (const type of PRIVILEGED) {
      expect(senderAuth.denialFor(type, NO_URL_SENDER, OWN_ID)).toEqual({ error: 'unauthorized' });
    }
    expect(senderAuth.denialFor('getToken', undefined, OWN_ID)).toEqual({ error: 'unauthorized' });
  });

  test('own extension page opened inside a TAB is denied (conservative: sender.tab wins)', () => {
    const pageInTab = { id: OWN_ID, url: `chrome-extension://${OWN_ID}/sidepanel.html`, tab: { id: 7 } };
    expect(senderAuth.denialFor('getToken', pageInTab, OWN_ID)).toEqual({ error: 'unauthorized' });
  });

  test('non-privileged types are never gated here — content-script flows stay reachable', () => {
    for (const type of [...CONTENT_SCRIPT_TYPES, ...PAGE_EFFECT_TYPES]) {
      expect(senderAuth.denialFor(type, CONTENT_SCRIPT_SENDER, OWN_ID)).toBeNull();
      expect(senderAuth.denialFor(type, PAGE_SENDER, OWN_ID)).toBeNull();
    }
  });
});

// ─── Behavioral: the real background.js listener ────────────────

type Listener = (msg: unknown, sender: unknown, sendResponse: (r: unknown) => void) => unknown;

function loadBackground() {
  const captured: { listener?: Listener } = {};
  const calls = { storageSet: [] as unknown[], fetch: [] as unknown[] };
  const never = new Promise(() => {}); // storage.get never settles → startup health polling never starts
  const chromeStub = {
    runtime: {
      id: OWN_ID,
      onMessage: { addListener: (fn: Listener) => { captured.listener = fn; } },
      onInstalled: { addListener: () => {} },
      sendMessage: () => Promise.resolve(),
    },
    storage: {
      local: {
        get: () => never,
        set: (obj: unknown) => { calls.storageSet.push(obj); return Promise.resolve(); },
      },
    },
    tabs: {
      onActivated: { addListener: () => {} },
      onCreated: { addListener: () => {} },
      onRemoved: { addListener: () => {} },
      onUpdated: { addListener: () => {} },
      query: (_opts: unknown, cb?: (tabs: unknown[]) => void) => {
        if (cb) { cb([]); return; }
        return Promise.resolve([]);
      },
      sendMessage: () => Promise.resolve(),
      get: () => {},
    },
    action: { setBadgeBackgroundColor: () => {}, setBadgeText: () => {} },
    scripting: { executeScript: () => Promise.resolve(), insertCSS: () => Promise.resolve() },
    // no chrome.sidePanel: autoOpenSidePanel exits immediately (no retry timers)
  };
  const fetchSpy = (...args: unknown[]) => {
    calls.fetch.push(args);
    return Promise.reject(new Error('no network in tests'));
  };
  // background.js is a classic (non-module) service worker script — evaluate
  // it with its globals injected. importScripts is satisfied by passing the
  // already-required sender-auth module under the global name it registers.
  const run = new Function('chrome', 'importScripts', 'gstackSenderAuth', 'fetch', BG_SRC);
  run(chromeStub, () => {}, senderAuth, fetchSpy);
  if (!captured.listener) throw new Error('background.js did not register an onMessage listener');
  return { listener: captured.listener, calls };
}

function dispatch(listener: Listener, msg: unknown, sender: unknown) {
  const result = { responded: false, response: undefined as Record<string, unknown> | undefined };
  listener(msg, sender, (resp: unknown) => {
    result.responded = true;
    result.response = resp as Record<string, unknown>;
  });
  return result;
}

// Denied senders get { error: 'unauthorized' } and nothing else — or no
// response at all (the pre-existing foreign-sender early return). Either
// way: never a token, port, or tab-state field.
function expectDenied(result: ReturnType<typeof dispatch>) {
  if (result.responded) {
    expect(result.response).toEqual({ error: 'unauthorized' });
    expect(Object.keys(result.response!)).toEqual(['error']);
  }
  const resp = result.response ?? {};
  for (const leak of LEAK_FIELDS) {
    expect(resp[leak]).toBeUndefined();
  }
}

describe('background.js onMessage listener (behavioral)', () => {
  const { listener, calls } = loadBackground();

  test('own sidepanel page: getPort responds with port/connected/token fields, no error', () => {
    const r = dispatch(listener, { type: 'getPort' }, PAGE_SENDER);
    expect(r.responded).toBe(true);
    expect('port' in r.response!).toBe(true);
    expect('connected' in r.response!).toBe(true);
    // The sidepanel's tryConnect reads resp.token — the field must exist for
    // extension pages (value is null until the token bootstrap completes).
    expect('token' in r.response!).toBe(true);
    expect(r.response!.error).toBeUndefined();
  });

  test('own sidepanel page: getToken responds with a token field', () => {
    const r = dispatch(listener, { type: 'getToken' }, PAGE_SENDER);
    expect(r.responded).toBe(true);
    expect('token' in r.response!).toBe(true);
    expect(r.response!.error).toBeUndefined();
  });

  test('own content script: every privileged type is denied with no token/port fields', () => {
    for (const type of PRIVILEGED) {
      const r = dispatch(listener, { type }, CONTENT_SCRIPT_SENDER);
      expect(r.responded).toBe(true); // the gate answers, it does not go silent
      expectDenied(r);
    }
  });

  test('foreign extension: every privileged type yields no token/port fields', () => {
    for (const type of PRIVILEGED) {
      expectDenied(dispatch(listener, { type }, FOREIGN_SENDER));
    }
  });

  test('missing sender.url: every privileged type is denied', () => {
    for (const type of PRIVILEGED) {
      const r = dispatch(listener, { type }, NO_URL_SENDER);
      expect(r.responded).toBe(true);
      expectDenied(r);
    }
  });

  test('retired sidebar-command type is rejected pre-gate with no response and no leaks', () => {
    // Even from the most-trusted sender shape, a type outside ALLOWED_TYPES
    // never reaches a handler: no sendResponse, no token/port fields possible.
    for (const sender of [PAGE_SENDER, CONTENT_SCRIPT_SENDER, FOREIGN_SENDER, NO_URL_SENDER]) {
      const r = dispatch(listener, { type: 'sidebar-command', message: 'hi' }, sender);
      expect(r.responded).toBe(false);
      expectDenied(r);
    }
  });

  test('denied setPort never persists the attacker port', () => {
    const before = calls.storageSet.length;
    const r = dispatch(listener, { type: 'setPort', port: 6666 }, CONTENT_SCRIPT_SENDER);
    expectDenied(r);
    expect(calls.storageSet.length).toBe(before);
  });

  test('denied command never reaches the network and fails at the gate, not the handler', () => {
    const before = calls.fetch.length;
    const r = dispatch(listener, { type: 'command', command: 'goto', args: ['https://evil.example'] }, CONTENT_SCRIPT_SENDER);
    // 'unauthorized' proves the gate fired; the handler's own failure mode is
    // 'Not connected to browse server'.
    expect(r.response).toEqual({ error: 'unauthorized' });
    expect(calls.fetch.length).toBe(before);
  });

  test('content script can still run the inspector flow (elementPicked → ok)', async () => {
    const r = dispatch(
      listener,
      { type: 'elementPicked', selector: '#hero', tagName: 'div', classes: [], id: null, dimensions: { width: 1, height: 1 } },
      CONTENT_SCRIPT_SENDER,
    );
    await new Promise((res) => setTimeout(res, 10));
    expect(r.response).toEqual({ ok: true });
  });

  test('content script can still request openSidePanel (not rejected as unauthorized)', () => {
    const r = dispatch(listener, { type: 'openSidePanel' }, CONTENT_SCRIPT_SENDER);
    // chrome.sidePanel is absent in the stub so the handler is a no-op — the
    // load-bearing assertion is that the gate did not deny it.
    expect(r.response?.error).toBeUndefined();
  });

  test('sidepanel getTabState still works (terminal pane tab sync)', async () => {
    const r = dispatch(listener, { type: 'getTabState' }, PAGE_SENDER);
    await new Promise((res) => setTimeout(res, 10));
    expect(r.responded).toBe(true);
    expect(r.response).toEqual({ active: null, tabs: [] });
  });
});

// ─── Wiring tripwire ────────────────────────────────────────────
// The behavioral suite injects senderAuth directly, so pin that the real
// worker actually loads it: importScripts of the helper file plus a
// denialFor call in the listener. A refactor that drops either fails here.

describe('background.js ↔ sender-auth.js wiring', () => {
  test('background.js importScripts sender-auth.js (classic worker load path)', () => {
    expect(BG_SRC).toContain("importScripts('sender-auth.js')");
  });

  test('background.js consults gstackSenderAuth.denialFor in the message listener', () => {
    expect(BG_SRC).toContain('gstackSenderAuth.denialFor(msg.type, sender, chrome.runtime.id)');
  });

  test('manifest keeps a classic (non-module) service worker — importScripts requires it', () => {
    const manifest = JSON.parse(fs.readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf-8'));
    expect(manifest.background.service_worker).toBe('background.js');
    expect(manifest.background.type).toBeUndefined();
  });
});
