// api.jsx — single client for canon_lm_server (auth, data, admin).
// Lives on window.API so every babel-loaded JSX file can share it.

(function () {
  const LS_TOKEN  = 'gbr_token';
  const LS_USER   = 'gbr_user';
  const LS_SERVER = 'gbr_server_url';
  const LS_LLM    = 'gbr_llm_url';
  // Defaults are RELATIVE paths so the same UI works when served from any
  // hostname (localhost, Tailscale, Funnel). Python canon_lm_server mounts:
  //   /         → static webapp
  //   /api/*    → auth + threads + admin
  //   /llm/*    → reverse-proxied to M3 zigu_engine_rs
  const DEFAULT_SERVER = '/api';
  const DEFAULT_LLM    = '/llm';

  function _read(key)  { try { return localStorage.getItem(key); } catch (_) { return null; } }
  function _write(key, v) { try { localStorage.setItem(key, v); } catch (_) {} }
  function _del(key)    { try { localStorage.removeItem(key); } catch (_) {} }

  // One-time migration: earlier builds stored absolute http://… URLs. We now
  // default to relative /api + /llm, so wipe any stale absolute values so they
  // don't override the new defaults.
  for (const k of [LS_SERVER, LS_LLM]) {
    const v = _read(k);
    if (v && /^https?:\/\//i.test(v)) _del(k);
  }

  const API = {
    // ----- storage -----
    get serverUrl() { return (_read(LS_SERVER) || DEFAULT_SERVER).replace(/\/$/, ''); },
    setServerUrl(v) { _write(LS_SERVER, (v || DEFAULT_SERVER).replace(/\/$/, '')); },
    get llmUrl() { return (_read(LS_LLM) || DEFAULT_LLM).replace(/\/$/, ''); },
    setLlmUrl(v) { _write(LS_LLM, (v || DEFAULT_LLM).replace(/\/$/, '')); },
    get token() { return _read(LS_TOKEN); },
    setToken(v) { if (v) _write(LS_TOKEN, v); else _del(LS_TOKEN); },
    get user() {
      const s = _read(LS_USER);
      try { return s ? JSON.parse(s) : null; } catch (_) { return null; }
    },
    setUser(v) { if (v) _write(LS_USER, JSON.stringify(v)); else _del(LS_USER); },
    clear() { _del(LS_TOKEN); _del(LS_USER); },

    // ----- low-level fetch -----
    _url(path) { return this.serverUrl + path; },
    _headers(extra) {
      const h = { 'Content-Type': 'application/json', ...(extra || {}) };
      const t = this.token;
      if (t) h['Authorization'] = 'Bearer ' + t;
      return h;
    },

    async fetch(path, options) {
      options = options || {};
      const { json, headers, ...rest } = options;
      const init = { ...rest, headers: this._headers(headers) };
      if (json !== undefined) init.body = JSON.stringify(json);
      const res = await fetch(this._url(path), init);
      if (!res.ok) {
        let detail = 'HTTP ' + res.status;
        try { const j = await res.clone().json(); if (j && j.detail) detail = j.detail; } catch (_) {}
        const e = new Error(detail);
        e.status = res.status;
        throw e;
      }
      return res;
    },

    async json(path, options) {
      const r = await this.fetch(path, options);
      if (r.status === 204) return null;
      return r.json();
    },

    // ----- auth -----
    async login(email, password) {
      const data = await this.json('/auth/login', { method: 'POST', json: { email, password } });
      this.setToken(data.access_token);
      this.setUser(data.user);
      return data;
    },
    async register(opts) {
      // Accepts { email, password, display_name, invite_code, accepted_tos }.
      // Backwards-compatible with the old (email, password, displayName) call
      // shape so any unmigrated caller still works.
      let body;
      if (typeof opts === 'string') {
        body = { email: opts, password: arguments[1], display_name: arguments[2] || null };
      } else {
        body = {
          email: opts.email,
          password: opts.password,
          display_name: opts.display_name ?? opts.displayName ?? null,
          invite_code: opts.invite_code ?? null,
          accepted_tos: opts.accepted_tos ?? null,
        };
      }
      const data = await this.json('/auth/register', { method: 'POST', json: body });
      this.setToken(data.access_token);
      this.setUser(data.user);
      return data;
    },
    async me() {
      const u = await this.json('/auth/me');
      this.setUser(u);
      return u;
    },
    // Self-service account management.
    async updateProfile(patch) {
      // Currently only display_name is mutable server-side.
      const u = await this.json('/auth/me', { method: 'PATCH', json: patch });
      this.setUser(u);
      return u;
    },
    async changePassword(currentPassword, newPassword) {
      return this.json('/auth/change-password', {
        method: 'POST',
        json: { current_password: currentPassword, new_password: newPassword },
      });
    },
    async deleteSelf(password) {
      const result = await this.json('/auth/me', { method: 'DELETE', json: { password } });
      this.clear();
      return result;
    },
    async health() { return this.json('/health'); },

    // ----- threads & messages -----
    listThreads(q) { return this.json('/threads' + (q ? '?q=' + encodeURIComponent(q) : '')); },
    createThread(body) { return this.json('/threads', { method: 'POST', json: body || {} }); },
    getThread(id) { return this.json('/threads/' + id); },
    patchThread(id, patch) { return this.json('/threads/' + id, { method: 'PATCH', json: patch }); },
    deleteThread(id) { return this.json('/threads/' + id, { method: 'DELETE' }); },
    appendMessage(threadId, role, content) {
      return this.json('/threads/' + threadId + '/messages', {
        method: 'POST',
        json: { role, content },
      });
    },

    // ----- settings -----
    getSettings() { return this.json('/settings'); },
    putSettings(patch) { return this.json('/settings', { method: 'PUT', json: patch }); },

    // ----- admin (require is_admin) -----
    adminSystem() { return this.json('/admin/system'); },
    adminDb()     { return this.json('/admin/db'); },
    adminListUsers(q)   { return this.json('/admin/users' + (q ? '?q=' + encodeURIComponent(q) : '')); },
    adminToggleUserAdmin(id, isAdmin) { return this.json('/admin/users/' + id + '/admin', { method: 'PATCH', json: { is_admin: isAdmin } }); },
    adminDeleteUser(id) { return this.json('/admin/users/' + id, { method: 'DELETE' }); },
    adminCreateUser(body) { return this.json('/admin/users', { method: 'POST', json: body }); },
    adminUpdateUser(id, patch) { return this.json('/admin/users/' + id, { method: 'PATCH', json: patch }); },
    adminResetUserPassword(id) { return this.json('/admin/users/' + id + '/reset-password', { method: 'POST', json: {} }); },
    adminListInvites() { return this.json('/admin/invites'); },
    adminCreateInvite(body) { return this.json('/admin/invites', { method: 'POST', json: body || {} }); },
    adminDeleteInvite(code) { return this.json('/admin/invites/' + encodeURIComponent(code), { method: 'DELETE' }); },
    adminListThreads(q) { return this.json('/admin/threads' + (q ? '?q=' + encodeURIComponent(q) : '')); },
    adminDeleteThread(id) { return this.json('/admin/threads/' + id, { method: 'DELETE' }); },

    adminListDatasets() { return this.json('/admin/datasets'); },
    adminUploadDataset(file, onProgress) {
      return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open('POST', this._url('/admin/datasets/upload?filename=' + encodeURIComponent(file.name)));
        if (this.token) xhr.setRequestHeader('Authorization', 'Bearer ' + this.token);
        xhr.setRequestHeader('Content-Type', 'application/octet-stream');
        xhr.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); };
        xhr.onload = () => {
          if (xhr.status >= 200 && xhr.status < 300) {
            try { resolve(JSON.parse(xhr.responseText)); } catch (_) { resolve({}); }
          } else {
            let detail = 'HTTP ' + xhr.status;
            try { const j = JSON.parse(xhr.responseText); if (j && j.detail) detail = j.detail; } catch (_) {}
            reject(Object.assign(new Error(detail), { status: xhr.status }));
          }
        };
        xhr.onerror = () => reject(new Error('Network error during upload'));
        xhr.send(file);
      });
    },
    adminDeleteDataset(name) { return this.json('/admin/datasets/' + encodeURIComponent(name), { method: 'DELETE' }); },
    async adminDownloadDataset(name) {
      const r = await this.fetch('/admin/datasets/' + encodeURIComponent(name) + '/download');
      const blob = await r.blob();
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url; a.download = name;
      document.body.appendChild(a); a.click(); a.remove();
      URL.revokeObjectURL(url);
    },

    adminListTrainingScripts() { return this.json('/admin/training/scripts'); },
    adminListTrainingJobs(limit) { return this.json('/admin/training/jobs' + (limit ? '?limit=' + limit : '')); },
    adminLaunchTraining(scriptName) { return this.json('/admin/training/jobs', { method: 'POST', json: { script_name: scriptName } }); },
    adminStopTraining(id) { return this.json('/admin/training/jobs/' + id + '/stop', { method: 'POST' }); },
    adminDeleteTrainingJob(id) { return this.json('/admin/training/jobs/' + id, { method: 'DELETE' }); },
    adminTrainingLogChunk(id, offset, maxBytes) {
      const qs = new URLSearchParams({ offset: String(offset || 0) });
      if (maxBytes) qs.set('max_bytes', String(maxBytes));
      return this.json('/admin/training/jobs/' + id + '/log?' + qs.toString());
    },

    // ----- live model identity (zigu_engine_rs /v1/models) -----
    // Returns the canonical name of the currently-loaded model (e.g.
    // "GBR-LM-V22"). The Rust engine asks its CPN sidecar's /health for the
    // value, so the UI auto-tracks whatever CPN_VERSION the M3 box was
    // started with — no UI redeploy needed when a new V## is trained.
    // Resolves to null if the engine is unreachable.
    async liveModel() {
      try {
        const res = await fetch(this.llmUrl + '/v1/models', {
          headers: { 'Content-Type': 'application/json' },
        });
        if (!res.ok) return null;
        const obj = await res.json();
        const item = obj && obj.data && obj.data[0];
        return (item && item.id) || null;
      } catch (_) {
        return null;
      }
    },

    // ----- streaming chat -----
    // Chat completions go to the LLM URL (default: M3 Rust zigu_engine_rs). The
    // Rust engine has no auth, so the bearer header is omitted there.
    // Body must include { model, messages, temperature, top_p, max_tokens }.
    // Persistence (saving user/assistant turns) is the caller's responsibility —
    // use API.appendMessage(thread_id, role, content) to write to the Python DB.
    streamChatCompletion(body, handlers) {
      const ctrl = new AbortController();
      const { onChunk, onDone, onError } = handlers || {};
      // NOTE 2026-05-12: zigu_engine_rs streaming path bypasses the CPN
      // sidecar and emits a hardcoded "day4-stub" (server.rs:280-282 +
      // STUB_STREAM_CHUNKS at line 456). Until that lands, request
      // non-streaming and synthesize a single onChunk from the JSON body
      // so the UI still updates the asst bubble.
      (async () => {
        try {
          const res = await fetch(this.llmUrl + '/v1/chat/completions', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ ...body, stream: false }),
            signal: ctrl.signal,
          });
          if (!res.ok) {
            let detail = 'HTTP ' + res.status;
            try { const j = await res.json(); if (j && j.detail) detail = j.detail; } catch (_) {}
            throw Object.assign(new Error(detail), { status: res.status });
          }
          const obj = await res.json();
          const choice = obj && obj.choices && obj.choices[0];
          const acc = (choice && choice.message && choice.message.content) || '';
          const model = obj.model || null;
          const fingerprint = obj.system_fingerprint || null;
          if (acc) onChunk && onChunk(acc, acc);
          onDone && onDone({ content: acc, model, system_fingerprint: fingerprint });
        } catch (e) {
          if (e.name === 'AbortError') { onDone && onDone({ aborted: true, content: '' }); return; }
          onError && onError(e);
        }
      })();
      return ctrl;
    },
  };

  window.API = API;

  // OS-aware modifier key + shortcut labels. JSX files read these instead of
  // hard-coding the Mac Cmd glyph so Windows/Linux users see "Ctrl".
  const _platform = (navigator.platform || navigator.userAgent || '').toLowerCase();
  window.IS_MAC = /mac|iphone|ipad/i.test(_platform);
  window.MOD_KEY = window.IS_MAC ? '⌘' : 'Ctrl';
  window.SHIFT_KEY = window.IS_MAC ? '⇧' : 'Shift';
  window.ENTER_KEY = window.IS_MAC ? '↵' : 'Enter';

  // ---------- theme apply helper ----------
  // theme: 'dark' | 'light' | 'system'. We cache the last applied theme in
  // localStorage so the next page load can apply it before /settings round-trips.
  const LS_THEME = 'gbr_theme_cache';
  const _systemDark = () => window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;

  function _resolveTheme(t) {
    if (t === 'system') return _systemDark() ? 'dark' : 'light';
    return t === 'light' ? 'light' : 'dark';
  }

  window.applyTheme = function(theme) {
    try { localStorage.setItem(LS_THEME, theme || 'dark'); } catch (_) {}
    const root = document.documentElement;
    const resolved = _resolveTheme(theme);
    if (resolved === 'light') {
      root.setAttribute('data-theme', 'light');
      root.classList.add('light');
    } else {
      root.removeAttribute('data-theme');
      root.classList.remove('light');
    }
  };

  // Apply cached theme as early as possible (avoids dark-flash on light users).
  try {
    const cached = localStorage.getItem(LS_THEME) || 'dark';
    window.applyTheme(cached);
  } catch (_) {}

  // If user is on 'system', track OS-level changes live.
  if (window.matchMedia) {
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
      try {
        const t = localStorage.getItem(LS_THEME);
        if (t === 'system') window.applyTheme('system');
      } catch (_) {}
    });
  }
})();
