/// Typed-item vault operations. All calls are handle-keyed — the master key
/// never crosses the WASM boundary.

import type { SessionHandle } from '../../wasm/relicario_wasm';
import type { GitHost } from './git-host';
import { createGitHost, uint8ArrayToBase64 } from './git-host';
import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings, VaultConfig } from '../shared/types';
import * as session from './session';
import * as devices from './devices';
import type { AttachVaultResponse, CreateVaultResponse, GetVaultStatusResponse } from '../shared/messages';
import type { PopupState } from './router/popup-only';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wasm: any = null;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function setWasm(w: any): void { wasm = w; }

function requireWasm(): any {
  if (!wasm) throw new Error('WASM module not initialized');
  return wasm;
}

const DEFAULT_PARAMS_JSON = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}';

/// Register this device on the remote (devices.json) and persist the vault
/// config + reference image locally so future unlocks work. Shared by the
/// create and attach flows — both finish with this identical tail.
async function registerDeviceAndPersistConfig(
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  w: any,
  git: GitHost,
  config: VaultConfig,
  referenceImageBytes: Uint8Array,
  deviceName: string,
): Promise<void> {
  const keys = w.register_device(deviceName) as { signing_public_key: string };
  await devices.addDevice(git, {
    name: deviceName,
    public_key: keys.signing_public_key,
    added_at: Math.floor(Date.now() / 1000),
  });
  await chrome.storage.local.set({
    vaultConfig: config,
    imageBase64: uint8ArrayToBase64(referenceImageBytes),
    device_name: deviceName,
  });
}

export async function handleCreateVault(
  msg: { config: VaultConfig; passphrase: string; carrierImageBytes: ArrayBuffer; deviceName: string },
  state: PopupState,
): Promise<CreateVaultResponse | { ok: false; error: string }> {
  const w = state.wasm;
  let handle: SessionHandle | null = null;
  try {
    const carrierBytes = new Uint8Array(msg.carrierImageBytes);
    // Actionable guard: the file <input accept="image/jpeg"> is only a hint,
    // and a broken ArrayBuffer transport would arrive empty. Both surface as
    // the opaque WASM error "no SOF marker found in JPEG", so classify here
    // before handing bytes to the steganography embed.
    if (carrierBytes.length === 0) {
      return { ok: false, error: 'carrier_image_empty' };
    }
    if (carrierBytes[0] !== 0xff || carrierBytes[1] !== 0xd8) {
      return { ok: false, error: 'carrier_image_not_jpeg' };
    }
    const imageSecret = new Uint8Array(32);
    crypto.getRandomValues(imageSecret);
    const referenceImageBytes = new Uint8Array(w.embed_image_secret(carrierBytes, imageSecret));

    const salt = new Uint8Array(32);
    crypto.getRandomValues(salt);
    // Capture the unlock result in a non-null binding for the in-scope ops;
    // `handle` stays the ownership tracker the finally block cleans up.
    const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, salt, DEFAULT_PARAMS_JSON);
    handle = h;

    const encryptedManifest = new Uint8Array(w.manifest_encrypt(h, '{"schema_version":2,"items":{}}'));
    const encryptedSettings = new Uint8Array(w.settings_encrypt(h, w.default_vault_settings_json()));

    const { config } = msg;
    const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
    await git.writeFileCreateOnly('.relicario/salt', salt, 'init: vault salt');
    await git.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(DEFAULT_PARAMS_JSON), 'init: KDF parameters');
    await git.writeFileCreateOnly('manifest.enc', encryptedManifest, 'init: encrypted manifest');
    await git.writeFileCreateOnly('settings.enc', encryptedSettings, 'init: encrypted settings');

    await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName);

    // SW now owns the unlocked session — keeps the handle alive (enables recoveryQrAvailable).
    session.setCurrent(h);
    state.gitHost = git;
    state.manifest = { schema_version: 2, items: {} } as Manifest;
    handle = null; // ownership transferred — do NOT lock-and-free in finally

    return { ok: true, data: { referenceImageBytes, deviceName: msg.deviceName, recoveryQrAvailable: true } };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : String(err) };
  } finally {
    // Plan A .free() policy (docs/...extension-restructure-design.md Risks): lock THEN free,
    // and only if we still own the handle (success path transfers ownership to session.setCurrent).
    if (handle) {
      try { w.lock(handle); } catch { /* lock may already have happened */ }
      handle.free();
    }
  }
}

