import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// --- Mocks (must be declared before `route` is imported so the router's
// `import * as vault` / `import * as session` resolve to these doubles) ---

// Partial mock: we override only the vault calls the new tests care about
// (fetchAndDecryptItem / fetchAndDecryptSettings / encryptAndWriteSettings)
// and let the real implementations of listItems / findByHostname / etc.
// continue to run for the other tests that don't need mocks.
vi.mock('../../vault', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../../vault')>();
  return {
    ...actual,
    fetchAndDecryptItem: vi.fn(),
    fetchAndDecryptSettings: vi.fn(),
    encryptAndWriteSettings: vi.fn(),
    encryptAndWriteItem: vi.fn(),
    encryptAndWriteManifest: vi.fn(),
  };
});

vi.mock('../../session', () => ({
  setCurrent: vi.fn(),
  getCurrent: vi.fn(),
  clearCurrent: vi.fn(),
  requireCurrent: vi.fn(),
}));

vi.mock('../../devices', () => ({
  readDevices: vi.fn(),
  writeDevices: vi.fn(),
  addDevice: vi.fn().mockResolvedValue(undefined),
  revokeDevice: vi.fn(),
}));

import { route, type RouterState } from '../index';
import type { Request } from '../../../shared/messages';
import type { Item } from '../../../shared/types';
import * as vault from '../../vault';
import * as session from '../../session';
import * as devices from '../../devices';

// --- chrome.* shim ---

// @ts-expect-error test harness
globalThis.chrome = {
  runtime: {
    id: 'relicario-test-id',
    getURL: (p: string) => `chrome-extension://relicario-test-id/${p}`,
  },
  storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn().mockResolvedValue(undefined) } },
  tabs: { get: vi.fn(), sendMessage: vi.fn() },
};

function makePopupSender(): chrome.runtime.MessageSender {
  return { url: `chrome-extension://relicario-test-id/popup.html`, id: 'relicario-test-id' };
}

function makeSetupSender(): chrome.runtime.MessageSender {
  return { url: `chrome-extension://relicario-test-id/setup.html`, id: 'relicario-test-id' };
}

function makeContentSender(pageUrl = 'https://example.com/'): chrome.runtime.MessageSender {
  return {
    tab: { id: 42, url: pageUrl } as chrome.tabs.Tab,
    frameId: 0,
    id: 'relicario-test-id',
  };
}

function makeVaultSender(): chrome.runtime.MessageSender {
  return { url: `chrome-extension://relicario-test-id/vault.html`, id: 'relicario-test-id' };
}

function makeExternalSender(): chrome.runtime.MessageSender {
  return { url: 'https://evil.example/', id: 'some-other-extension' };
}

function makeState(): RouterState {
  return {
    manifest: { schema_version: 2, items: {} },
    gitHost: null,
    wasm: {
      // Stubs sufficient for the message types exercised by tests:
      new_item_id: () => 'fakeitemid0000ab',
      generate_password: () => 'PASSWORD',
      rate_passphrase: () => ({ score: 4, guesses_log10: 15 }),
    },
  };
}

// --- Sender-check matrix ---

