/* global window */
// api-client.jsx — thin wrapper that the React screens call into. If Supabase
// is configured (window.CAMBAM_CONFIG), the app talks to it; otherwise it
// short-circuits and just acts on the local STORE so the app still works
// offline / before deploy.
//
// Caddie dispatches are generated out-of-band by `server/cli/caddie.js`
// (which calls `claude -p` headlessly) — this client only ever reads the
// latest published row from `caddie_articles`. No on-demand generation.
//
// To wire production:
//   1) Add a /config.js script (above this one in index.html) that sets:
//        window.CAMBAM_CONFIG = {
//          supabaseUrl:      'https://xxxx.supabase.co',
//          supabaseAnonKey:  'eyJ...',
//        };
//   2) Add Supabase JS UMD before this script:
//        <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
//      …or vendor it locally under /vendor/ to avoid CDN dependencies.

const CONFIG = window.CAMBAM_CONFIG || {};

function getSupabase() {
  if (!CONFIG.supabaseUrl || !CONFIG.supabaseAnonKey) return null;
  if (!window.supabase || !window.supabase.createClient) return null;
  if (!window._cambamSb) {
    window._cambamSb = window.supabase.createClient(CONFIG.supabaseUrl, CONFIG.supabaseAnonKey);
  }
  return window._cambamSb;
}

// Resolve the current signed-in user's row in the `members` table.
// session.user.id is the auth.users PK; members.auth_id is that FK,
// and members.id is the table's own PK (which is what all other tables
// reference via member_id). Cache once per session.
async function currentMemberId() {
  const sb = getSupabase();
  if (!sb) return null;
  if (window._cambamMemberId) return window._cambamMemberId;
  const { data: { user } } = await sb.auth.getUser();
  if (!user) return null;
  const { data } = await sb.from('members').select('id').eq('auth_id', user.id).maybeSingle();
  window._cambamMemberId = data?.id || null;
  return window._cambamMemberId;
}