export async function handleAttachVault(
  msg: { config: VaultConfig; passphrase: string; referenceImageBytes: ArrayBuffer; deviceName: string },
  state: PopupState,
): Promise<AttachVaultResponse | { ok: false; error: string }> {
  const w = state.wasm;
  let handle: SessionHandle | null = null;
  try {
    const referenceImageBytes = new Uint8Array(msg.referenceImageBytes);
    const { config } = msg;
    const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);

    // The vault metadata and manifest are independent read-only GETs — fan out.
    const [meta, encryptedManifest] = await Promise.all([
      fetchVaultMeta(git),
      git.readFile('manifest.enc'),
    ]);

    const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, meta.salt, meta.paramsJson);
    handle = h;
    // manifest_decrypt verifies the passphrase + reference image — throws on AEAD failure.
    const manifest = w.manifest_decrypt(h, encryptedManifest) as Manifest;

    await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName);

    // SW now owns the unlocked session — transfer ownership to the session.
    session.setCurrent(h);
    state.gitHost = git;
    state.manifest = manifest;
    handle = null; // ownership transferred — do NOT lock-and-free in finally

    return { ok: true, data: { deviceName: msg.deviceName } };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : String(err) };
  } finally {
    // Same .free() policy as handleCreateVault: lock THEN free, only if we still
    // own the handle (success path transfers ownership to session.setCurrent).
    if (handle) {
      try { w.lock(handle); } catch { /* lock may already have happened */ }
      handle.free();
    }
  }
}

export interface VaultMeta {
  salt: Uint8Array;
  paramsJson: string;
}

export async function fetchVaultMeta(git: GitHost): Promise<VaultMeta> {
  const saltBytes = await git.readFile('.relicario/salt');
  const paramsRaw = await git.readFile('.relicario/params.json');
  const paramsJson = new TextDecoder().decode(paramsRaw);
  return { salt: saltBytes, paramsJson };
}

// --- Manifest ---

export async function fetchAndDecryptManifest(
  git: GitHost,
  handle: SessionHandle,
): Promise<Manifest> {
  const w = requireWasm();
  const ciphertext = await git.readFile('manifest.enc');
  return w.manifest_decrypt(handle, ciphertext) as Manifest;
}

/**
 * Read every byte the .relbak envelope needs from the remote vault repo.
 * Returns base64 strings for binary blobs (matching the WASM JSON shape).
 *
 * Translates the extension's flat `attachments/<aid>.bin` layout to the
 * canonical `<item_id>/<aid>` envelope-key form by walking the decrypted
 * manifest. Attachments referenced by the manifest but missing on-disk
 * are skipped with a console warning (the user already lost them; the
 * backup just records what's there).
 */