describe('router sender dispatch', () => {
  let state: RouterState;
  beforeEach(() => { state = makeState(); });

  const popupOnlyMsgs: Request[] = [
    { type: 'is_unlocked' },
    { type: 'lock' },
    { type: 'list_items' },
    { type: 'generate_password', request: { kind: 'random', length: 20, classes: { lower: true, upper: true, digits: true, symbols: true }, symbol_charset: { kind: 'safe_only' } } },
    { type: 'rate_passphrase', passphrase: 'hunter2hunter2hunter2' },
    { type: 'get_blacklist' },
  ];

  for (const msg of popupOnlyMsgs) {
    it(`accepts popup-only "${msg.type}" from popup`, async () => {
      const res = await route(msg, state, makePopupSender());
      expect(res).toMatchObject({ ok: true });
    });
    it(`accepts popup-only "${msg.type}" from vault tab`, async () => {
      const res = await route(msg, state, makeVaultSender());
      expect(res).toMatchObject({ ok: true });
    });
    it(`rejects popup-only "${msg.type}" from content`, async () => {
      const res = await route(msg, state, makeContentSender());
      expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
    });
    it(`rejects popup-only "${msg.type}" from external`, async () => {
      const res = await route(msg, state, makeExternalSender());
      expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
    });
  }

  it('accepts save_setup from popup', async () => {
    const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' };
    const res = await route(msg, state, makePopupSender());
    expect(res).toMatchObject({ ok: true });
  });

  it('accepts save_setup from setup tab', async () => {
    const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' };
    const res = await route(msg, state, makeSetupSender());
    expect(res).toMatchObject({ ok: true });
  });

  it('rejects save_setup from content', async () => {
    const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' };
    const res = await route(msg, state, makeContentSender());
    expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  const contentMsgs: Request[] = [
    { type: 'get_autofill_candidates' },
    { type: 'blacklist_site' },
  ];

  for (const msg of contentMsgs) {
    it(`accepts content "${msg.type}" from top-frame content`, async () => {
      const res = await route(msg, state, makeContentSender());
      expect(res.ok).toBe(true);
    });
    it(`rejects content "${msg.type}" from popup`, async () => {
      const res = await route(msg, state, makePopupSender());
      expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
    });
    it(`rejects content "${msg.type}" from subframe`, async () => {
      const sender: chrome.runtime.MessageSender = { ...makeContentSender(), frameId: 3 };
      const res = await route(msg, state, sender);
      expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
    });
    it(`rejects content "${msg.type}" from external`, async () => {
      const res = await route(msg, state, makeExternalSender());
      expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
    });
  }

  it('rejects unknown message type', async () => {
    // @ts-expect-error intentional invalid type
    const res = await route({ type: 'nonsense' }, state, makePopupSender());
    expect(res).toEqual({ ok: false, error: 'unknown_message_type' });
  });
});

// --- Origin-bound autofill ---

describe('get_autofill_candidates uses sender.tab.url', () => {
  it('derives hostname from sender, not message', async () => {
    const state: RouterState = makeState();
    state.manifest = {
      schema_version: 2,
      items: {
        'aaaaaaaaaaaaaaaa': {
          id: 'aaaaaaaaaaaaaaaa', type: 'login', title: 'GitHub',
          tags: [], favorite: false, icon_hint: 'github.com',
          modified: 0, attachment_summaries: [],
        },
        'bbbbbbbbbbbbbbbb': {
          id: 'bbbbbbbbbbbbbbbb', type: 'login', title: 'Example',
          tags: [], favorite: false, icon_hint: 'example.com',
          modified: 0, attachment_summaries: [],
        },
      },
    };
    const res = await route(
      { type: 'get_autofill_candidates' },
      state,
      makeContentSender('https://example.com/login'),
    );
    expect(res.ok).toBe(true);
    if (res.ok) {
      const data = res.data as { candidates: Array<[string, { title: string }]> };
      expect(data.candidates).toHaveLength(1);
      expect(data.candidates[0][1].title).toBe('Example');
    }
  });
});

// --- fill_credentials TOCTOU + origin verification ---

describe('fill_credentials captured-tab verification', () => {
  const FAKE_ITEM_ID = 'cccccccccccccccc';

  function loginItem(url: string): Item {
    return {
      id: FAKE_ITEM_ID,
      title: 'Example',
      type: 'login',
      tags: [],
      favorite: false,
      created: 0,
      modified: 0,
      core: { type: 'login', username: 'alice', password: 'hunter2', url },
      sections: [],
      attachments: [],
      field_history: {},
    };
  }

  function primeUnlocked(state: RouterState): void {
    // Provide a fake handle + githost so the handler's "vault_locked" guard
    // passes — values don't matter because vault is mocked.
    vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never);
    state.gitHost = {} as never;
  }

  beforeEach(() => {
    vi.mocked(session.getCurrent).mockReset();
    vi.mocked(vault.fetchAndDecryptItem).mockReset();
    (chrome.tabs.get as ReturnType<typeof vi.fn>).mockReset();
    (chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).mockReset();
  });

  it('returns tab_navigated when captured tab hostname differs from current', async () => {
    const state = makeState();
    primeUnlocked(state);
    // chrome.tabs.get returns a tab that has navigated to a DIFFERENT host.
    (chrome.tabs.get as ReturnType<typeof vi.fn>).mockResolvedValue({
      id: 42,
      url: 'https://evil.example/landing',
    });

    const res = await route(
      {
        type: 'fill_credentials',
        id: FAKE_ITEM_ID,
        capturedTabId: 42,
        capturedUrl: 'https://example.com/login',
      },
      state,
      makePopupSender(),
    );
    expect(res).toEqual({ ok: false, error: 'tab_navigated' });
    // We must NOT have attempted to deliver credentials.
    expect(chrome.tabs.sendMessage).not.toHaveBeenCalled();
  });

  it('returns origin_mismatch when item hostname differs from current tab', async () => {
    const state = makeState();
    primeUnlocked(state);
    // Tab is still on example.com (matches capturedUrl) …
    (chrome.tabs.get as ReturnType<typeof vi.fn>).mockResolvedValue({
      id: 42,
      url: 'https://example.com/login',
    });
    // … but the item we'd fill belongs to github.com.
    vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(
      loginItem('https://github.com/login'),
    );

    const res = await route(
      {
        type: 'fill_credentials',
        id: FAKE_ITEM_ID,
        capturedTabId: 42,
        capturedUrl: 'https://example.com/login',
      },
      state,
      makePopupSender(),
    );
    expect(res).toEqual({ ok: false, error: 'origin_mismatch' });
    expect(chrome.tabs.sendMessage).not.toHaveBeenCalled();
  });

  it('forwards fill_credentials with expectedHost when all checks pass', async () => {
    const state = makeState();
    primeUnlocked(state);
    (chrome.tabs.get as ReturnType<typeof vi.fn>).mockResolvedValue({
      id: 42,
      url: 'https://example.com/login',
    });
    vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(
      loginItem('https://example.com/login'),
    );
    (chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true });

    const res = await route(
      {
        type: 'fill_credentials',
        id: FAKE_ITEM_ID,
        capturedTabId: 42,
        capturedUrl: 'https://example.com/login',
      },
      state,
      makePopupSender(),
    );
    expect(res).toEqual({ ok: true });
    expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(42, {
      type: 'fill_credentials',
      username: 'alice',
      password: 'hunter2',
      expectedHost: 'example.com',
    });
  });
});