const api = {
  // Whether Supabase is configured. Screens consult this to decide whether to
  // call the API or run locally.
  isConfigured() { return !!getSupabase(); },

  // ─── Auth ──────────────────────────────────────────────────────
  // Each auth method returns { ok, error?, local? }. `local: true` means
  // Supabase isn't configured and the caller should sign in against STORE.
  async signInWithEmail(email) {
    const sb = getSupabase();
    if (!sb) {
      window.STORE.set({ authed: true, me: { ...window.STORE.state.me, email } });
      return { ok: true, local: true };
    }
    const { error } = await sb.auth.signInWithOtp({
      email,
      options: { emailRedirectTo: window.location.origin + window.location.pathname },
    });
    return { ok: !error, error: error && error.message };
  },

  async signInWithProvider(provider) {
    // Passkey routes through the MFA flow, not OAuth. Reject explicitly so a
    // stray caller doesn't silently fail against Supabase's OAuth allow-list.
    if (provider === 'passkey') {
      return { ok: false, error: 'Use signInWithPasskey() — passkeys are MFA, not OAuth.' };
    }
    const sb = getSupabase();
    if (!sb) {
      window.STORE.set({ authed: true });
      return { ok: true, local: true };
    }
    const { error } = await sb.auth.signInWithOAuth({
      provider,
      options: { redirectTo: window.location.origin + window.location.pathname },
    });
    return { ok: !error, error: error && error.message };
  },

  async signOut() {
    const sb = getSupabase();
    if (sb) await sb.auth.signOut();
    window.STORE.set({ authed: false });
  },

  // Subscribe to Supabase auth state changes. The callback fires for every
  // event type ('SIGNED_IN', 'SIGNED_OUT', 'TOKEN_REFRESHED', etc.). Use
  // event === 'SIGNED_IN' to distinguish fresh sign-ins from session
  // restores (which fire 'INITIAL_SESSION'). Returns an unsubscribe fn.
  onAuthChange(cb) {
    const sb = getSupabase();
    if (!sb) return () => {};
    const { data: sub } = sb.auth.onAuthStateChange((event, session) => {
      try { cb(event, session); } catch (e) { console.warn('onAuthChange cb', e); }
    });
    return () => sub?.subscription?.unsubscribe?.();
  },

  // ─── Passkeys (custom WebAuthn flow via Edge Function) ─────────
  // Why this isn't using sb.auth.mfa.webauthn: Supabase's hosted WebAuthn
  // MFA is platform-disabled (Management API rejects enabling with "not
  // currently supported"), AND their model layers passkey on top of
  // magic-link as a second factor anyway — neither delivers the true
  // one-tap passwordless UX we want.
  //
  // The flow: an Edge Function (supabase/functions/webauthn) runs the
  // WebAuthn ceremony server-side, stores credentials in a `passkeys`
  // table, and mints a real Supabase session by calling
  // auth.admin.generateLink → returning the OTP → which the client
  // exchanges via supabase.auth.verifyOtp.
  //
  // Device hint: a localStorage flag tracks "this device has enrolled a
  // passkey here". The login screen reads it to switch into passkey-
  // primary mode (no email input — discoverable credential auth).

  PASSKEY_HINT_KEY: 'cambam-passkey-hint',
  WEBAUTHN_ENDPOINT: (CONFIG.supabaseUrl || '') + '/functions/v1/webauthn',

  webauthnSupported() {
    return typeof window !== 'undefined'
      && typeof window.PublicKeyCredential === 'function'
      && !!(window.SimpleWebAuthnBrowser
        && typeof window.SimpleWebAuthnBrowser.startRegistration === 'function');
  },

  passkeyHint() {
    try {
      const raw = localStorage.getItem(this.PASSKEY_HINT_KEY);
      if (!raw) return null;
      // Backwards-compat: legacy hint was just the string "1".
      if (raw === '1') return { email: null };
      const parsed = JSON.parse(raw);
      return parsed && typeof parsed === 'object' ? parsed : null;
    } catch { return null; }
  },

  setPasskeyHint(payload) {
    try {
      if (payload) localStorage.setItem(this.PASSKEY_HINT_KEY, JSON.stringify(payload));
      else localStorage.removeItem(this.PASSKEY_HINT_KEY);
    } catch {}
  },

  // Internal — POSTs to the webauthn Edge Function. Always sends the anon
  // key as `apikey` so the function gateway accepts the call; for
  // authenticated actions (list, delete) also sends the user's bearer
  // token so the function can resolve user.id from the JWT.
  async _webauthnCall(action, body = {}, opts = {}) {
    const sb = getSupabase();
    if (!sb) return { ok: false, error: 'Supabase not configured' };
    const headers = {
      'Content-Type': 'application/json',
      apikey: CONFIG.supabaseAnonKey,
    };
    if (opts.requireAuth) {
      const { data: { session } } = await sb.auth.getSession();
      if (!session?.access_token) return { ok: false, error: 'Not signed in' };
      headers.Authorization = `Bearer ${session.access_token}`;
    }
    const res = await fetch(this.WEBAUTHN_ENDPOINT, {
      method: 'POST',
      headers,
      body: JSON.stringify({ action, ...body }),
    });
    const json = await res.json().catch(() => ({}));
    if (!res.ok) return { ok: false, error: json.error || `HTTP ${res.status}` };
    return { ok: true, ...json };
  },

  // List enrolled passkeys for the signed-in user. Used by Settings.
  async listPasskeys() {
    const res = await this._webauthnCall('list', {}, { requireAuth: true });
    if (!res.ok) return [];
    return res.items || [];
  },

  // Enroll a new passkey. Two callers:
  //   - First-time auto-prompt modal (right after magic-link sign-in)
  //   - Settings → Add a passkey
  // Both pass the user's email; this kicks off navigator.credentials.create,
  // posts the attestation to the Edge Function for verification + storage,
  // and (if the caller was signed out) returns an OTP the client immediately
  // exchanges for a real session via verifyOtp.
  async enrollPasskey(email, friendlyName) {
    if (!this.webauthnSupported()) return { ok: false, error: 'This browser does not support passkeys' };
    const sb = getSupabase();
    if (!sb) return { ok: false, error: 'Supabase not configured' };

    const optsRes = await this._webauthnCall('register-options', { email });
    if (!optsRes.ok) return optsRes;

    let credential;
    try {
      credential = await window.SimpleWebAuthnBrowser.startRegistration({ optionsJSON: optsRes.options });
    } catch (e) {
      // NotAllowedError (user cancelled) is the common case — silent no-op.
      return { ok: false, error: e?.message || 'Passkey setup cancelled', cancelled: e?.name === 'NotAllowedError' };
    }

    const verifyRes = await this._webauthnCall('register-verify', {
      email,
      response: credential,
      friendlyName: friendlyName || null,
    });
    if (!verifyRes.ok) return verifyRes;

    // Mint a session via the returned OTP. The user may already be signed
    // in (Settings enrollment) — verifyOtp will reuse / refresh the session.
    const { data: session, error: otpErr } = await sb.auth.verifyOtp({
      email: verifyRes.email,
      token: verifyRes.otp,
      type: 'email',
    });
    if (otpErr) return { ok: false, error: otpErr.message };

    this.setPasskeyHint({ email: verifyRes.email, enrolledAt: new Date().toISOString() });
    return { ok: true, session };
  },

  // Sign in with passkey — primary auth, no email required. Uses
  // discoverable credentials so the browser presents the user's available
  // CamBam passkeys; the chosen one identifies the user via userHandle.
  async signInWithPasskey() {
    if (!this.webauthnSupported()) return { ok: false, error: 'This browser does not support passkeys' };
    const sb = getSupabase();
    if (!sb) return { ok: false, error: 'Supabase not configured' };

    const optsRes = await this._webauthnCall('auth-options');
    if (!optsRes.ok) return optsRes;

    let assertion;
    try {
      assertion = await window.SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: optsRes.options });
    } catch (e) {
      return { ok: false, error: e?.message || 'Passkey sign-in cancelled', cancelled: e?.name === 'NotAllowedError' };
    }

    const verifyRes = await this._webauthnCall('auth-verify', { response: assertion });
    if (!verifyRes.ok) return verifyRes;

    const { data: session, error: otpErr } = await sb.auth.verifyOtp({
      email: verifyRes.email,
      token: verifyRes.otp,
      type: 'email',
    });
    if (otpErr) return { ok: false, error: otpErr.message };

    // Refresh the device hint with the freshly-verified email.
    this.setPasskeyHint({ email: verifyRes.email, signedInAt: new Date().toISOString() });
    return { ok: true, session };
  },

  // Remove a passkey by id. If no passkeys remain on the account, clear
  // the device hint so the login screen falls back to email mode.
  async unenrollPasskey(id) {
    const res = await this._webauthnCall('delete', { id }, { requireAuth: true });
    if (!res.ok) return res;
    const remaining = await this.listPasskeys();
    if (remaining.length === 0) this.setPasskeyHint(null);
    return { ok: true };
  },

  // ─── Lineups ───────────────────────────────────────────────────
  async loadLineup(tournamentId) {
    const sb = getSupabase();
    if (!sb) return null;
    const memberId = await currentMemberId();
    if (!memberId) return null;
    const { data } = await sb
      .from('lineups')
      .select('id, locked, submitted_at, lineup_golfers(slot, golfer_id, golfer_name, price)')
      .eq('member_id', memberId)
      .eq('tournament_id', tournamentId)
      .maybeSingle();
    return data;
  },

  async saveLineup(tournamentId, slots) {
    const sb = getSupabase();
    if (!sb) return;
    const memberId = await currentMemberId();
    if (!memberId) return;
    const capUsed = slots.reduce((acc, g) => acc + (g ? g.price : 0), 0);
    // Upsert the lineup header, then replace the 6 slot rows.
    const { data: lineup, error } = await sb
      .from('lineups')
      .upsert({ member_id: memberId, tournament_id: tournamentId, cap_used: capUsed, submitted_at: new Date().toISOString() })
      .select()
      .single();
    if (error) throw error;
    await sb.from('lineup_golfers').delete().eq('lineup_id', lineup.id);
    const rows = slots
      .map((g, i) => g && { lineup_id: lineup.id, slot: i + 1, golfer_id: g.golferId || g.name, golfer_name: g.name, price: g.price })
      .filter(Boolean);
    if (rows.length) await sb.from('lineup_golfers').insert(rows);
  },

  // ─── Standings ─────────────────────────────────────────────────
  async loadStandings(tournamentId) {
    const sb = getSupabase();
    if (!sb) return null;
    const { data } = await sb
      .from('member_event_scores')
      .select('rank, fantasy_pts, member:members(id, handle, display_name, avatar_tone, champion)')
      .eq('tournament_id', tournamentId)
      .order('rank', { ascending: true })
      .limit(50);
    return data;
  },

  // ─── Clubhouse ─────────────────────────────────────────────────
  async loadClubhouseMessages(tournamentId) {
    const sb = getSupabase();
    if (!sb) return null;
    const { data } = await sb
      .from('clubhouse_messages')
      .select('id, body, reactions, is_event, attachment, created_at, member:members(handle, display_name, avatar_tone, champion)')
      .eq('tournament_id', tournamentId)
      .order('created_at', { ascending: true })
      .limit(200);
    return data;
  },

  async postClubhouseMessage(tournamentId, body, attachment = null) {
    const sb = getSupabase();
    if (!sb) return;
    const memberId = await currentMemberId();
    if (!memberId) return;
    await sb.from('clubhouse_messages').insert({
      tournament_id: tournamentId, member_id: memberId, body, attachment,
    });
  },

  async reactToMessage(messageId, key) {
    const sb = getSupabase();
    if (!sb) return;
    const memberId = await currentMemberId();
    if (!memberId) return;
    // Reactions are stored as a jsonb { "<key>": ["@handle1", "@handle2", ...] }.
    // We delegate the atomic merge to a Postgres function in production.
    await sb.rpc('clubhouse_react', { p_message_id: messageId, p_key: key, p_user: memberId });
  },

  // ─── Caddie (read-only) ───────────────────────────────────────
  // Dispatches are written by `server/cli/caddie.js` after each major. The
  // client just reads the latest published row.
  async loadLatestCaddie(tournamentSlug) {
    const sb = getSupabase();
    if (!sb) return null;
    // Resolve slug → uuid. If the caller already passes a uuid, use it directly.
    let tournamentId = tournamentSlug;
    if (tournamentSlug && tournamentSlug.length < 32) {
      const { data: t } = await sb.from('tournaments').select('id').eq('major', tournamentSlug).maybeSingle();
      if (!t) return null;
      tournamentId = t.id;
    }
    const { data } = await sb
      .from('caddie_articles')
      .select('headline, body, quote, tone, length, created_at')
      .eq('tournament_id', tournamentId)
      .eq('status', 'published')
      .order('created_at', { ascending: false })
      .limit(1)
      .maybeSingle();
    return data;
  },

  // ─── Push notifications ───────────────────────────────────────
  async subscribeToPush() {
    if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;
    const reg = await navigator.serviceWorker.register('/sw.js');
    const sub = await reg.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: CONFIG.vapidPublicKey, // configured per deploy
    });
    const sb = getSupabase();
    if (sb) await sb.from('push_subscriptions').upsert({ endpoint: sub.endpoint, subscription: sub });
    return sub;
  },

  // ─── Tournaments ───────────────────────────────────────────────
  // The "current" tournament: live (by status OR by being inside its
  // start/end window), else the soonest UPCOMING tournament whose
  // starts_at is still in the future, else the most recently ended.
  //
  // Date-aware on purpose: the live-scoring worker only reconciles
  // status for the one tournament it's currently tracking, so other
  // tournaments can sit at status='upcoming' even after they've ended.
  // Using today's date as the source of truth avoids surfacing a past
  // major as "current".
  async currentTournament() {
    const sb = getSupabase();
    if (!sb) return null;
    const cols = 'id, season, major, name, venue, starts_at, lineup_locks_at, ends_at, status';
    const nowIso = new Date().toISOString();

    // 1) Status='live' wins outright.
    const { data: live } = await sb.from('tournaments').select(cols).eq('status', 'live').limit(1);
    if (live && live[0]) return live[0];

    // 2) Anything where now() is between starts_at and ends_at counts as live
    //    even if status hasn't been updated yet.
    const { data: implicitLive } = await sb.from('tournaments').select(cols)
      .lte('starts_at', nowIso).gte('ends_at', nowIso).order('starts_at', { ascending: false }).limit(1);
    if (implicitLive && implicitLive[0]) return implicitLive[0];

    // 3) The soonest tournament that hasn't started yet.
    const { data: upcoming } = await sb.from('tournaments').select(cols)
      .gt('starts_at', nowIso).order('starts_at', { ascending: true }).limit(1);
    if (upcoming && upcoming[0]) return upcoming[0];

    // 4) Otherwise the most recently ended (so the UI shows the last result).
    const { data: recent } = await sb.from('tournaments').select(cols)
      .lt('ends_at', nowIso).order('ends_at', { ascending: false }).limit(1);
    return (recent && recent[0]) || null;
  },

  async loadAllTournaments(season) {
    const sb = getSupabase();
    if (!sb) return [];
    const cols = 'id, season, major, name, venue, starts_at, lineup_locks_at, ends_at, status';
    let q = sb.from('tournaments').select(cols).order('starts_at', { ascending: true });
    if (season != null) q = q.eq('season', season);
    const { data } = await q;
    return data || [];
  },

  // Most recently ended tournament — uses date rather than status='final'
  // because the worker only flips status for the tournament it's tracking.
  async lastFinalTournament() {
    const sb = getSupabase();
    if (!sb) return null;
    const nowIso = new Date().toISOString();
    const { data } = await sb.from('tournaments')
      .select('id, season, major, name, venue, starts_at, ends_at, status')
      .lt('ends_at', nowIso).order('ends_at', { ascending: false }).limit(1).maybeSingle();
    return data;
  },

  // ─── Field (per-tournament golfer list) ────────────────────────
  async loadField(tournamentId) {
    const sb = getSupabase();
    if (!sb || !tournamentId) return [];
    const { data } = await sb.from('field')
      .select('tournament_id, golfer_id, golfer_name, country, salary, world_rank, is_active')
      .eq('tournament_id', tournamentId)
      .order('salary', { ascending: false });
    return data || [];
  },

  // ─── Members ───────────────────────────────────────────────────
  // Returns the cached current-user member row (loaded by the startup IIFE).
  // Always non-null after sign-in + commissioner promote; falls back to a
  // live query if the cache is empty (e.g. someone just promoted you).
  async currentMember() {
    const sb = getSupabase();
    if (!sb) return null;
    if (window._cambamMe) return window._cambamMe;
    const memberId = await currentMemberId();
    if (!memberId) return null;
    const { data } = await sb.from('members').select('*').eq('id', memberId).maybeSingle();
    if (data) window._cambamMe = data;
    return data;
  },

  // RLS-gated: members can read themselves, commissioners read everyone.
  async loadAllMembers() {
    const sb = getSupabase();
    if (!sb) return [];
    const { data } = await sb.from('members')
      .select('id, handle, display_name, email, role, status, joined_at, champion, avatar_tone, is_suspended')
      .order('joined_at', { ascending: true });
    return data || [];
  },

  // Standings already returns rows joined with the member; this is for
  // screens that need a single member's row by id.
  async loadMemberProfile(memberId) {
    const sb = getSupabase();
    if (!sb || !memberId) return null;
    const { data } = await sb.from('members')
      .select('id, handle, display_name, email, role, status, joined_at, champion, avatar_tone, is_suspended')
      .eq('id', memberId).maybeSingle();
    return data;
  },

  // ─── Member lineups (opponent / locked view) ──────────────────
  // Returns null when the lineup is hidden by RLS (e.g. before lock).
  async loadMemberLineup(memberId, tournamentId) {
    const sb = getSupabase();
    if (!sb || !memberId || !tournamentId) return null;
    const { data } = await sb.from('lineups')
      .select('id, locked, submitted_at, cap_used, lineup_golfers(slot, golfer_id, golfer_name, price)')
      .eq('member_id', memberId).eq('tournament_id', tournamentId).maybeSingle();
    return data;
  },

  // ─── Golfer detail ─────────────────────────────────────────────
  // Joins `field` + `golfer_scores` for a single golfer at the given event.
  // Returns { name, country, salary, world_rank, fantasy_pts, thru, round,
  //           status, score_to_par, r1..r4, ownership? } — fields are null
  // when the score row hasn't been written yet.
  async loadGolferDetail(golferId, tournamentId) {
    const sb = getSupabase();
    if (!sb || !golferId || !tournamentId) return null;
    const [{ data: f }, { data: s }] = await Promise.all([
      sb.from('field').select('golfer_name, country, salary, world_rank, is_active')
        .eq('tournament_id', tournamentId).eq('golfer_id', golferId).maybeSingle(),
      sb.from('golfer_scores').select('thru, round, status, score_to_par, fantasy_pts, r1, r2, r3, r4, updated_at')
        .eq('tournament_id', tournamentId).eq('golfer_id', golferId).maybeSingle(),
    ]);
    if (!f && !s) return null;
    return {
      golfer_id: golferId,
      name: f?.golfer_name || null,
      country: f?.country || null,
      salary: f?.salary ?? null,
      world_rank: f?.world_rank ?? null,
      is_active: f?.is_active ?? null,
      ...(s || {}),
    };
  },

  // ─── Member season stats (aggregate over member_event_scores) ─
  async loadMemberStats(memberId, season) {
    const sb = getSupabase();
    if (!sb || !memberId) return { total_pts: 0, events_played: 0, best_rank: null, best_event: null };
    let q = sb.from('member_event_scores')
      .select('rank, fantasy_pts, tournament:tournaments(id, season, major, name)')
      .eq('member_id', memberId);
    const { data } = await q;
    const rows = (data || []).filter((r) => !season || r.tournament?.season === season);
    let total = 0, best = null;
    for (const r of rows) {
      total += Number(r.fantasy_pts || 0);
      if (best == null || (r.rank != null && r.rank < best.rank)) best = r;
    }
    return {
      total_pts: total,
      events_played: rows.length,
      best_rank: best?.rank ?? null,
      best_event: best?.tournament || null,
      rows,
    };
  },

  // ─── Admin: audit log ─────────────────────────────────────────
  async loadAuditLog(limit = 20) {
    const sb = getSupabase();
    if (!sb) return [];
    const { data } = await sb.from('admin_audit')
      .select('id, action, payload, created_at, actor:members!admin_audit_actor_id_fkey(handle, display_name, avatar_tone)')
      .order('created_at', { ascending: false }).limit(limit);
    return data || [];
  },

  // ─── Recent Caddie dispatches (admin list) ────────────────────
  async loadLatestCaddieList(limit = 10) {
    const sb = getSupabase();
    if (!sb) return [];
    const { data } = await sb.from('caddie_articles')
      .select('id, headline, tone, length, status, created_at, tournament:tournaments(major, name)')
      .eq('status', 'published').order('created_at', { ascending: false }).limit(limit);
    return data || [];
  },
};

// On first load, if Supabase is configured, restore the session and sync
// the user's profile/lineup into the local store.
(async () => {
  const sb = getSupabase();
  if (!sb) return;
  const { data: { session } } = await sb.auth.getSession();
  if (!session) return;
  window.STORE.set({ authed: true, me: { ...window.STORE.state.me, email: session.user.email } });
  try {
    // members.auth_id ↔ auth.users.id; members.id is the table's own PK.
    const { data: member } = await sb.from('members').select('*').eq('auth_id', session.user.id).maybeSingle();
    if (member) {
      window._cambamMe = member;
      window._cambamMemberId = member.id;
      window.STORE.set({
        me: {
          id: member.id,
          handle: member.handle,
          displayName: member.display_name,
          email: session.user.email,
          tone: member.avatar_tone || 0,
          role: member.role,
          champion: member.champion,
        },
      });
    }
  } catch (e) { console.warn('member load failed', e); }
})();

window.cambamApi = api;