export async function fetchVaultStateForBackup(
  gitHost: GitHost,
  manifest: Manifest,
): Promise<{
  salt_b64: string;
  params_json: string;
  devices_json: string;
  manifest_enc_b64: string;
  settings_enc_b64: string;
  items: Array<{ id: string; ciphertext_b64: string }>;
  attachments: Array<{ item_id: string; attachment_id: string; ciphertext_b64: string }>;
}> {
  const meta = await fetchVaultMeta(gitHost);
  const devicesBytes = await gitHost.readFile('.relicario/devices.json');
  const devicesText = new TextDecoder().decode(devicesBytes);
  const manifestEnc = await gitHost.readFile('manifest.enc');
  const settingsEnc = await gitHost.readFile('settings.enc');

  // Items: items/<id>.enc, flat directory.
  const itemNames = await gitHost.listDir('items');
  const items = await Promise.all(itemNames
    .filter((name) => name.endsWith('.enc'))
    .map(async (name) => {
      const id = name.replace(/\.enc$/, '');
      const ct = await gitHost.readFile(`items/${name}`);
      return { id, ciphertext_b64: uint8ArrayToBase64(ct) };
    }));

  // Attachments live at `attachments/<aid>.bin`. Map aid -> item_id via the
  // manifest's attachment_summaries.
  const aidToItem: Record<string, string> = {};
  for (const [itemId, entry] of Object.entries(manifest.items)) {
    for (const summary of entry.attachment_summaries ?? []) {
      aidToItem[summary.id] = itemId;
    }
  }

  let attachments: Array<{ item_id: string; attachment_id: string; ciphertext_b64: string }> = [];
  try {
    const blobNames = await gitHost.listDir('attachments');
    for (const name of blobNames.filter((n) => n.endsWith('.bin'))) {
      const aid = name.replace(/\.bin$/, '');
      const item_id = aidToItem[aid];
      if (!item_id) {
        console.warn('[relicario] backup: attachment', aid, 'is orphan (no manifest entry); skipping');
        continue;
      }
      const ct = await gitHost.getBlob(`attachments/${name}`);
      attachments.push({
        item_id,
        attachment_id: aid,
        ciphertext_b64: uint8ArrayToBase64(ct),
      });
    }
  } catch {
    // attachments/ may not exist yet — fine.
  }

  return {
    salt_b64: uint8ArrayToBase64(meta.salt),
    params_json: meta.paramsJson,
    devices_json: devicesText,
    manifest_enc_b64: uint8ArrayToBase64(manifestEnc),
    settings_enc_b64: uint8ArrayToBase64(settingsEnc),
    items,
    attachments,
  };
}

export async function encryptAndWriteManifest(
  git: GitHost,
  handle: SessionHandle,
  manifest: Manifest,
  message: string,
): Promise<void> {
  const w = requireWasm();
  const ciphertext = w.manifest_encrypt(handle, JSON.stringify(manifest));
  await git.writeFile('manifest.enc', ciphertext, message);
}

// --- Items ---

export async function fetchAndDecryptItem(
  git: GitHost,
  handle: SessionHandle,
  id: ItemId,
): Promise<Item> {
  const w = requireWasm();
  const ciphertext = await git.readFile(`items/${id}.enc`);
  return w.item_decrypt(handle, ciphertext) as Item;
}

export async function encryptAndWriteItem(
  git: GitHost,
  handle: SessionHandle,
  id: ItemId,
  item: Item,
  message: string,
): Promise<void> {
  const w = requireWasm();
  const ciphertext = w.item_encrypt(handle, JSON.stringify(item));
  await git.writeFile(`items/${id}.enc`, ciphertext, message);
}

// --- Settings (the α subset the SW reads/writes is autofill_origin_acks) ---

export async function fetchAndDecryptSettings(
  git: GitHost,
  handle: SessionHandle,
): Promise<VaultSettings> {
  const w = requireWasm();
  const ciphertext = await git.readFile('settings.enc');
  return w.settings_decrypt(handle, ciphertext) as VaultSettings;
}

export async function encryptAndWriteSettings(
  git: GitHost,
  handle: SessionHandle,
  settings: VaultSettings,
  message: string,
): Promise<void> {
  const w = requireWasm();
  const ciphertext = w.settings_encrypt(handle, JSON.stringify(settings));
  await git.writeFile('settings.enc', ciphertext, message);
}

// --- In-memory manifest helpers ---

export function listItems(
  manifest: Manifest,
  group?: string,
): Array<[ItemId, ManifestEntry]> {
  const entries = Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>;
  // Hide trashed items from the default list view.
  const live = entries.filter(([, e]) => e.trashed_at === undefined);
  if (!group) return live;
  const g = group.toLowerCase();
  return live.filter(([, e]) => e.group?.toLowerCase() === g);
}

export function searchItems(
  manifest: Manifest,
  query: string,
): Array<[ItemId, ManifestEntry]> {
  const q = query.toLowerCase();
  return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>)
    .filter(([, e]) => e.trashed_at === undefined)
    .filter(([, e]) => {
      if (e.title.toLowerCase().includes(q)) return true;
      if (e.tags.some((t) => t.toLowerCase().includes(q))) return true;
      return false;
    });
}

