/// Popup-callable message handlers.
///
/// Every export here assumes the router has already verified sender identity
/// via sender.url === popup.html (or setup.html for save_setup).

import type { PopupMessage, Response } from '../../shared/messages';
import type { Item, ItemId, Manifest, VaultConfig, SetupState, TotpConfig, AttachmentRef } from '../../shared/types';
import { base32Decode } from '../../shared/base32';
import type { GitHost } from '../git-host';
import { createGitHost, base64ToUint8Array } from '../git-host';
import * as vault from '../vault';
import { itemToManifestEntry } from '../vault';
import * as session from '../session';
import * as devices from '../devices';
import * as sessionTimer from '../session-timer';
import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } from '../storage';

// --- Shared ambient state owned by the SW module ---
//
// The router keeps these on a single `state` object and injects it into the
// handler so testing can mock them without reaching for globals.

export interface PopupState {
  manifest: Manifest | null;
  gitHost: GitHost | null;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  wasm: any;
}

export async function handle(
  msg: PopupMessage,
  state: PopupState,
  sender: chrome.runtime.MessageSender,
): Promise<Response> {
  void sender; // unused in most branches; retained for symmetry with content-callable
  switch (msg.type) {
    case 'is_unlocked':
      return { ok: true, data: { unlocked: session.getCurrent() !== null } };

    case 'unlock': {
      const w = state.wasm;
      const config = await loadConfig();
      if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' };
      const imageB64 = await loadImageBase64();
      if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' };

      const imageBytes = base64ToUint8Array(imageB64);
      if (!state.gitHost) state.gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
      const meta = await vault.fetchVaultMeta(state.gitHost);

      const handle = w.unlock(msg.passphrase, imageBytes, meta.salt, meta.paramsJson);
      session.setCurrent(handle);
      (msg as { passphrase: string }).passphrase = '';

      state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
      return { ok: true };
    }

    case 'lock':
      session.clearCurrent();
      state.manifest = null;
      // Don't leak the cached git-host (incl. lastSyncAt) across a lock —
      // symmetric with the session-expiry path (index.ts); unlock rebuilds it.
      state.gitHost = null;
      return { ok: true };

    case 'list_items': {
      if (!state.manifest) return { ok: false, error: 'vault_locked' };
      return { ok: true, data: { items: vault.listItems(state.manifest, msg.group) } };
    }

    case 'get_item': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
      const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
      return { ok: true, data: { item } };
    }

    case 'add_item': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
      const id = state.wasm.new_item_id();
      const item: Item = { ...msg.item, id };
      await vault.encryptAndWriteItem(state.gitHost, handle, id, item, `add: ${item.title}`);
      state.manifest.items[id] = itemToManifestEntry(item);
      await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: add ${item.title}`);
      return { ok: true, data: { id } };
    }

    case 'update_item': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
      await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, msg.item, `update: ${msg.item.title}`);
      state.manifest.items[msg.id] = itemToManifestEntry(msg.item);
      await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: update ${msg.item.title}`);
      return { ok: true };
    }

    case 'delete_item': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
      const entry = state.manifest.items[msg.id];
      if (!entry) return { ok: false, error: 'item_not_found' };
      const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
      const now = Math.floor(Date.now() / 1000);
      const updated: Item = { ...item, trashed_at: now, modified: now };
      await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, updated, `trash: ${entry.title}`);
      state.manifest.items[msg.id] = { ...entry, trashed_at: now, modified: now };
      await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: trash ${entry.title}`);
      return { ok: true };
    }

    case 'get_totp': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
      const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
      // Resolve the TotpConfig from whichever carrier the item type uses.
      // Login items hold TOTP as an optional subfield on LoginCore; the standalone
      // Totp item type carries it as TotpCore.config (required).
      let cfg: TotpConfig | null = null;
      if (item.core.type === 'login' && item.core.totp) {
        cfg = item.core.totp;
      } else if (item.core.type === 'totp') {
        cfg = item.core.config;
      }
      if (!cfg) return { ok: false, error: 'no_totp' };
      const now = Math.floor(Date.now() / 1000);
      const code = state.wasm.totp_compute(JSON.stringify(cfg), BigInt(now));
      return { ok: true, data: { code: code.code, expires_at: code.expires_at } };
    }

    case 'sync': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
      state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
      // Record sync time (unix SECONDS) for the get_vault_status indicator.
      state.gitHost.lastSyncAt = Math.floor(Date.now() / 1000);
      return { ok: true };
    }

    case 'get_setup_state':
      return { ok: true, data: await loadSetupState() };

    case 'save_setup': {
      await chrome.storage.local.set({
        vaultConfig: msg.config,
        imageBase64: msg.imageBase64,
      });
      state.gitHost = null;
      return { ok: true };
    }

    case 'rate_passphrase':
      return { ok: true, data: state.wasm.rate_passphrase(msg.passphrase) };

    case 'get_active_tab_url': {
      const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {
        chrome.tabs.query({ active: true, lastFocusedWindow: true }, (t) => resolve(t));
      });
      const tab = tabs[0];
      if (!tab?.url) return { ok: true, data: null };
      // Filter out chrome:// and extension URLs — autofill doesn't apply.
      if (/^(chrome|chrome-extension|chrome-search|moz-extension|edge|edge-extension|about|file|view-source|data|devtools|javascript):/i.test(tab.url)) {
        return { ok: true, data: null };
      }
      return { ok: true, data: { url: tab.url, title: tab.title ?? '' } };
    }

    case 'list_groups': {
      if (!state.manifest) return { ok: true, data: { groups: [] } };
      const set = new Set<string>();
      for (const id in state.manifest.items) {
        const g = state.manifest.items[id].group;
        if (g) set.add(g);
      }
      return { ok: true, data: { groups: Array.from(set).sort() } };
    }

    case 'preview_totp_from_secret': {
      const cleaned = msg.secret_b32.toUpperCase().replace(/\s+/g, '').replace(/=+$/, '');
      if (cleaned.length < 16 || !/^[A-Z2-7]+$/.test(cleaned)) {
        return { ok: false, error: 'invalid base32 secret' };
      }
      let secretBytes: Uint8Array;
      try {
        secretBytes = base32Decode(cleaned);
      } catch (e) {
        return { ok: false, error: `invalid base32: ${e instanceof Error ? e.message : String(e)}` };
      }
      const cfg = {
        secret: Array.from(secretBytes),
        algorithm: 'sha1',
        digits: 6,
        period_seconds: 30,
        kind: 'totp',
      };
      const now = Math.floor(Date.now() / 1000);
      const result = state.wasm.totp_compute(JSON.stringify(cfg), BigInt(now));
      return { ok: true, data: { code: result.code, expires_at: result.expires_at } };
    }

    case 'generate_password': {
      const password = state.wasm.generate_password(JSON.stringify(msg.request));
      return { ok: true, data: { password } };
    }

    case 'generate_passphrase': {
      const passphrase = state.wasm.generate_passphrase(JSON.stringify(msg.request));
      return { ok: true, data: { passphrase } };
    }

    case 'fill_credentials':
      return handleFillCredentials(msg, state);

    case 'ack_autofill_origin': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
      const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
      const acks = { ...(settings.autofill_origin_acks ?? {}), [msg.hostname]: Math.floor(Date.now() / 1000) };
      const updated = { ...settings, autofill_origin_acks: acks };
      await vault.encryptAndWriteSettings(state.gitHost, handle, updated, `settings: ack origin ${msg.hostname}`);
      return { ok: true };
    }

    case 'get_settings':
      return { ok: true, data: { settings: await loadDeviceSettings() } };

    case 'update_settings': {
      const current = await loadDeviceSettings();
      await saveDeviceSettings({ ...current, ...msg.settings });
      return { ok: true };
    }

    case 'get_vault_settings': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
      const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
      return { ok: true, data: { settings } };
    }

    case 'update_vault_settings': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
      await vault.encryptAndWriteSettings(
        state.gitHost, handle, msg.settings,
        'settings: update vault-level config',
      );
      return { ok: true };
    }

    case 'get_blacklist':
      return { ok: true, data: { blacklist: await loadBlacklist() } };

    case 'remove_blacklist': {
      const bl = await loadBlacklist();
      await saveBlacklist(bl.filter((h) => h !== msg.hostname));
      return { ok: true };
    }

    case 'upload_attachment': {
      try {
        const handle = session.getCurrent();
        if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };

        // Look up the per-attachment size cap from vault settings (if any).
        let maxBytes = BigInt(Number.MAX_SAFE_INTEGER);
        try {
          const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
          const cap = settings.attachment_caps?.per_attachment_max_bytes;
          if (cap !== undefined) maxBytes = BigInt(cap);
        } catch {
          // If settings are unavailable, proceed uncapped.
        }

        const plaintext = new Uint8Array(msg.bytes);
        // Cap enforcement layering:
        // - per_attachment_max_bytes: enforced here via WASM (defense-in-depth)
        // - per_item_max_count, per_vault_*_cap_bytes: enforced client-side in
        //   the popup (Task 7's attachments-disclosure component does this).
        const encrypted = state.wasm.attachment_encrypt(handle, plaintext, maxBytes);
        // encrypted: EncryptedAttachment — exposes .aid (string) and .bytes (Uint8Array)
        const aid: string = encrypted.aid;
        const encBytes: Uint8Array = encrypted.bytes;

        // Duplicate-id check: same content hash → same id → no-op re-upload.
        let existingRef: AttachmentRef | undefined;
        try {
          const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.itemId);
          existingRef = item.attachments.find((a) => a.id === aid);
        } catch {
          // Item not yet persisted or not found — will fail at addAttachmentToItem below.
        }
        if (existingRef) {
          return { ok: true, data: { attachment: existingRef } };
        }

        // Store the encrypted blob.
        const blobPath = `attachments/${aid}.bin`;
        await state.gitHost.putBlob(blobPath, encBytes, `attach: ${msg.filename}`);

        // Build the AttachmentRef and append it to the item + manifest.
        const ref: AttachmentRef = {
          id: aid,
          filename: msg.filename,
          mime_type: msg.mimeType,
          size: plaintext.length,
          created: Math.floor(Date.now() / 1000),
        };
        await vault.addAttachmentToItem(state.gitHost, handle, msg.itemId, ref);

        return { ok: true, data: { attachment: ref } };
      } catch (e) {
        console.error('[relicario] upload_attachment failed', e);
        return { ok: false, error: 'upload_failed' };
      }
    }

    case 'download_attachment': {
      try {
        const handle = session.getCurrent();
        if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };

        // Fetch the item to recover the filename + mime type from the AttachmentRef.
        const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.itemId);
        const ref = item.attachments.find((a) => a.id === msg.attachmentId);
        if (!ref) return { ok: false, error: 'attachment_not_found' };

        const blobPath = `attachments/${ref.id}.bin`;
        const encBytes = await state.gitHost.getBlob(blobPath);
        const decrypted = state.wasm.attachment_decrypt(handle, encBytes);

        return {
          ok: true,
          data: {
            bytes: decrypted.buffer.slice(
              decrypted.byteOffset,
              decrypted.byteOffset + decrypted.byteLength,
            ) as ArrayBuffer,
            filename: ref.filename,
            mimeType: ref.mime_type,
          },
        };
      } catch (e) {
        console.error('[relicario] download_attachment failed', e);
        return { ok: false, error: 'download_failed' };
      }
    }

    case 'list_devices': {
      if (!state.gitHost) return { ok: false, error: 'vault_locked' };
      const list = await devices.readDevices(state.gitHost);
      return { ok: true, data: { devices: list } };
    }

    case 'list_revoked': {
      if (!state.gitHost) return { ok: false, error: 'vault_locked' };
      const revoked = await devices.readRevoked(state.gitHost);
      return { ok: true, data: { revoked } };
    }

    case 'add_device': {
      if (!state.gitHost) return { ok: false, error: 'vault_locked' };
      const device = {
        name: msg.name,
        public_key: msg.public_key,
        added_at: Math.floor(Date.now() / 1000),
      };
      await devices.addDevice(state.gitHost, device);
      return { ok: true };
    }

    case 'register_this_device': {
      if (!state.gitHost) return { ok: false, error: 'vault_locked' };
      // register_device keeps private keys internal — only public keys cross to JS
      const keys = state.wasm.register_device(msg.name) as {
        signing_public_key: string;
        deploy_public_key: string;
      };
      await chrome.storage.local.set({ device_name: msg.name });
      await devices.addDevice(state.gitHost, {
        name: msg.name,
        public_key: keys.signing_public_key,
        added_at: Math.floor(Date.now() / 1000),
      });
      return { ok: true };
    }

    case 'revoke_device': {
      if (!state.gitHost) return { ok: false, error: 'vault_locked' };
      const stored = await chrome.storage.local.get(['device_name']);
      const revokedBy = stored.device_name as string | undefined;
      await devices.revokeDevice(state.gitHost, msg.name, revokedBy);
      return { ok: true };
    }

    case 'list_trashed': {
      if (!state.manifest) return { ok: false, error: 'vault_locked' };
      const items = vault.listTrashed(state.manifest);
      return { ok: true, data: { items } };
    }

    case 'restore_item': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
      await vault.restoreItem(state.gitHost, handle, state.manifest, msg.id);
      return { ok: true };
    }

    case 'purge_item': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
      await vault.purgeItem(state.gitHost, msg.id, state.manifest);
      await vault.encryptAndWriteManifest(
        state.gitHost, handle, state.manifest,
        `manifest: purge ${state.manifest.items[msg.id]?.title ?? msg.id}`,
      );
      return { ok: true };
    }

    case 'purge_all_trash': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
      const result = await vault.purgeAllTrash(state.gitHost, handle, state.manifest);
      return { ok: true, data: result };
    }

    case 'get_field_history': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
      const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
      const history = state.wasm.get_field_history(JSON.stringify(item));
      return { ok: true, data: { history } };
    }

    case 'get_session_config':
      return { ok: true, data: { config: sessionTimer.getConfig() } };

    case 'update_session_config': {
      sessionTimer.setConfig(msg.config);
      sessionTimer.resetTimer();
      await chrome.storage.local.set({ session_timeout: msg.config });
      return { ok: true };
    }

    case 'export_backup': {
      if (!state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };

      try {
        const blob = await vault.fetchVaultStateForBackup(state.gitHost, state.manifest);

        let reference_jpg: string | null = null;
        if (msg.includeImage) {
          const stored = await chrome.storage.local.get('imageBase64');
          const b64 = stored.imageBase64 as string | undefined;
          if (!b64) return { ok: false, error: 'no reference image stored locally' };
          reference_jpg = b64;
        }

        const inputJson = JSON.stringify({
          salt: blob.salt_b64,
          params_json: blob.params_json,
          devices_json: blob.devices_json,
          manifest_enc: blob.manifest_enc_b64,
          settings_enc: blob.settings_enc_b64,
          items: blob.items.map(i => ({ id: i.id, ciphertext: i.ciphertext_b64 })),
          attachments: blob.attachments.map(a => ({
            item_id: a.item_id, attachment_id: a.attachment_id, ciphertext: a.ciphertext_b64
          })),
          reference_jpg,
          git_archive: null, // Extension never bundles git history.
        });

        const bytes: Uint8Array = state.wasm.pack_backup_json(inputJson, msg.passphrase);
        return { ok: true, data: { bytes: bytes.buffer } };
      } catch (e) {
        return { ok: false, error: (e as Error).message };
      }
    }

    case 'restore_backup': {
      try {
        const bytes = new Uint8Array(msg.bytes);
        const outJson: string = state.wasm.unpack_backup_json(bytes, msg.passphrase);
        const out = JSON.parse(outJson) as {
          salt: string;
          params_json: string;
          devices_json: string;
          manifest_enc: string;
          settings_enc: string;
          items: Array<{ id: string; ciphertext: string }>;
          attachments: Array<{ item_id: string; attachment_id: string; ciphertext: string }>;
          reference_jpg: string | null;
        };

        // Build a GitHost for the new remote.
        const newHost = createGitHost(
          msg.newRemote.hostType,
          msg.newRemote.hostUrl,
          msg.newRemote.repoPath,
          msg.newRemote.apiToken,
        );

        // Refuse if the remote already has a vault.
        try {
          const meta = await vault.fetchVaultMeta(newHost);
          if (meta.salt && meta.paramsJson) {
            return { ok: false, error: 'remote already contains a Relicario vault' };
          }
        } catch {
          // No vault present — expected for a fresh remote.
        }

        // Write the layout via writeFileCreateOnly. Refuses to clobber.
        const b64 = (s: string) => Uint8Array.from(atob(s), c => c.charCodeAt(0));
        await newHost.writeFileCreateOnly('.relicario/salt', b64(out.salt), 'restore: salt');
        await newHost.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(out.params_json), 'restore: params.json');
        await newHost.writeFileCreateOnly('.relicario/devices.json', new TextEncoder().encode(out.devices_json), 'restore: devices.json');
        await newHost.writeFileCreateOnly('manifest.enc', b64(out.manifest_enc), 'restore: manifest.enc');
        await newHost.writeFileCreateOnly('settings.enc', b64(out.settings_enc), 'restore: settings.enc');

        for (const it of out.items) {
          await newHost.writeFileCreateOnly(
            `items/${it.id}.enc`, b64(it.ciphertext), `restore: item ${it.id}`,
          );
        }
        // Translate canonical envelope keys (<item_id>/<aid>) back to the
        // extension's flat layout (attachments/<aid>.bin). The aid is
        // already content-addressed and globally unique; the item_id segment
        // is recorded only in the manifest's attachment_summaries.
        for (const a of out.attachments) {
          await newHost.writeFileCreateOnly(
            `attachments/${a.attachment_id}.bin`,
            b64(a.ciphertext),
            `restore: attachment ${a.attachment_id}`,
          );
        }

        // Update local config so subsequent unlocks work.
        const cfg = {
          hostType: msg.newRemote.hostType,
          hostUrl: msg.newRemote.hostUrl,
          repoPath: msg.newRemote.repoPath,
          apiToken: msg.newRemote.apiToken,
        };
        const storageUpdate: Record<string, unknown> = { vaultConfig: cfg };
        if (out.reference_jpg) {
          storageUpdate.imageBase64 = out.reference_jpg;
        }
        await chrome.storage.local.set(storageUpdate);

        // Make sure the SW's gitHost cache picks up the new config.
        state.gitHost = newHost;
        state.manifest = null; // user must unlock to populate

        return {
          ok: true,
          data: {
            summary: {
              itemCount: out.items.length,
              attachmentCount: out.attachments.length,
              hasImage: out.reference_jpg != null,
            },
          },
        };
      } catch (e) {
        return { ok: false, error: (e as Error).message };
      }
    }

    case 'parse_lastpass_csv': {
      try {
        const json: string = state.wasm.parse_lastpass_csv_json(new Uint8Array(msg.bytes));
        const parsed = JSON.parse(json) as {
          items: Item[];
          warnings: Array<{ row: number; title?: string; message: string }>;
        };
        return { ok: true, data: parsed };
      } catch (e) {
        return { ok: false, error: (e as Error).message };
      }
    }

    case 'generate_recovery_qr': {
      const handle = session.getCurrent();
      if (!handle) return { ok: false, error: 'vault_locked' };
      try {
        const svg: string = state.wasm.wasm_generate_recovery_qr(handle, msg.passphrase);
        return { ok: true, data: { svg } };
      } catch (e) {
        return { ok: false, error: (e as Error).message };
      }
    }

    case 'unwrap_recovery_qr': {
      try {
        const imageSecretBytes: Uint8Array = state.wasm.wasm_unwrap_recovery_qr(msg.payload_b64, msg.passphrase);
        return { ok: true, data: { image_secret: Array.from(imageSecretBytes) } };
      } catch (e) {
        return { ok: false, error: (e as Error).message };
      }
    }

    case 'import_lastpass_commit': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
      if (msg.items.length === 0) return { ok: false, error: 'no items to import' };

      try {
        const total = msg.items.length;
        for (let i = 0; i < msg.items.length; i++) {
          const item = msg.items[i];
          // Items arrive with IDs already minted by the WASM bridge. We
          // overwrite that with a fresh extension-generated ID so the SW
          // remains the single ID-issuance authority for new items in the
          // remote — same pattern as `add_item`.
          const id = state.wasm.new_item_id();
          const reIdItem: Item = { ...item, id };

          await vault.encryptAndWriteItem(
            state.gitHost, handle, id, reIdItem,
            `import: ${reIdItem.title} (${i + 1}/${total})`,
          );
          state.manifest.items[id] = itemToManifestEntry(reIdItem);
        }

        await vault.encryptAndWriteManifest(
          state.gitHost, handle, state.manifest,
          `manifest: import ${total} items from LastPass`,
        );
        return { ok: true, data: { summary: { itemCount: total } } };
      } catch (e) {
        return { ok: false, error: (e as Error).message };
      }
    }

    case 'create_vault':
      return vault.handleCreateVault(msg, state);

    case 'attach_vault':
      return vault.handleAttachVault(msg, state);

    case 'get_vault_status':
      return vault.handleGetVaultStatus(state);

    default:
      return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` };
  }
}

