/* Cassandra — browser-side portfolio (Phase G).
 *
 * The server never persists holdings. The pie lives in this browser's
 * localStorage under `cassandra.pie`; the server only knows what
 * tickers the universe contains (anonymously). This module:
 *
 *  1. On dashboard load, hydrates the portfolio panel from localStorage.
 *  2. Fetches /api/universe (gzipped, same for every user) and computes
 *     P/L locally.
 *  3. Refreshes prices every 60s.
 *  4. On /upload submission, POSTs to /api/portfolio/parse, stashes the
 *     returned pie in localStorage, redirects to the dashboard.
 *  5. The "analyze" button POSTs the pie + prices to /api/analyze and
 *     renders the returned commentary inline.
 */
(function () {
  'use strict';

  const STORAGE_KEY = 'cassandra.pie';
  const UNIVERSE_REFRESH_MS = 60_000;

  // Compliance feature flags — server emits these as data attributes on
  // #pf-mount (see app/templates/dashboard.html). When false the related
  // UI is suppressed and the corresponding API call is skipped.
  function flagEnabled(name) {
    const m = document.getElementById('pf-mount');
    return !!(m && m.dataset && m.dataset[name] === 'true');
  }
  function aiEnabled()   { return flagEnabled('aiEnabled');   }
  function syncEnabled() { return flagEnabled('syncEnabled'); }

  // --- localStorage ------------------------------------------------------

  function loadPie() {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      return raw ? JSON.parse(raw) : null;
    } catch (e) {
      console.warn('cassandra.pie: corrupt localStorage, clearing', e);
      localStorage.removeItem(STORAGE_KEY);
      return null;
    }
  }

  function savePie(pie) {
    pie.imported_at = new Date().toISOString();
    localStorage.setItem(STORAGE_KEY, JSON.stringify(pie));
  }

  function clearPie() {
    localStorage.removeItem(STORAGE_KEY);
  }

  // Cache the AI analysis inside the pie so it survives auto-refreshes
  // and full page reloads. Cleared when the pie itself is forgotten or
  // replaced. We re-fetch on demand by clicking "Regenerate".
  function saveAnalysis(analysis) {
    const pie = loadPie();
    if (!pie) return;
    pie.analysis = analysis;
    localStorage.setItem(STORAGE_KEY, JSON.stringify(pie));
  }

  // --- Universe ----------------------------------------------------------

  let universeCache = null;
  let universeFetchedAt = 0;

  async function fetchUniverse() {
    const r = await fetch('/api/universe', {
      headers: { 'Accept': 'application/json' },
      credentials: 'same-origin',
    });
    if (!r.ok) throw new Error('universe: HTTP ' + r.status);
    universeCache = await r.json();
    universeFetchedAt = Date.now();
    return universeCache;
  }

  function priceFor(ticker) {
    if (!universeCache || !universeCache.tickers) return null;
    return universeCache.tickers[ticker] || null;
  }

  // --- FX conversion -----------------------------------------------------

  // fx[CCY] = units of CCY per 1 USD (USD itself is 1.0).
  // To convert X-currency value to Y-currency: value_y = value_x * (fx[Y] / fx[X]).
  function convertCurrency(value, fromCcy, toCcy, fx) {
    if (value == null || !isFinite(value)) return null;
    if (!fromCcy || !toCcy || fromCcy === toCcy) return value;
    if (!fx || !fx[fromCcy] || !fx[toCcy]) return null;  // missing rate
    return value * (fx[toCcy] / fx[fromCcy]);
  }

  // --- P/L computation ---------------------------------------------------

  function enrichPosition(p, base, fx) {
    // T212 reports `invested value` (and therefore avg_cost) in the pie's
    // base currency — so the avg row is ALREADY in base. The current
    // price from Yahoo is in the ticker's local currency and must be
    // converted before subtracting.
    const q = priceFor(p.yahoo_ticker);
    const priceLocal = q ? q.p : null;
    const priceCcy = (q && q.c) || p.currency || null;
    const priceBase = convertCurrency(priceLocal, priceCcy, base, fx);

    const value = (priceBase != null && p.qty != null) ? priceBase * p.qty : null;
    const invested = (p.avg_cost != null && p.qty != null) ? p.avg_cost * p.qty : null;
    const ppl = (value != null && invested != null) ? value - invested : null;
    const ppl_pct = (value != null && invested) ? (value / invested - 1) * 100 : null;
    const d1 = q && q.d ? q.d['1d'] : null;

    return Object.assign({}, p, {
      _current_price_local: priceLocal,
      _current_price_base: priceBase,
      _currency: priceCcy,    // for the currency-mix pills
      _base_currency: base,
      _value: value,
      _invested: invested,
      _ppl: ppl,
      _ppl_pct: ppl_pct,
      _change_1d: d1,
      _fx_missing: priceLocal != null && priceBase == null,
    });
  }

  function aggregate(positions) {
    let totalValue = 0, totalInvested = 0, missingPrice = 0;
    const byCcy = {};
    for (const r of positions) {
      if (r._value != null) {
        totalValue += r._value;
        if (r._currency) byCcy[r._currency] = (byCcy[r._currency] || 0) + r._value;
      } else {
        missingPrice++;
      }
      if (r._invested != null) totalInvested += r._invested;
    }
    return {
      n_positions: positions.length,
      missing_price: missingPrice,
      total_value: totalValue || null,
      total_invested: totalInvested || null,
      total_ppl: (totalValue && totalInvested) ? totalValue - totalInvested : null,
      total_ppl_pct: (totalValue && totalInvested) ? (totalValue / totalInvested - 1) * 100 : null,
      by_currency: byCcy,
    };
  }

  // --- Rendering ---------------------------------------------------------

  function fmt(n, opts) {
    if (n == null || !isFinite(n)) return '—';
    const o = Object.assign({ minimumFractionDigits: 2, maximumFractionDigits: 2 }, opts || {});
    return Number(n).toLocaleString(undefined, o);
  }
  function signed(n) {
    if (n == null || !isFinite(n)) return '—';
    return (n >= 0 ? '+' : '') + fmt(n);
  }
  function pct(n) {
    if (n == null || !isFinite(n)) return '—';
    return (n >= 0 ? '+' : '') + Number(n).toFixed(2) + '%';
  }
  function cls(n) {
    if (n == null) return 'neu';
    return n >= 0 ? 'pos' : 'neg';
  }
  function esc(s) {
    if (s == null) return '';
    return String(s).replace(/[&<>"']/g, c => ({
      '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
    }[c]));
  }

  // Tiny one-shot flag the orphan-cleanup path sets so renderEmpty can
  // surface a plain-English "your previous backup needs to be re-uploaded"
  // line. Read-once; cleared as soon as it's shown.
  function consumeBackupExpiredNotice() {
    try {
      if (sessionStorage.getItem('cassandra.sync.backupExpired') === '1') {
        sessionStorage.removeItem('cassandra.sync.backupExpired');
        return true;
      }
    } catch (e) { /* ignore */ }
    return false;
  }

  function renderEmpty(mount) {
    const expired = consumeBackupExpiredNotice();
    const notice = expired
      ? '<div class="empty-notice">Your encrypted cloud backup expired. ' +
          'Please re-upload your portfolio to refresh it.' +
        '</div>'
      : '';
    var panel = document.getElementById('portfolio-panel');
    if (panel) panel.classList.add('pf-empty');
    mount.innerHTML =
      '<div class="empty" style="padding:16px;">' +
        notice +
        'No positions yet — click <strong>edit</strong> to add one, or ' +
        '<a href="/settings#import">import a CSV from your broker →</a>' +
      '</div>';
    // The form is only ever visible in edit mode — never auto-shown by
    // the empty state. Defensive: ensure it stays hidden here.
    var form = document.getElementById('pf-add-form');
    if (form) form.hidden = true;
  }

  // Silently remove an unrecoverable cloud blob and re-render. The user
  // sees the standard empty state with a soft one-liner — no jargon, no
  // extra buttons. The decision to remove is safe: the blob is already
  // permanently undecryptable, so we're cleaning up dead state, not
  // discarding user data.
  async function autoCleanStaleBlob(mount) {
    try {
      await window.CassandraSync.disableSync();
    } catch (e) {
      console.warn('cassandra.sync: auto-clean of stale blob failed', e);
    }
    try {
      sessionStorage.setItem('cassandra.sync.backupExpired', '1');
    } catch (e) { /* ignore */ }
    renderEmpty(mount);
  }

  function renderRestoreFromCloud(mount, status) {
    const lastSynced = status.updated_at
      ? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
      : '—';
    mount.innerHTML =
      '<div style="padding:16px;">' +
        '<div class="result__head">▸ Restore from cloud</div>' +
        '<div class="result__row" style="margin-bottom:12px;">' +
          'A synced portfolio is available for this account (last synced ' +
          esc(lastSynced) + '). Enter your PIN to load it on this browser.' +
        '</div>' +
        '<form id="pf-restore-form" style="display:flex; gap:10px; align-items:center;">' +
          '<input id="pf-restore-pin" type="password" inputmode="numeric" ' +
            'autocomplete="off" placeholder="PIN" ' +
            'class="modal-input" style="flex:0 0 200px; margin-bottom:0;">' +
          '<button type="submit" class="settings-btn">Restore</button>' +
          '<a href="/settings#import" class="settings-row__hint" style="margin-left:auto;">' +
            'or import a new CSV →</a>' +
        '</form>' +
        '<div id="pf-restore-err" class="pf-warn" hidden style="margin-top:10px;"></div>' +
      '</div>';

    const form = document.getElementById('pf-restore-form');
    const pin = document.getElementById('pf-restore-pin');
    const err = document.getElementById('pf-restore-err');
    form.addEventListener('submit', async (e) => {
      e.preventDefault();
      err.hidden = true;
      const value = (pin.value || '').trim();
      if (!value) return;
      try {
        const pie = await window.CassandraSync.pullSync(value);
        if (!pie) {
          err.textContent = 'No synced portfolio found.';
          err.hidden = false;
          return;
        }
        savePie(pie);
        mountAndRender();
      } catch (e2) {
        if (e2 && e2.name === 'StaleBlobError') {
          // Pepper rotated since the blob was written — silently clean
          // up and fall through to the empty state with a soft notice.
          autoCleanStaleBlob(mount);
          return;
        }
        err.textContent = (e2 && e2.name === 'BadPinError')
          ? 'Incorrect PIN.'
          : (e2.message || 'Could not restore.');
        err.hidden = false;
      }
    });
  }

  function renderPanel(mount, pie, enriched, agg) {
    var panel = document.getElementById('portfolio-panel');
    if (panel) panel.classList.remove('pf-empty');
    // The empty-state path forces the add form visible. When we move
    // back to a populated view we re-hide it — unless edit mode is on,
    // in which case the form stays visible for ongoing edits.
    var form = document.getElementById('pf-add-form');
    if (form && panel && !panel.classList.contains('pf-editing')) {
      form.hidden = true;
    }

    const ccyPills = Object.keys(agg.by_currency)
      .sort((a, b) => agg.by_currency[b] - agg.by_currency[a])
      .map(c => {
        const share = agg.total_value ? (agg.by_currency[c] / agg.total_value * 100) : 0;
        return '<span class="pf-pill">' + esc(c) + ' ' + share.toFixed(0) + '%</span>';
      })
      .join(' ');

    const rows = enriched.map(p => {
      // 'Last' shows the local-currency price (matches Yahoo + T212 display).
      // P/L column is in the pie's base currency after FX conversion.
      const lastDisplay = p._current_price_local != null
        ? fmt(p._current_price_local) +
          (p._currency && p._currency !== pie.base_currency
            ? ' <span class="pf-ccy">' + esc(p._currency) + '</span>'
            : '')
        : '—';
      const fxBadge = p._fx_missing
        ? ' <span class="pf-ccy" title="FX rate missing">' + esc(p._currency || '?') + '</span>'
        : '';
      return '<tr>' +
        '<td class="label">' + esc(p.yahoo_ticker) + '</td>' +
        '<td>' + esc(p.name || '') + '</td>' +
        '<td class="num mobile-hide">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
        '<td class="num neu mobile-hide">' + fmt(p.avg_cost) + '</td>' +
        '<td class="num">' + lastDisplay + fxBadge + '</td>' +
        '<td class="num ' + cls(p._ppl) + '">' + signed(p._ppl) + '</td>' +
        '<td class="num ' + cls(p._ppl_pct) + '">' + pct(p._ppl_pct) + '</td>' +
        '<td class="pf-row-del-cell">' +
          '<button type="button" class="pf-row-del" data-idx="' + p._orig_idx + '" ' +
                  'title="Remove this position" aria-label="Remove">\xd7</button>' +
        '</td>' +
        '</tr>';
    }).join('');

    const importedAt = pie.imported_at
      ? new Date(pie.imported_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
      : '—';
    // Prices' as-of timestamp comes from the universe response; that's
    // the moment the server fetched the quotes this panel just used.
    // Falls back to "now" if universeCache hasn't populated yet.
    const pricesAsOf = (universeCache && universeCache.as_of)
      ? new Date(universeCache.as_of).toISOString().replace('T', ' ').slice(0, 19) + ' UTC'
      : new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';

    const nameTooltip = 'Imported ' + importedAt + ' — kept in this browser only';

    const missingNote = agg.missing_price > 0
      ? '<div class="pf-warn">' + agg.missing_price +
        ' position(s) have no live price — universe may be catching up.</div>'
      : '';

    mount.innerHTML =
      '<div class="pf-overall">' +
        '<div class="pf-overall__head">' +
          '<span class="pf-name has-tip" title="' + esc(nameTooltip) + '">' +
            esc(pie.pie_name || 'Portfolio') +
          '</span>' +
          '<span class="pf-as-of" title="Prices fetched by the server at this time. Browser recomputes P/L on every refresh (~60s).">' +
            'prices ' + esc(pricesAsOf) +
          '</span>' +
        '</div>' +
        '<div class="pf-overall__grid">' +
          '<div class="pf-stat"><div class="pf-stat-label">Total</div>' +
            '<div class="pf-stat-value">' + fmt(agg.total_value) +
            ' <span class="pf-ccy">' + esc(pie.base_currency || '') + '</span></div></div>' +
          '<div class="pf-stat"><div class="pf-stat-label">Invested</div>' +
            '<div class="pf-stat-value">' + fmt(agg.total_invested) + '</div></div>' +
          '<div class="pf-stat"><div class="pf-stat-label">Unrealised P/L</div>' +
            '<div class="pf-stat-value ' + cls(agg.total_ppl) + '">' + signed(agg.total_ppl) +
            (agg.total_ppl_pct != null
              ? ' <span class="pf-pct">(' + pct(agg.total_ppl_pct) + ')</span>'
              : '') +
            '</div></div>' +
          '<div class="pf-stat"><div class="pf-stat-label">Positions</div>' +
            '<div class="pf-stat-value">' + agg.n_positions + '</div></div>' +
          '<div class="pf-stat" style="grid-column: span 2;">' +
            '<div class="pf-stat-label">Currency mix</div>' +
            '<div class="pf-pills">' + (ccyPills || '—') + '</div></div>' +
        '</div>' +
      '</div>' +
      missingNote +
      '<table class="dense">' +
        '<thead><tr>' +
          '<th>Ticker</th><th>Name</th>' +
          '<th class="num mobile-hide">Qty</th><th class="num mobile-hide">Avg</th>' +
          '<th class="num">Last</th><th class="num">P/L</th>' +
          '<th class="num">%</th>' +
          '<th></th>' +
        '</tr></thead>' +
        '<tbody>' + rows + '</tbody>' +
      '</table>' +
      // The "Generate" button only renders when there's no cached
      // analysis yet. Once one exists, regeneration moves inside the
      // collapsible analysis box (see showAnalysis below). The "Forget
      // this pie" button is destructive enough that it lives in
      // edit-mode only — CSS in portfolio.css hides it when the
      // portfolio panel isn't carrying the .pf-editing class.
      '<div class="pf-actions">' +
        (aiEnabled() && !(pie.analysis && pie.analysis.content)
          ? '<button id="pf-analyze" type="button">Generate AI analysis</button>'
          : '') +
        '<button id="pf-forget" type="button" class="pf-secondary">Forget this pie</button>' +
      '</div>' +
      '<div id="pf-analysis" class="pf-analysis" hidden></div>';

    const analyzeBtn = document.getElementById('pf-analyze');
    if (analyzeBtn) {
      analyzeBtn.addEventListener('click', () => runAnalysis(pie, enriched));
    }
    document.getElementById('pf-forget').addEventListener('click', () => {
      if (confirm('Remove the saved pie from this browser? The server holds nothing — this is local.')) {
        clearPie();
        mountAndRender();
      }
    });

    // Re-hydrate any cached AI analysis so the 60s auto-refresh doesn't
    // wipe it. Rendered expanded so the user keeps seeing the body they
    // just generated — collapsing it under their cursor every minute
    // reads as "the analysis disappeared". They can still click the
    // header to collapse manually within a single refresh window. The
    // regenerate callback closes over the current pie/enriched so a
    // click rebuilds the analysis with the same context that drove
    // the initial render.
    if (aiEnabled() && pie.analysis && pie.analysis.content) {
      showAnalysis(pie.analysis, { open: true }, () => runAnalysis(pie, enriched));
    }
  }

  function showAnalysis(analysis, opts, onRegenerate) {
    const out = document.getElementById('pf-analysis');
    if (!out) return;
    const openAttr = (opts && opts.open) ? ' open' : '';
    out.hidden = false;
    out.innerHTML =
      '<details class="pf-analysis__details"' + openAttr + '>' +
        '<summary class="pf-analysis__head">' +
          '<span class="pf-analysis__head-left">' +
            'AI analysis' +
          '</span>' +
          '<span class="pf-analysis__head-right">' +
            '<span class="pf-as-of">' +
              esc((analysis.generated_at || '').slice(0, 19).replace('T', ' ')) +
              ' UTC</span>' +
            (onRegenerate
              ? '<button id="pf-regen" type="button" class="pf-regen"' +
                ' title="Run the analysis again on the current portfolio">' +
                'Regenerate</button>'
              : '') +
          '</span>' +
        '</summary>' +
        '<pre class="pf-analysis__body">' + esc(analysis.content) + '</pre>' +
      '</details>';
    if (onRegenerate) {
      const regen = document.getElementById('pf-regen');
      if (regen) {
        regen.addEventListener('click', (e) => {
          // The button lives inside <summary>; clicking it would
          // normally toggle the <details> open/closed. Suppress the
          // default toggle and the bubble so only our regen runs.
          e.preventDefault();
          e.stopPropagation();
          onRegenerate();
        });
      }
    }
  }

  async function runAnalysis(pie, enriched) {
    const out = document.getElementById('pf-analysis');
    // First-run click is on pf-analyze; the regenerate path is pf-regen
    // inside the details summary. Either may be the live trigger.
    const btn = document.getElementById('pf-analyze') ||
                document.getElementById('pf-regen');
    out.hidden = false;
    out.innerHTML = '<div class="empty">generating…</div>';
    if (btn) btn.disabled = true;

    // Build the prices payload from the universe cache so the server
    // doesn't have to re-fetch.
    const prices = {};
    if (universeCache && universeCache.tickers) {
      for (const p of pie.positions) {
        const q = universeCache.tickers[p.yahoo_ticker];
        if (q) prices[p.yahoo_ticker] = q;
      }
    }

    // The language toggle's data-lang attribute is the user's LIVE
    // pick — newer than user.lang in the DB if the user toggled and
    // hit Generate/Regenerate before the toggle-PATCH committed.
    // Backend prefers this value if provided (see universe.py).
    const langPill = document.getElementById('lang-toggle');
    const userLang = (langPill && langPill.dataset.lang) || 'en';

    try {
      const r = await fetch('/api/analyze', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin',
        body: JSON.stringify({
          positions: pie.positions,
          prices: prices,
          base_currency: pie.base_currency || 'GBP',
          lang: userLang,
        }),
      });
      const data = await r.json();
      if (!r.ok) {
        // FastAPI `detail` is usually a string, but some endpoints send
        // an object — e.g. the 402 paid-gate returns {code, message}.
        // Render the human-readable text either way; never the object
        // (which stringifies to the useless "[object Object]").
        const d = data && data.detail;
        const msg = (d && typeof d === 'object')
          ? (d.message || JSON.stringify(d))
          : (d || ('HTTP ' + r.status));
        out.innerHTML = '<div class="pf-warn">' + esc(msg) + '</div>';
        return;
      }
      // Persist before rendering so auto-refresh can re-hydrate.
      saveAnalysis(data);
      // Pass the regenerate callback so the in-details "Regenerate"
      // button shows up on the freshly-rendered analysis too.
      showAnalysis(data, { open: true }, () => runAnalysis(pie, enriched));
    } catch (e) {
      out.innerHTML = '<div class="pf-warn">' + esc(e.message) + '</div>';
    } finally {
      // The original button may have been replaced by showAnalysis →
      // re-fetch its handle (or null if neither id is on the page now).
      const liveBtn = document.getElementById('pf-analyze') ||
                      document.getElementById('pf-regen');
      if (liveBtn) liveBtn.disabled = false;
    }
  }

  // --- Mount / refresh ---------------------------------------------------

  async function mountAndRender() {
    const mount = document.getElementById('pf-mount');
    if (!mount) return;
    const pie = loadPie();
    if (!pie || !pie.positions || !pie.positions.length) {
      // Before falling back to "no portfolio", check whether the account
      // has a synced blob this device could restore from. Status is
      // 402 for free-tier users — getStatus() returns paid:false there
      // and we fall through to the standard empty state. When
      // PORTFOLIO_SYNC_ENABLED=false the sync route 404s, so skip the
      // status check entirely and render the standard empty state.
      let status = null;
      if (syncEnabled() && window.CassandraSync) {
        try { status = await window.CassandraSync.getStatus(); }
        catch (e) { console.warn('sync status check failed', e); }
      }
      if (status && status.paid && status.exists) {
        if (status.orphaned) {
          // Pepper rotated since the blob was written — clean up
          // silently and show the standard empty state with a soft
          // "please re-upload" notice.
          autoCleanStaleBlob(mount);
        } else {
          renderRestoreFromCloud(mount, status);
        }
      } else {
        renderEmpty(mount);
      }
      return;
    }
    try {
      if (!universeCache || Date.now() - universeFetchedAt > UNIVERSE_REFRESH_MS) {
        await fetchUniverse();
      }
    } catch (e) {
      console.warn('universe fetch failed', e);
    }
    const base = pie.base_currency || 'GBP';
    const fx = (universeCache && universeCache.fx) || null;
    const enriched = pie.positions.map((p, i) => Object.assign(enrichPosition(p, base, fx), { _orig_idx: i }))
      .sort((a, b) => (b._value || 0) - (a._value || 0));
    const agg = aggregate(enriched);
    renderPanel(mount, pie, enriched, agg);
  }

  // --- Parse primitive ---------------------------------------------------
  //
  // Hits /api/portfolio/parse and returns the parsed pie. The caller
  // decides whether to savePie() and whether to push to cloud sync — keeps
  // the post-parse decision in the inline UI script instead of buried in
  // this module.
  async function parseCsv(file) {
    const fd = new FormData();
    fd.append('file', file);
    const r = await fetch('/api/portfolio/parse', {
      method: 'POST',
      body: fd,
      credentials: 'same-origin',
    });
    const data = await r.json().catch(() => ({}));
    if (!r.ok) {
      const err = new Error(data.detail || ('HTTP ' + r.status));
      err.status = r.status;
      throw err;
    }
    return data;
  }

  // Formatting helpers exposed so inline UI scripts (like the import
  // preview in settings.html) don't have to re-implement them.
  window.CassandraPortfolio = {
    mountAndRender,
    parseCsv,
    loadPie,
    savePie,
    clearPie,
    fmt,
    signed,
    pct,
    cls,
    esc,
  };

  // Auto-mount on dashboard load and refresh every minute.
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', mountAndRender);
  } else {
    mountAndRender();
  }
  setInterval(mountAndRender, UNIVERSE_REFRESH_MS);
})();
