/* Cassandra — client-side encrypted portfolio sync.
 *
 * The server only ever sees opaque ciphertext. The browser:
 *  1. Derives an AES-GCM key from the user's PIN with PBKDF2 (600k SHA-256).
 *  2. Encrypts the pie JSON, packs salt+nonce+ct into one blob.
 *  3. POSTs the blob to /api/portfolio/sync.
 *  4. On pull, fetches the blob, reverses the steps with the PIN.
 *
 * The derived key is cached in sessionStorage so the user enters the PIN
 * at most once per browser session (cleared on tab close / logout). The
 * server-side outer wrap (see app/services/portfolio_sync.py) hardens the
 * stored ciphertext against a DB-only leak.
 *
 * Packed inner-blob format (all bytes, then base64-url for transport):
 *   byte  0:      version (currently 1)
 *   bytes 1..4:   PBKDF2 iteration count, uint32 big-endian
 *   bytes 5..20:  salt (16 bytes)
 *   bytes 21..32: nonce (12 bytes)
 *   bytes 33..:   AES-GCM ciphertext (includes 16-byte tag suffix)
 */
(function () {
  'use strict';

  const VERSION = 1;
  const ITERATIONS = 600_000;
  const SALT_LEN = 16;
  const NONCE_LEN = 12;
  const HEADER_LEN = 1 + 4 + SALT_LEN + NONCE_LEN;     // = 33

  const SESSION_KEY_STORAGE = 'cassandra.sync.key.v1';
  const SESSION_SALT_STORAGE = 'cassandra.sync.salt.v1';

  // --- byte helpers ----------------------------------------------------

  function u8concat(parts) {
    let n = 0;
    for (const p of parts) n += p.length;
    const out = new Uint8Array(n);
    let i = 0;
    for (const p of parts) { out.set(p, i); i += p.length; }
    return out;
  }

  function b64urlEncode(bytes) {
    let s = '';
    const chunk = 0x8000;
    for (let i = 0; i < bytes.length; i += chunk) {
      s += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
    }
    return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
  }

  function b64urlDecode(s) {
    const norm = s.replace(/-/g, '+').replace(/_/g, '/');
    const padded = norm + '='.repeat((4 - norm.length % 4) % 4);
    const bin = atob(padded);
    const out = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
    return out;
  }

  // --- WebCrypto -------------------------------------------------------

  async function pbkdf2Derive(pin, salt, iterations) {
    const baseKey = await crypto.subtle.importKey(
      'raw',
      new TextEncoder().encode(pin),
      { name: 'PBKDF2' },
      false,
      ['deriveKey'],
    );
    return crypto.subtle.deriveKey(
      { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
      baseKey,
      { name: 'AES-GCM', length: 256 },
      true,           // extractable: we cache raw bytes in sessionStorage
      ['encrypt', 'decrypt'],
    );
  }

  async function exportKey(key) {
    const raw = await crypto.subtle.exportKey('raw', key);
    return new Uint8Array(raw);
  }

  async function importKey(raw) {
    return crypto.subtle.importKey(
      'raw', raw, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'],
    );
  }

  // --- session cache ---------------------------------------------------

  // We cache both the raw key AND the salt that produced it, so a push
  // after upload can rebuild the same packed-blob header without
  // re-prompting for a PIN. Lives in sessionStorage so it dies with the
  // tab.
  function cacheKey(rawKey, salt) {
    try {
      sessionStorage.setItem(SESSION_KEY_STORAGE, b64urlEncode(rawKey));
      sessionStorage.setItem(SESSION_SALT_STORAGE, b64urlEncode(salt));
    } catch (e) {
      console.warn('cassandra.sync: sessionStorage write failed', e);
    }
  }

  async function getCachedKeyAndSalt() {
    const rk = sessionStorage.getItem(SESSION_KEY_STORAGE);
    const sk = sessionStorage.getItem(SESSION_SALT_STORAGE);
    if (!rk || !sk) return null;
    return {
      key: await importKey(b64urlDecode(rk)),
      salt: b64urlDecode(sk),
    };
  }

  function clearCachedKey() {
    sessionStorage.removeItem(SESSION_KEY_STORAGE);
    sessionStorage.removeItem(SESSION_SALT_STORAGE);
  }

  // --- pack / unpack ---------------------------------------------------

  function packBlob(salt, nonce, ct, iterations) {
    const header = new Uint8Array(HEADER_LEN);
    header[0] = VERSION;
    new DataView(header.buffer).setUint32(1, iterations, false);  // big-endian
    header.set(salt, 5);
    header.set(nonce, 5 + SALT_LEN);
    return u8concat([header, new Uint8Array(ct)]);
  }

  function unpackBlob(bytes) {
    if (bytes.length < HEADER_LEN + 16) {
      throw new Error('blob too small');
    }
    const version = bytes[0];
    if (version !== VERSION) {
      throw new Error('unknown sync blob version: ' + version);
    }
    const iterations = new DataView(bytes.buffer, bytes.byteOffset, HEADER_LEN)
      .getUint32(1, false);
    const salt = bytes.slice(5, 5 + SALT_LEN);
    const nonce = bytes.slice(5 + SALT_LEN, HEADER_LEN);
    const ct = bytes.slice(HEADER_LEN);
    return { version, iterations, salt, nonce, ct };
  }

  // --- encrypt / decrypt ----------------------------------------------

  /**
   * Encrypt a pie object with `pin`. Returns the packed blob as a
   * base64url string ready for POST /api/portfolio/sync. Also caches
   * the derived key in sessionStorage so subsequent pushes don't need
   * the PIN.
   */
  async function encryptPie(pie, pin) {
    const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
    const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
    const key = await pbkdf2Derive(pin, salt, ITERATIONS);
    const plaintext = new TextEncoder().encode(JSON.stringify(pie));
    const ct = await crypto.subtle.encrypt(
      { name: 'AES-GCM', iv: nonce }, key, plaintext,
    );
    cacheKey(await exportKey(key), salt);
    return b64urlEncode(packBlob(salt, nonce, ct, ITERATIONS));
  }

  /**
   * Re-encrypt with a cached key (no PIN needed). Re-uses the cached
   * salt so the blob remains decryptable with the same PIN later.
   * Returns null if no key is cached.
   */
  async function encryptPieWithCachedKey(pie) {
    const cached = await getCachedKeyAndSalt();
    if (!cached) return null;
    const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
    const plaintext = new TextEncoder().encode(JSON.stringify(pie));
    const ct = await crypto.subtle.encrypt(
      { name: 'AES-GCM', iv: nonce }, cached.key, plaintext,
    );
    return b64urlEncode(packBlob(cached.salt, nonce, ct, ITERATIONS));
  }

  /**
   * Decrypt a server blob with `pin`. Throws BadPinError on auth failure.
   * Caches the derived key on success.
   */
  class BadPinError extends Error {
    constructor() { super('Incorrect PIN'); this.name = 'BadPinError'; }
  }

  async function decryptBlob(blobB64, pin) {
    const bytes = b64urlDecode(blobB64);
    const { iterations, salt, nonce, ct } = unpackBlob(bytes);
    const key = await pbkdf2Derive(pin, salt, iterations);
    let plaintext;
    try {
      plaintext = await crypto.subtle.decrypt(
        { name: 'AES-GCM', iv: nonce }, key, ct,
      );
    } catch (_e) {
      throw new BadPinError();
    }
    cacheKey(await exportKey(key), salt);
    return JSON.parse(new TextDecoder().decode(plaintext));
  }

  // --- network ---------------------------------------------------------

  async function getStatus() {
    const r = await fetch('/api/portfolio/sync/status', {
      credentials: 'same-origin',
      headers: { 'Accept': 'application/json' },
    });
    if (r.status === 402) return { exists: false, paid: false };
    if (!r.ok) throw new Error('sync status: HTTP ' + r.status);
    const body = await r.json();
    return {
      exists: !!body.exists,
      orphaned: !!body.orphaned,
      updated_at: body.updated_at,
      paid: true,
    };
  }

  // Thrown by pullSync when the server reports the stored blob is
  // wrapped with a different server key (pepper rotation). Distinct
  // from BadPinError so the UI can swap the restore form for a
  // re-upload CTA instead of asking again for the PIN.
  class StaleBlobError extends Error {
    constructor(msg) {
      super(msg || 'Stored portfolio cannot be decrypted with the current server key.');
      this.name = 'StaleBlobError';
    }
  }

  async function pushSync(pie, pin) {
    // If a cached key exists, re-use it; otherwise derive from the PIN.
    let blob = await encryptPieWithCachedKey(pie);
    if (!blob) {
      if (!pin) throw new Error('PIN required to enable sync');
      blob = await encryptPie(pie, pin);
    }
    const r = await fetch('/api/portfolio/sync', {
      method: 'POST',
      credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ blob }),
    });
    if (!r.ok) {
      const body = await r.json().catch(() => ({}));
      throw new Error(body.detail || ('sync push: HTTP ' + r.status));
    }
    return r.json();
  }

  async function pullSync(pin) {
    const r = await fetch('/api/portfolio/sync', {
      credentials: 'same-origin',
      headers: { 'Accept': 'application/json' },
    });
    if (r.status === 404) return null;
    if (r.status === 410) {
      const body = await r.json().catch(() => ({}));
      throw new StaleBlobError(body.detail);
    }
    if (!r.ok) {
      const body = await r.json().catch(() => ({}));
      // 429 → server already throttling; bubble the message up unchanged.
      throw new Error(body.detail || ('sync pull: HTTP ' + r.status));
    }
    const { blob } = await r.json();
    return decryptBlob(blob, pin);
  }

  async function disableSync() {
    const r = await fetch('/api/portfolio/sync', {
      method: 'DELETE',
      credentials: 'same-origin',
    });
    if (!r.ok) throw new Error('sync delete: HTTP ' + r.status);
    clearCachedKey();
    return r.json();
  }

  window.CassandraSync = {
    getStatus,
    encryptPie,
    decryptBlob,
    pushSync,
    pullSync,
    disableSync,
    clearCachedKey,
    BadPinError,
    StaleBlobError,
    // Exposed for tests / debugging:
    _packBlob: packBlob,
    _unpackBlob: unpackBlob,
  };
})();