/// Match manifest entries against a page hostname.
///
/// icon_hint is derived by the Rust core (crates/relicario-core/src/manifest.rs)
/// from LoginCore.url's hostname, so equality on icon_hint is the cheapest match.
/// α is intentionally coarse: no www.-stripping, no public-suffix matching
/// (`www.github.com` saved items will not match `github.com`, and vice versa).
/// Tighter matching is a 1C-β/γ concern.
export function findByHostname(
  manifest: Manifest,
  hostname: string,
): Array<[ItemId, ManifestEntry]> {
  const h = hostname.toLowerCase();
  return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>)
    .filter(([, e]) => e.trashed_at === undefined)
    .filter(([, e]) => (e.icon_hint ?? '').toLowerCase() === h);
}

// --- Attachment helpers ---

/// Sync the manifest entry's attachment_summaries for a single item.
/// Reads the current manifest + item; if either is missing, does nothing.
async function syncManifestAttachments(
  git: GitHost,
  handle: SessionHandle,
  itemId: ItemId,
  item: Item,
  message: string,
): Promise<void> {
  const manifest = await fetchAndDecryptManifest(git, handle);
  const entry = manifest.items[itemId];
  if (!entry) return;
  entry.attachment_summaries = item.attachments.map((a) => ({
    id: a.id,
    filename: a.filename,
    mime_type: a.mime_type,
    size: a.size,
  }));
  await encryptAndWriteManifest(git, handle, manifest, message);
}

/// Add an AttachmentRef to an existing item.
/// Reads + decrypts the item, appends to attachments[], re-encrypts and
/// writes both items/<id>.enc and the manifest entry's attachment_summaries.
/// Throws if the item is not found.
export async function addAttachmentToItem(
  git: GitHost,
  handle: SessionHandle,
  itemId: ItemId,
  ref: AttachmentRef,
): Promise<void> {
  let item: Item;
  try {
    item = await fetchAndDecryptItem(git, handle, itemId);
  } catch {
    throw new Error(`addAttachmentToItem: item ${itemId} not found`);
  }
  item.attachments = [...item.attachments, ref];
  item.modified = Math.floor(Date.now() / 1000);
  const commitMsg = `attach ${ref.filename} to item ${itemId}`;
  await encryptAndWriteItem(git, handle, itemId, item, commitMsg);
  await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`);
}

// --- Trash operations ---

export function listTrashed(manifest: Manifest): Array<[ItemId, ManifestEntry]> {
  return Object.entries(manifest.items)
    .filter(([, entry]) => entry.trashed_at != null)
    .sort(([, a], [, b]) => (b.trashed_at ?? 0) - (a.trashed_at ?? 0));
}

export async function restoreItem(
  git: GitHost,
  handle: SessionHandle,
  manifest: Manifest,
  itemId: ItemId,
): Promise<void> {
  const item = await fetchAndDecryptItem(git, handle, itemId);
  const now = Math.floor(Date.now() / 1000);
  const restored: Item = { ...item, trashed_at: undefined, modified: now };
  await encryptAndWriteItem(git, handle, itemId, restored, `restore: ${item.title}`);
  manifest.items[itemId] = { ...manifest.items[itemId], trashed_at: undefined, modified: now };
  await encryptAndWriteManifest(git, handle, manifest, `manifest: restore ${item.title}`);
}

export async function purgeItem(
  git: GitHost,
  itemId: ItemId,
  manifest: Manifest,
): Promise<string[]> {
  const entry = manifest.items[itemId];
  const deletedBlobs: string[] = [];

  // Delete attachments
  for (const att of entry?.attachment_summaries ?? []) {
    try {
      await git.deleteBlob(`attachments/${att.id}.bin`, `purge attachment: ${att.filename}`);
      deletedBlobs.push(att.id);
    } catch { /* blob may not exist */ }
  }

  // Delete item file
  await git.deleteFile(`items/${itemId}.enc`, `purge: ${entry?.title ?? itemId}`);

  // Update manifest
  delete manifest.items[itemId];

  return deletedBlobs;
}

export async function purgeAllTrash(
  git: GitHost,
  handle: SessionHandle,
  manifest: Manifest,
): Promise<{ itemCount: number; orphanCount: number }> {
  const trashed = listTrashed(manifest);
  const allDeletedBlobs = new Set<string>();

  // Purge each trashed item
  for (const [id] of trashed) {
    const deleted = await purgeItem(git, id, manifest);
    deleted.forEach((b) => allDeletedBlobs.add(b));
  }

  // Collect all referenced attachment IDs from remaining items
  const referenced = new Set<string>();
  for (const entry of Object.values(manifest.items)) {
    for (const att of entry.attachment_summaries ?? []) {
      referenced.add(att.id);
    }
  }

  // Scan for orphan blobs
  let orphanCount = 0;
  try {
    const blobFiles = await git.listDir('attachments');
    for (const filename of blobFiles) {
      const id = filename.replace(/\.bin$/, '');
      if (!referenced.has(id) && !allDeletedBlobs.has(id)) {
        try {
          await git.deleteBlob(`attachments/${filename}`, `purge orphan: ${id}`);
          orphanCount++;
        } catch { /* ignore */ }
      }
    }
  } catch { /* attachments dir may not exist */ }

  // Write manifest once
  if (trashed.length > 0 || orphanCount > 0) {
    await encryptAndWriteManifest(
      git, handle, manifest,
      `trash: purge ${trashed.length} items + ${orphanCount} orphan blobs`,
    );
  }

  return { itemCount: trashed.length, orphanCount };
}

/// Remove attachments matching the given IDs from the item.
/// Returns the removed AttachmentRefs (so the caller can deleteBlob the
/// underlying bytes). IDs not present in the item's attachments are
/// silently ignored.
export async function removeAttachmentsFromItem(
  git: GitHost,
  handle: SessionHandle,
  itemId: ItemId,
  idsToRemove: string[],
): Promise<AttachmentRef[]> {
  let item: Item;
  try {
    item = await fetchAndDecryptItem(git, handle, itemId);
  } catch {
    throw new Error(`removeAttachmentsFromItem: item ${itemId} not found`);
  }
  const removeSet = new Set(idsToRemove);
  const removed: AttachmentRef[] = [];
  const kept: AttachmentRef[] = [];
  for (const att of item.attachments) {
    if (removeSet.has(att.id)) {
      removed.push(att);
    } else {
      kept.push(att);
    }
  }
  item.attachments = kept;
  item.modified = Math.floor(Date.now() / 1000);
  const commitMsg = `remove ${removed.length} attachment(s) from item ${itemId}`;
  await encryptAndWriteItem(git, handle, itemId, item, commitMsg);
  await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`);
  return removed;
}