// --- setup-tab exception scope ---
//
// Setup is allowed the subset of popup-only messages the wizard actually
// sends:
//   - save_setup           (final wire-up)
//   - rate_passphrase       (zxcvbn meter during passphrase entry)
//   - is_unlocked           (step-4 extension detection)
//   - create_vault          ("name this device" → create)
//   - attach_vault          ("name this device" → attach)
//   - generate_recovery_qr  (done-step recovery-QR banner)
// Everything else popup-only must be rejected from setup.

describe('setup tab exception scope', () => {
  it('accepts rate_passphrase from the setup tab (zxcvbn meter)', async () => {
    const state = makeState();
    const res = await route(
      { type: 'rate_passphrase', passphrase: 'correct horse battery staple parapet' },
      state,
      makeSetupSender(),
    );
    expect(res).toMatchObject({ ok: true });
  });

  it('accepts is_unlocked from the setup tab (step-4 detection)', async () => {
    const state = makeState();
    const res = await route({ type: 'is_unlocked' }, state, makeSetupSender());
    expect(res).toMatchObject({ ok: true });
  });

  it('accepts create_vault from the setup tab (reaches handler, not rejected)', async () => {
    const state = makeState();
    const res = await route(
      {
        type: 'create_vault',
        config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' },
        passphrase: 'correct horse battery staple parapet',
        carrierImageBytes: new ArrayBuffer(8),
        deviceName: 'Chrome on Linux',
      },
      state,
      makeSetupSender(),
    );
    expect(res).not.toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  it('accepts attach_vault from the setup tab (reaches handler, not rejected)', async () => {
    const state = makeState();
    const res = await route(
      {
        type: 'attach_vault',
        config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' },
        passphrase: 'correct horse battery staple parapet',
        referenceImageBytes: new ArrayBuffer(8),
        deviceName: 'Chrome on Linux',
      },
      state,
      makeSetupSender(),
    );
    expect(res).not.toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  it('accepts generate_recovery_qr from the setup tab (done-step banner)', async () => {
    const state = makeState();
    const res = await route(
      { type: 'generate_recovery_qr' } as Request,
      state,
      makeSetupSender(),
    );
    expect(res).not.toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  it('rejects fill_credentials from the setup tab (outside the allowlist)', async () => {
    const state = makeState();
    const res = await route(
      {
        type: 'fill_credentials',
        id: 'cccccccccccccccc',
        capturedTabId: 42,
        capturedUrl: 'https://example.com/',
      },
      state,
      makeSetupSender(),
    );
    expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  it('rejects unlock from the setup tab (outside the allowlist)', async () => {
    const state = makeState();
    const res = await route(
      { type: 'unlock', passphrase: 'hunter2' },
      state,
      makeSetupSender(),
    );
    expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
  });
});

// --- register_this_device: wasm returns a JS object, not a JSON string ---
//
// The #[wasm_bindgen] binding for `register_device` uses `serde-wasm-bindgen`
// and returns a plain JsValue (object), not a JSON string. Calling
// JSON.parse on it would throw `SyntaxError: "[object Object]" is not
// valid JSON`. This regression test pins that contract.

describe('register_this_device', () => {
  it('treats register_device() return value as an object, not a JSON string', async () => {
    const state = makeState();
    state.gitHost = {} as never;
    state.wasm.register_device = () => ({
      signing_public_key: 'aa'.repeat(32),
      deploy_public_key: 'bb'.repeat(32),
    });

    vi.mocked(devices.addDevice).mockClear();

    const res = await route(
      { type: 'register_this_device', name: 'Test Browser' },
      state,
      makePopupSender(),
    );

    expect(res).toEqual({ ok: true });
    expect(devices.addDevice).toHaveBeenCalledWith(
      expect.anything(),
      expect.objectContaining({ name: 'Test Browser', public_key: 'aa'.repeat(32) }),
    );
  });
});

// --- isContent rejects unknown sender.id ---

describe('isContent sender.id guard', () => {
  it('rejects content-shaped sender whose id is not the extension id', async () => {
    const state = makeState();
    const sender: chrome.runtime.MessageSender = {
      tab: { id: 42, url: 'https://example.com/' } as chrome.tabs.Tab,
      frameId: 0,
      id: 'some-other-extension', // NOT chrome.runtime.id
    };
    const res = await route({ type: 'get_autofill_candidates' }, state, sender);
    expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
  });
});

// --- capture_save_login (content-callable, origin-bound) ---

describe('capture_save_login', () => {
  const EXISTING_ID = 'dddddddddddddddd';

  function loginItem(url: string, username: string, password: string): Item {
    return {
      id: EXISTING_ID,
      title: 'Example',
      type: 'login',
      tags: [],
      favorite: false,
      created: 0,
      modified: 0,
      core: { type: 'login', username, password, url },
      sections: [],
      attachments: [],
      field_history: {},
    };
  }

  function primeUnlocked(state: RouterState): void {
    vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never);
    state.gitHost = {} as never;
  }

  beforeEach(() => {
    vi.mocked(session.getCurrent).mockReset();
    vi.mocked(vault.fetchAndDecryptItem).mockReset();
    vi.mocked(vault.encryptAndWriteItem).mockReset();
    vi.mocked(vault.encryptAndWriteManifest).mockReset();
    vi.mocked(vault.encryptAndWriteItem).mockResolvedValue(undefined);
    vi.mocked(vault.encryptAndWriteManifest).mockResolvedValue(undefined);
  });

  it('accepts capture_save_login from top-frame content', async () => {
    const state = makeState();
    primeUnlocked(state);
    const res = await route(
      { type: 'capture_save_login', username: 'alice', password: 'hunter2' },
      state,
      makeContentSender('https://example.com/login'),
    );
    expect(res.ok).toBe(true);
  });

  it('rejects capture_save_login from popup', async () => {
    const state = makeState();
    primeUnlocked(state);
    const res = await route(
      { type: 'capture_save_login', username: 'alice', password: 'hunter2' },
      state,
      makePopupSender(),
    );
    expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  it('update path: existing (host, username) match rotates the password', async () => {
    const state = makeState();
    primeUnlocked(state);
    // Seed manifest with a login for example.com.
    state.manifest = {
      schema_version: 2,
      items: {
        [EXISTING_ID]: {
          id: EXISTING_ID, type: 'login', title: 'Example',
          tags: [], favorite: false, icon_hint: 'example.com',
          modified: 0, attachment_summaries: [],
        },
      },
    };
    vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(
      loginItem('https://example.com/', 'alice', 'oldpass'),
    );

    const res = await route(
      { type: 'capture_save_login', username: 'alice', password: 'newpass' },
      state,
      makeContentSender('https://example.com/login'),
    );
    expect(res).toMatchObject({ ok: true, data: { action: 'updated', id: EXISTING_ID } });
    // Verify write was invoked with a core whose password is the new one.
    expect(vault.encryptAndWriteItem).toHaveBeenCalledTimes(1);
    const writtenItem = vi.mocked(vault.encryptAndWriteItem).mock.calls[0][3];
    expect(writtenItem.id).toBe(EXISTING_ID);
    if (writtenItem.core.type !== 'login') throw new Error('expected login core');
    expect(writtenItem.core.password).toBe('newpass');
    expect(writtenItem.core.username).toBe('alice');
  });

  it('add path: no match creates a new item bound to senderHost', async () => {
    const state = makeState();
    primeUnlocked(state);
    // Empty manifest — no candidates.
    state.manifest = { schema_version: 2, items: {} };

    const res = await route(
      { type: 'capture_save_login', username: 'bob', password: 's3cret' },
      state,
      makeContentSender('https://example.com/signup'),
    );
    expect(res.ok).toBe(true);
    if (res.ok) {
      const data = res.data as { action: string; id: string };
      expect(data.action).toBe('added');
      expect(data.id).toBe('fakeitemid0000ab'); // from stub new_item_id()
    }
    expect(vault.encryptAndWriteItem).toHaveBeenCalledTimes(1);
    const newItem = vi.mocked(vault.encryptAndWriteItem).mock.calls[0][3];
    expect(newItem.title).toBe('example.com');
    if (newItem.core.type !== 'login') throw new Error('expected login core');
    expect(newItem.core.url).toBe('https://example.com');
    expect(newItem.core.username).toBe('bob');
    expect(newItem.core.password).toBe('s3cret');
    // Manifest entry should have been added too.
    expect(state.manifest!.items['fakeitemid0000ab']).toBeDefined();
  });

  it('origin_mismatch when existing item for same username has a different host', async () => {
    const state = makeState();
    primeUnlocked(state);
    // Manifest says there's a match for example.com (icon_hint), but the
    // underlying item actually belongs to github.com — defense-in-depth
    // check should reject.
    state.manifest = {
      schema_version: 2,
      items: {
        [EXISTING_ID]: {
          id: EXISTING_ID, type: 'login', title: 'Example',
          tags: [], favorite: false, icon_hint: 'example.com',
          modified: 0, attachment_summaries: [],
        },
      },
    };
    vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(
      loginItem('https://github.com/', 'alice', 'oldpass'),
    );

    const res = await route(
      { type: 'capture_save_login', username: 'alice', password: 'newpass' },
      state,
      makeContentSender('https://example.com/login'),
    );
    expect(res).toEqual({ ok: false, error: 'origin_mismatch' });
    expect(vault.encryptAndWriteItem).not.toHaveBeenCalled();
  });
});

// --- get_totp covers both Login.totp and Totp.config ---

describe('get_totp handler covers both Login.totp and Totp.config', () => {
  function primeUnlocked(state: RouterState): void {
    vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never);
    state.gitHost = {} as never;
  }

  function withTotpWasm(state: RouterState): void {
    state.wasm = {
      ...state.wasm,
      totp_compute: (_json: string, _now: bigint) => ({
        code: '123456',
        expires_at: 1_700_000_030,
      }),
    };
  }

  beforeEach(() => {
    vi.mocked(session.getCurrent).mockReset();
    vi.mocked(vault.fetchAndDecryptItem).mockReset();
  });

  it('returns a code for an item with core.type === "totp"', async () => {
    const state = makeState();
    primeUnlocked(state);
    withTotpWasm(state);
    const totpItem: Item = {
      id: 'totp0000000000aa',
      title: 'GitHub TOTP',
      type: 'totp',
      tags: [],
      favorite: false,
      created: 0,
      modified: 0,
      core: {
        type: 'totp',
        config: {
          secret: [0x48, 0x65, 0x6c, 0x6c, 0x6f], // "Hello" in bytes
          algorithm: 'sha1',
          digits: 6,
          period_seconds: 30,
          kind: 'totp',
        },
        issuer: 'GitHub',
      },
      sections: [],
      attachments: [],
      field_history: {},
    };
    vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(totpItem);

    const res = await route(
      { type: 'get_totp', id: 'totp0000000000aa' },
      state,
      makePopupSender(),
    );
    expect(res.ok).toBe(true);
    if (res.ok) {
      const d = res.data as { code: string; expires_at: number };
      expect(d.code).toMatch(/^\d{6}$/);
      expect(d.expires_at).toBeGreaterThan(0);
    }
  });

  it('still returns a code for Login items with a totp subfield', async () => {
    const state = makeState();
    primeUnlocked(state);
    withTotpWasm(state);
    const loginItem: Item = {
      id: 'login0000000000a',
      title: 'Example',
      type: 'login',
      tags: [],
      favorite: false,
      created: 0,
      modified: 0,
      core: {
        type: 'login',
        username: 'alice',
        password: 'hunter2',
        url: 'https://example.com',
        totp: {
          secret: [0x48, 0x65, 0x6c, 0x6c, 0x6f],
          algorithm: 'sha1',
          digits: 6,
          period_seconds: 30,
          kind: 'totp',
        },
      },
      sections: [],
      attachments: [],
      field_history: {},
    };
    vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(loginItem);

    const res = await route(
      { type: 'get_totp', id: 'login0000000000a' },
      state,
      makePopupSender(),
    );
    expect(res.ok).toBe(true);
  });

  it('rejects items without any TOTP config', async () => {
    const state = makeState();
    primeUnlocked(state);
    withTotpWasm(state);
    const identityItem: Item = {
      id: 'id0000000000aaaa',
      title: 'Identity',
      type: 'identity',
      tags: [],
      favorite: false,
      created: 0,
      modified: 0,
      core: { type: 'identity' },
      sections: [],
      attachments: [],
      field_history: {},
    };
    vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(identityItem);

    const res = await route(
      { type: 'get_totp', id: 'id0000000000aaaa' },
      state,
      makePopupSender(),
    );
    expect(res).toEqual({ ok: false, error: 'no_totp' });
  });
});

// --- get_vault_settings / update_vault_settings (β₂ Slice 3) ---

describe('get_vault_settings / update_vault_settings', () => {
  function primeUnlocked(state: RouterState): void {
    vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never);
    state.gitHost = {} as never;
  }

  beforeEach(() => {
    vi.mocked(session.getCurrent).mockReset();
    vi.mocked(vault.fetchAndDecryptSettings).mockReset();
    vi.mocked(vault.encryptAndWriteSettings).mockReset();
  });

  it('get_vault_settings accepted from popup; returns VaultSettings', async () => {
    const state = makeState();
    primeUnlocked(state);
    const mockSettings = {
      trash_retention: { kind: 'days', value: 30 },
      field_history_retention: { kind: 'forever' },
      generator_defaults: {
        kind: 'random', length: 20,
        classes: { lower: true, upper: true, digits: true, symbols: true },
        symbol_charset: { kind: 'safe_only' },
      },
      attachment_caps: {},
      autofill_origin_acks: { 'github.com': 1000 },
    };
    vi.mocked(vault.fetchAndDecryptSettings).mockResolvedValueOnce(mockSettings as never);
    const res = await route({ type: 'get_vault_settings' }, state, makePopupSender());
    expect(res).toMatchObject({ ok: true });
    if (res.ok) {
      const d = res.data as { settings: typeof mockSettings };
      expect(d.settings).toEqual(mockSettings);
    }
  });

  it('get_vault_settings rejected from content', async () => {
    const state = makeState();
    const res = await route({ type: 'get_vault_settings' }, state, makeContentSender());
    expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  it('update_vault_settings accepted from popup; calls encryptAndWriteSettings', async () => {
    const state = makeState();
    primeUnlocked(state);
    vi.mocked(vault.encryptAndWriteSettings).mockResolvedValueOnce(undefined);
    const newSettings = {
      trash_retention: { kind: 'forever' },
      field_history_retention: { kind: 'last_n', value: 5 },
      generator_defaults: {
        kind: 'bip39', word_count: 6, separator: '-', capitalization: 'lower',
      },
      attachment_caps: {},
      autofill_origin_acks: {},
    };
    const res = await route(
      { type: 'update_vault_settings', settings: newSettings as never },
      state,
      makePopupSender(),
    );
    expect(res).toMatchObject({ ok: true });
    expect(vault.encryptAndWriteSettings).toHaveBeenCalledWith(
      expect.anything(), expect.anything(), newSettings, expect.any(String),
    );
  });

  it('update_vault_settings rejected from setup tab (not in SETUP_ALLOWED)', async () => {
    const state = makeState();
    const res = await route(
      { type: 'update_vault_settings', settings: {} as never },
      state,
      makeSetupSender(),
    );
    expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
  });
});

// --- upload_attachment / download_attachment (γ₁ Task 6) ---

describe('upload_attachment / download_attachment', () => {
  it('upload_attachment accepted from popup', async () => {
    const state = makeState();
    const result = await route(
      { type: 'upload_attachment', itemId: 'abc', filename: 'f.pdf', mimeType: 'application/pdf', bytes: new ArrayBuffer(10) },
      state,
      makePopupSender(),
    );
    // The handler may return ok: false (vault_locked) since session is not primed,
    // but the router MUST reach the handler — i.e. not return unauthorized_sender.
    expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  it('upload_attachment rejected from content script', async () => {
    const state = makeState();
    const result = await route(
      { type: 'upload_attachment', itemId: 'abc', filename: 'f.pdf', mimeType: 'application/pdf', bytes: new ArrayBuffer(10) },
      state,
      makeContentSender(),
    );
    expect(result).toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  it('download_attachment accepted from popup', async () => {
    const state = makeState();
    const result = await route(
      { type: 'download_attachment', itemId: 'abc', attachmentId: 'aid' },
      state,
      makePopupSender(),
    );
    expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  it('download_attachment rejected from content script', async () => {
    const state = makeState();
    const result = await route(
      { type: 'download_attachment', itemId: 'abc', attachmentId: 'aid' },
      state,
      makeContentSender(),
    );
    expect(result).toEqual({ ok: false, error: 'unauthorized_sender' });
  });
});

// --- export_backup / restore_backup sender check ---

describe('export_backup / restore_backup sender check', () => {
  it('accepts vault tab for export_backup', async () => {
    const state = makeState();
    const result = await route(
      { type: 'export_backup', passphrase: 'p', includeImage: false },
      state,
      makeVaultSender(),
    );
    // The handler may return ok: false (vault_locked / missing state) but the
    // router must NOT reject it as unauthorized_sender.
    expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  it('accepts popup for export_backup', async () => {
    const state = makeState();
    const result = await route(
      { type: 'export_backup', passphrase: 'p', includeImage: false },
      state,
      makePopupSender(),
    );
    expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  it('rejects setup tab for export_backup', async () => {
    const state = makeState();
    const result = await route(
      { type: 'export_backup', passphrase: 'p', includeImage: false },
      state,
      makeSetupSender(),
    );
    expect(result).toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  it('rejects content top frame for restore_backup', async () => {
    const state = makeState();
    const result = await route(
      {
        type: 'restore_backup',
        bytes: new ArrayBuffer(8),
        passphrase: 'p',
        newRemote: { hostType: 'gitea', hostUrl: 'https://x', repoPath: 'a/b', apiToken: 't' },
      },
      state,
      makeContentSender('https://example.com'),
    );
    expect(result).toEqual({ ok: false, error: 'unauthorized_sender' });
  });
});

// --- parse_lastpass_csv / import_lastpass_commit sender check ---

describe('parse_lastpass_csv / import_lastpass_commit sender check', () => {
  it('accepts vault tab for parse_lastpass_csv', async () => {
    const state = makeState();
    const result = await route(
      { type: 'parse_lastpass_csv', bytes: new ArrayBuffer(8) },
      state,
      makeVaultSender(),
    );
    expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  it('accepts popup for parse_lastpass_csv', async () => {
    const state = makeState();
    const result = await route(
      { type: 'parse_lastpass_csv', bytes: new ArrayBuffer(8) },
      state,
      makePopupSender(),
    );
    expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  it('rejects setup tab for parse_lastpass_csv', async () => {
    const state = makeState();
    const result = await route(
      { type: 'parse_lastpass_csv', bytes: new ArrayBuffer(8) },
      state,
      makeSetupSender(),
    );
    expect(result).toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  it('rejects content top frame for import_lastpass_commit', async () => {
    const state = makeState();
    const result = await route(
      { type: 'import_lastpass_commit', items: [] },
      state,
      makeContentSender('https://example.com'),
    );
    expect(result).toEqual({ ok: false, error: 'unauthorized_sender' });
  });
});

// --- get_active_tab_url ---

describe('get_active_tab_url', () => {
  let originalChrome: any;
  beforeEach(() => { originalChrome = (globalThis as any).chrome; });
  afterEach(() => { (globalThis as any).chrome = originalChrome; });

  it('get_active_tab_url returns active tab url + title', async () => {
    // happy-dom does not provide chrome.tabs; stub it.
    (globalThis as any).chrome = {
      ...((globalThis as any).chrome ?? {}),
      tabs: {
        query: (q: any, cb: (tabs: any[]) => void) => {
          cb([{ url: 'https://github.com/login', title: 'Sign in to GitHub' }]);
        },
      },
    };
    const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender());
    expect(resp.ok).toBe(true);
    expect(resp.data).toEqual({ url: 'https://github.com/login', title: 'Sign in to GitHub' });
  });

  it('get_active_tab_url returns null for chrome:// pages', async () => {
    (globalThis as any).chrome = {
      ...((globalThis as any).chrome ?? {}),
      tabs: {
        query: (q: any, cb: (tabs: any[]) => void) => {
          cb([{ url: 'chrome://newtab/', title: 'New Tab' }]);
        },
      },
    };
    const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender());
    expect(resp.ok).toBe(true);
    expect(resp.data).toBeNull();
  });

  it('get_active_tab_url returns null for view-source: URLs', async () => {
    (globalThis as any).chrome = {
      ...((globalThis as any).chrome ?? {}),
      tabs: {
        query: (q: any, cb: (tabs: any[]) => void) => {
          cb([{ url: 'view-source:https://github.com/login', title: 'View Source' }]);
        },
      },
    };
    const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender());
    expect(resp.ok).toBe(true);
    expect(resp.data).toBeNull();
  });
});

// --- list_groups ---

describe('list_groups', () => {
  it('list_groups returns deduplicated sorted groups from manifest', async () => {
    const state = makeState();
    state.manifest = {
      schema_version: 2,
      items: {
        a: { id: 'a', title: 't1', type: 'login', group: 'work', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] },
        b: { id: 'b', title: 't2', type: 'login', group: 'personal', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] },
        c: { id: 'c', title: 't3', type: 'login', group: 'work', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] },
        d: { id: 'd', title: 't4', type: 'login', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] }, // no group
      },
    };
    const resp = await route({ type: 'list_groups' } as any, state, makePopupSender());
    expect(resp.ok).toBe(true);
    expect(resp.data).toEqual({ groups: ['personal', 'work'] });
  });

  it('list_groups returns empty array when manifest is null', async () => {
    const state = makeState();
    state.manifest = null;
    const resp = await route({ type: 'list_groups' } as any, state, makePopupSender());
    expect(resp.ok).toBe(true);
    expect(resp.data).toEqual({ groups: [] });
  });
});

// --- preview_totp_from_secret ---

describe('preview_totp_from_secret', () => {
  let originalChrome: any;
  beforeEach(() => { originalChrome = (globalThis as any).chrome; });
  afterEach(() => { (globalThis as any).chrome = originalChrome; });

  it('returns code for valid base32', async () => {
    const state = makeState();
    state.wasm = {
      totp_compute: vi.fn().mockReturnValue({ code: '123456', expires_at: 9_999_999_999 }),
    };
    const resp = await route(
      { type: 'preview_totp_from_secret', secret_b32: 'JBSWY3DPEHPK3PXP' } as any,
      state, makePopupSender(),
    );
    expect(resp.ok).toBe(true);
    expect(resp.data).toEqual({ code: '123456', expires_at: 9_999_999_999 });
    // Verify a transient TotpConfig was passed (sha1, 6 digits, 30s)
    const cfgArg = JSON.parse(state.wasm.totp_compute.mock.calls[0][0]);
    expect(cfgArg.algorithm).toBe('sha1');
    expect(cfgArg.digits).toBe(6);
    expect(cfgArg.period_seconds).toBe(30);
  });

  it('rejects invalid base32', async () => {
    const state = makeState();
    state.wasm = { totp_compute: vi.fn() };
    const resp = await route(
      { type: 'preview_totp_from_secret', secret_b32: 'too-short!!!' } as any,
      state, makePopupSender(),
    );
    expect(resp.ok).toBe(false);
    expect(resp.error).toMatch(/invalid/i);
    expect(state.wasm.totp_compute).not.toHaveBeenCalled();
  });
});