// --- fill_credentials with captured-tab verification (audit M5) ---

async function handleFillCredentials(
  msg: Extract<PopupMessage, { type: 'fill_credentials' }>,
  state: PopupState,
): Promise<Response> {
  const handle = session.getCurrent();
  if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };

  let tab: chrome.tabs.Tab;
  try { tab = await chrome.tabs.get(msg.capturedTabId); }
  catch { return { ok: false, error: 'captured_tab_gone' }; }

  const currentHost  = safeHostname(tab.url ?? '');
  const capturedHost = safeHostname(msg.capturedUrl);
  if (!currentHost || !capturedHost || currentHost !== capturedHost) {
    return { ok: false, error: 'tab_navigated' };
  }

  const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
  if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' };
  const itemHost = safeHostname(item.core.url ?? '');
  if (!itemHost || itemHost !== currentHost) return { ok: false, error: 'origin_mismatch' };

  // Pass the hostname the SW validated. The content script re-verifies
  // against location.href before filling — if the tab navigated between
  // our chrome.tabs.get check above and the sendMessage delivery below,
  // fill.ts rejects with 'origin_changed'.
  await chrome.tabs.sendMessage(msg.capturedTabId, {
    type: 'fill_credentials',
    username: item.core.username ?? '',
    password: item.core.password ?? '',
    expectedHost: currentHost,
  });
  return { ok: true };
}

// --- chrome.storage.local helpers (module-scoped so all handlers share) ---

async function loadConfig(): Promise<VaultConfig | null> {
  const r = await chrome.storage.local.get('vaultConfig');
  return (r.vaultConfig as VaultConfig) ?? null;
}

async function loadImageBase64(): Promise<string | null> {
  const r = await chrome.storage.local.get('imageBase64');
  return (r.imageBase64 as string) ?? null;
}

async function loadSetupState(): Promise<SetupState> {
  const config = await loadConfig();
  const imageBase64 = await loadImageBase64();
  return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null };
}

function safeHostname(url: string): string | undefined {
  try { return new URL(url).hostname; } catch { return undefined; }
}