// --- Manifest entry derivation ---

/**
 * Project a decrypted Item into its ManifestEntry shape for browse-without-
 * decrypt views. Both router files use this; defined here (the SW's
 * vault-orchestration home) instead of duplicated in each router. Moved out
 * of popup-only.ts / content-callable.ts as part of Plan C Phase 2 (P1.9).
 */
export function itemToManifestEntry(item: Item): ManifestEntry {
  return {
    id: item.id,
    type: item.type,
    title: item.title,
    tags: item.tags,
    favorite: item.favorite,
    group: item.group,
    icon_hint: (item.core.type === 'login' && item.core.url)
      ? safeHostname(item.core.url) : undefined,
    modified: item.modified,
    trashed_at: item.trashed_at,
    attachment_summaries: item.attachments.map((a) => ({
      id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
    })),
  };
}

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

// --- Vault status (Plan C Phase 6) ---

/**
 * Return the cached vault status for the sidebar indicator. Reads cached sync
 * metadata off the GitHost (populated by the `sync` handler) plus a live count
 * of active (non-trashed) items from the in-memory manifest. Does NOT touch
 * the network — sync is user-initiated (spec 2026-05-04, Phase 6). The
 * Pick-typed gitHost param both avoids a circular import of the router's
 * PopupState and structurally forbids a network call from here.
 */
export function handleGetVaultStatus(
  state: {
    gitHost: Pick<GitHost, 'lastSyncAt' | 'ahead' | 'behind'> | null;
    manifest: Manifest | null;
  },
): GetVaultStatusResponse | { ok: false; error: string } {
  if (!state.gitHost) return { ok: false, error: 'vault_locked' };
  const pendingItems = state.manifest ? listItems(state.manifest).length : 0;
  return {
    ok: true,
    data: {
      ahead: state.gitHost.ahead,
      behind: state.gitHost.behind,
      lastSyncAt: state.gitHost.lastSyncAt,
      pendingItems,
    },
  };
}
