/* global React, sbx, useDemoMode, rngFor, pageAll, chunk */
// People data layer — the golfer database, and what each golfer actually did.
//
// The existing useUsers() caps at 100 rows, which is right for a search box
// and wrong for a database view: sorting by spend across the first hundred
// rows Postgres happened to return is a misleading answer. This pages the
// whole table instead, and sorting happens over everything.
//
// Activity (rounds, spend, last played) is not on the profile — it has to be
// derived from bookings. Rather than one query per golfer, it's the same
// shape as the network rollup: read the window's slots once, its bookings
// once, and group by user in memory.

const ACTIVITY_DAYS = 365;

// Every profile, paged. Search is server-side so a large table doesn't have
// to come down just to find one person.
async function fetchGolfers(query) {
  const term = (query || '').trim().replace(/^@/, '');
  const cols = 'id, handle, first_name, last_name, avatar_url, is_admin, tier, sbx, created_at';
  const rows = await pageAll(() => {
    let q = sbx.from('profiles').select(cols);
    if (term) q = q.or(`handle.ilike.%${term}%,first_name.ilike.%${term}%,last_name.ilike.%${term}%`);
    return q.order('created_at', { ascending: false });
  });
  return rows;
}

// Rounds, spend and last-played per golfer over the trailing year.
async function fetchActivity() {
  const since = new Date();
  since.setDate(since.getDate() - ACTIVITY_DAYS);
  since.setHours(0, 0, 0, 0);

  const slots = await pageAll(() => sbx.from('tee_slots')
    .select('id, course_id, starts_at, price')
    .gte('starts_at', since.toISOString()));
  const slotById = {};
  slots.forEach(s => { slotById[s.id] = s; });

  let bookings = [];
  for (const group of chunk(slots.map(s => s.id))) {
    // eslint-disable-next-line no-await-in-loop
    const page = await pageAll(() => sbx.from('bookings')
      .select('id, slot_id, user_id, status, price_charged')
      .in('slot_id', group));
    bookings = bookings.concat(page);
  }

  const out = {};
  bookings.forEach(b => {
    const slot = slotById[b.slot_id];
    if (!slot || !b.user_id) return;
    // A cancellation was never played and a no-show was never charged, so
    // neither counts as a round or as spend.
    if (b.status === 'cancelled' || b.status === 'no_show') return;
    const rec = out[b.user_id] || (out[b.user_id] = { rounds: 0, spend: 0, lastPlayed: null, courses: new Set() });
    rec.rounds += 1;
    // price_charged is captured at check-in, not at booking — spend only
    // counts a round that was actually charged, not a reservation's list
    // price. A golfer's "rounds" count still includes it as activity.
    if (b.price_charged != null) rec.spend += b.price_charged;
    rec.courses.add(slot.course_id);
    if (!rec.lastPlayed || slot.starts_at > rec.lastPlayed) rec.lastPlayed = slot.starts_at;
  });
  Object.values(out).forEach(r => { r.courses = r.courses.size; });
  return out;
}

function demoActivity(golfers) {
  const out = {};
  golfers.forEach(g => {
    const r = rngFor(g.id, 'activity');
    const rounds = Math.round(r() * 38);
    out[g.id] = {
      rounds,
      spend: Math.round(rounds * (18 + r() * 12)),
      courses: rounds ? 1 + Math.round(r() * 3) : 0,
      lastPlayed: rounds
        ? new Date(Date.now() - Math.round(r() * 120) * 864e5).toISOString()
        : null,
    };
  });
  return out;
}

// ─── useGolfers ───────────────────────────────────────────────────────
// Returns [state, reload]; state is null while loading, then
//   { rows, activity, demo, error }
// `rows` carries the activity merged in, so sorting and filtering can treat
// "spend" the same way it treats "tier".
function useGolfers(query) {
  const demo = useDemoMode();
  const [state, setState] = React.useState(null);
  const [nonce, setNonce] = React.useState(0);
  const reload = React.useCallback(() => setNonce(n => n + 1), []);

  React.useEffect(() => {
    let live = true;
    setState(null);
    (async () => {
      let rows;
      try {
        rows = await fetchGolfers(query);
      } catch (e) {
        // The membership migration adds `tier`; without it the select fails
        // outright, which is worth naming rather than showing an empty table.
        if (live) setState({ rows: [], activity: {}, demo, error: /tier|column/i.test(e.message || '') ? 'MIGRATION' : (e.message || 'Could not load golfers.') });
        return;
      }
      const activity = demo ? demoActivity(rows) : await fetchActivity().catch(() => ({}));
      if (!live) return;
      setState({
        demo,
        activity,
        error: '',
        rows: rows.map(g => ({ ...g, act: activity[g.id] || { rounds: 0, spend: 0, courses: 0, lastPlayed: null } })),
      });
    })();
    return () => { live = false; };
  }, [demo, query, nonce]);

  return [state, reload];
}

// ─── Sorting ──────────────────────────────────────────────────────────
// Comparators live here so the table header and the export agree on what
// "sorted by spend" means.
const GOLFER_SORTS = {
  joined:  { label: 'Joined',     get: (g) => g.created_at || '', numeric: false },
  name:    { label: 'Name',       get: (g) => [g.first_name, g.last_name].filter(Boolean).join(' ').toLowerCase() || String(g.handle || ''), numeric: false },
  sbx:     { label: 'SBX',        get: (g) => (g.sbx == null ? -1 : Number(g.sbx)), numeric: true },
  rounds:  { label: 'Rounds',     get: (g) => g.act.rounds, numeric: true },
  spend:   { label: 'Spend',      get: (g) => g.act.spend, numeric: true },
  played:  { label: 'Last played', get: (g) => g.act.lastPlayed || '', numeric: false },
};

function sortGolfers(rows, key, dir) {
  const s = GOLFER_SORTS[key] || GOLFER_SORTS.joined;
  const mul = dir === 'asc' ? 1 : -1;
  return rows.slice().sort((a, b) => {
    const av = s.get(a), bv = s.get(b);
    if (s.numeric) return (av - bv) * mul;
    return String(av).localeCompare(String(bv)) * mul;
  });
}

// ─── CSV export ───────────────────────────────────────────────────────
// Built from whatever is on screen, so a filtered, sorted view exports as
// exactly that rather than as the whole table.
function golfersCsv(rows, roleFor) {
  const head = ['name', 'handle', 'role', 'tier', 'sbx', 'rounds_365d', 'spend_365d', 'courses_played', 'last_played', 'joined'];
  const esc = (v) => {
    const s = v == null ? '' : String(v);
    return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  };
  const lines = rows.map(g => [
    [g.first_name, g.last_name].filter(Boolean).join(' '),
    g.handle ? `@${String(g.handle).replace(/^@/, '')}` : '',
    roleFor(g),
    g.tier || '',
    g.sbx == null ? '' : Number(g.sbx).toFixed(3),
    g.act.rounds,
    g.act.spend,
    g.act.courses,
    g.act.lastPlayed ? String(g.act.lastPlayed).slice(0, 10) : '',
    g.created_at ? String(g.created_at).slice(0, 10) : '',
  ].map(esc).join(','));
  return [head.join(','), ...lines].join('\n');
}

// ─── useStaff ─────────────────────────────────────────────────────────
// Course-partner access, read the other way round: by course rather than by
// person, because "who can get into Melreese" is the question you actually
// ask when someone leaves.
function useStaff() {
  const [state, setState] = React.useState(null);
  const [nonce, setNonce] = React.useState(0);
  const reload = React.useCallback(() => setNonce(n => n + 1), []);

  React.useEffect(() => {
    let live = true;
    setState(null);
    (async () => {
      // Deliberately not embedding profiles here: course_managers has two
      // foreign keys into it (user_id and created_by), so an unqualified
      // join is ambiguous and a qualified one depends on the constraint
      // being named exactly as expected. Names come from a second, explicit
      // read instead.
      const { data, error } = await sbx.from('course_managers')
        .select('id, course_id, user_id, role, created_at, course:courses(id, name, short_name, status)');
      if (!live) return;
      if (error) { setState({ links: [], byCourse: {}, error: error.message }); return; }
      const rawLinks = (data || []).filter(l => l.course);

      const ids = [...new Set(rawLinks.map(l => l.user_id).filter(Boolean))];
      const people = {};
      for (const group of chunk(ids, 200)) {
        // eslint-disable-next-line no-await-in-loop
        const { data: profs } = await sbx.from('profiles')
          .select('id, first_name, last_name, handle, is_admin').in('id', group);
        (profs || []).forEach(pr => { people[pr.id] = pr; });
      }
      if (!live) return;
      const links = rawLinks.map(l => ({ ...l, user: people[l.user_id] || null }));
      const byCourse = {};
      links.forEach(l => {
        byCourse[l.course_id] = byCourse[l.course_id] || { course: l.course, managers: [] };
        byCourse[l.course_id].managers.push(l);
      });
      setState({ links, byCourse, error: '' });
    })();
    return () => { live = false; };
  }, [nonce]);

  return [state, reload];
}


// ─── Match history ────────────────────────────────────────────────────
// The golfer app's RLS lets a player read only their own matches, so an
// admin opening someone's profile sees nothing until the admin read policy
// in sql/people-admin.sql is in place. That is reported as "not visible
// yet" rather than as "no matches", because the two mean opposite things.
// MATCH_PAGE at a time, newest first — same load-more shape useAuditLog
// already uses, rather than either a hard cap (wrong the day someone has
// more matches than the cap) or fetching everything up front (wrong for a
// golfer with a long history when nobody asked to see all of it yet).
const MATCH_PAGE = 30;

async function fetchMatchHistory(userId, pages = 1) {
  if (!userId) return { matches: [], blocked: false, hasMore: false };
  const want = MATCH_PAGE * pages;
  const cols = 'id, join_code, course_name, status, result, final_margin, total_holes, created_at, started_at, completed_at, player_a, player_b';
  const { data, error } = await sbx.from('matches')
    .select(cols)
    .or(`player_a.eq.${userId},player_b.eq.${userId}`)
    .order('created_at', { ascending: false })
    .limit(want + 1);
  if (error) return { matches: [], blocked: true, message: error.message, hasMore: false };

  const all = data || [];
  const hasMore = all.length > want;
  const rows = all.slice(0, want);
  // Opponent names, resolved in one read rather than per match.
  const others = [...new Set(rows.flatMap(m => [m.player_a, m.player_b])
    .filter(id => id && id !== userId))];
  const people = {};
  for (const group of chunk(others, 200)) {
    // eslint-disable-next-line no-await-in-loop
    const { data: profs } = await sbx.from('profiles')
      .select('id, first_name, last_name, handle, avatar_url').in('id', group);
    (profs || []).forEach(p2 => { people[p2.id] = p2; });
  }

  const matches = rows.map(m => {
    const isA = m.player_a === userId;
    const oppId = isA ? m.player_b : m.player_a;
    // 'A' | 'B' | 'H' from the golfer app, read from this player's side.
    let outcome = null;
    if (m.status === 'completed') {
      if (m.result === 'H') outcome = 'H';
      else if (m.result === 'A' || m.result === 'B') outcome = ((m.result === 'A') === isA) ? 'W' : 'L';
    }
    return {
      id: m.id,
      joinCode: m.join_code,
      course: m.course_name,
      status: m.status,
      outcome,
      margin: m.final_margin,
      holes: m.total_holes,
      playedAt: m.completed_at || m.started_at || m.created_at,
      opponent: oppId ? (people[oppId] || null) : null,
      solo: !oppId,
    };
  });
  return { matches, blocked: false, hasMore };
}

function demoMatchHistory(userId) {
  const r = rngFor(userId, 'matches');
  const names = [['Ana','Zed'],['Marco','Rivera'],['Jess','Chen'],['Luis','Gomez'],['Nina','Brooks']];
  const courses = ['Melreese', 'Crandon', 'Palmetto', 'Doral Park'];
  const n = 4 + Math.round(r() * 6);
  return {
    blocked: false, hasMore: false,
    matches: Array.from({ length: n }, (_, i) => {
      const roll = r();
      const outcome = roll > 0.62 ? 'W' : roll > 0.2 ? 'L' : 'H';
      const nm = names[Math.floor(r() * names.length)];
      return {
        id: `demo-${userId}-${i}`,
        joinCode: String(Math.floor(r() * 9000) + 1000),
        course: courses[Math.floor(r() * courses.length)],
        status: 'completed',
        outcome,
        margin: outcome === 'H' ? 'AS' : `${1 + Math.floor(r() * 4)}&${1 + Math.floor(r() * 3)}`,
        holes: 9,
        playedAt: new Date(Date.now() - (i * 9 + Math.floor(r() * 6)) * 864e5).toISOString(),
        opponent: { id: `o${i}`, first_name: nm[0], last_name: nm[1], handle: (nm[0] + nm[1]).toLowerCase() },
        solo: false,
      };
    }),
  };
}

function useMatchHistory(userId, pages = 1) {
  const demo = useDemoMode();
  const [state, setState] = React.useState(null);
  React.useEffect(() => {
    let live = true;
    setState(null);
    (demo ? Promise.resolve(demoMatchHistory(userId)) : fetchMatchHistory(userId, pages))
      .then(r => { if (live) setState({ ...r, demo }); })
      .catch(e => { if (live) setState({ matches: [], blocked: true, message: e.message, hasMore: false, demo }); });
    return () => { live = false; };
  }, [userId, pages, demo]);
  return state;
}

// ─── Match trend ──────────────────────────────────────────────────────
// Every completed match this golfer played, stripped to date + outcome.
//
// Separate from fetchMatchHistory on purpose. That one pages 30 at a time
// because it renders a row per match and joins opponent profiles; a curve
// needs the WHOLE series or it isn't a curve, and it needs none of the
// opponent data. So this is the same rows, unpaged and unjoined.
//
// Same two-seat filter (player_a / player_b) as fetchMatchHistory, so the
// chart and the list directly under it can never disagree about how many
// matches a golfer has. Both therefore miss a 2v2 where the golfer sat in
// seat a2/b2 — see the note rendered under the chart.
async function fetchMatchTrend(userId) {
  if (!userId) return { points: [], blocked: false };
  const cols = 'id, status, result, player_a, player_b, created_at, started_at, completed_at';
  let rows;
  try {
    rows = await pageAll(() => sbx.from('matches')
      .select(cols)
      .or(`player_a.eq.${userId},player_b.eq.${userId}`)
      .order('created_at', { ascending: true }));
  } catch (e) {
    return { points: [], blocked: true, message: (e && e.message) || 'Could not load matches.' };
  }

  const points = [];
  (rows || []).forEach(m => {
    if (m.status !== 'completed') return;
    const isA = m.player_a === userId;
    let outcome = null;
    if (m.result === 'H') outcome = 'H';
    else if (m.result === 'A' || m.result === 'B') outcome = ((m.result === 'A') === isA) ? 'W' : 'L';
    if (!outcome) return;
    const at = m.completed_at || m.started_at || m.created_at;
    if (!at) return;
    const ms = new Date(at).getTime();
    if (!Number.isFinite(ms)) return;
    points.push({ id: m.id, at: ms, outcome });
  });
  points.sort((a, b) => a.at - b.at);
  return { points, blocked: false };
}

function useMatchTrend(userId) {
  const demo = useDemoMode();
  const [state, setState] = React.useState(null);
  React.useEffect(() => {
    let live = true;
    setState(null);
    if (demo) {
      const points = demoMatchHistory(userId).matches
        .filter(m => m.status === 'completed' && m.outcome)
        .map(m => ({ id: m.id, at: new Date(m.playedAt).getTime(), outcome: m.outcome }))
        .sort((a, b) => a.at - b.at);
      setState({ points, blocked: false, demo: true });
      return () => { live = false; };
    }
    fetchMatchTrend(userId)
      .then(r => { if (live) setState({ ...r, demo: false }); })
      .catch(e => { if (live) setState({ points: [], blocked: true, message: e.message, demo: false }); });
    return () => { live = false; };
  }, [userId, demo]);
  return state;
}

// ─── SBX rating history ───────────────────────────────────────────────
// profiles.sbx is a single current number with nothing behind it — the
// rating a golfer has right now. sbx_history is the trail: one row per
// golfer per day, written by a trigger whenever profiles.sbx changes, so
// the curve builds itself from the day the table went in.
//
// Nothing before that day exists, and nothing here invents it. A golfer
// with no rows is reported as having no history rather than back-filled
// from a replayed rating algorithm this repo doesn't have.
async function fetchSbxHistory(userId) {
  if (!userId) return { points: [], missing: false };
  const { data, error } = await sbx.from('sbx_history')
    .select('sbx, recorded_at')
    .eq('user_id', userId)
    .order('recorded_at', { ascending: true });
  if (error) {
    // 42P01 = the table hasn't been created yet. That's "not set up", not a
    // fault, and the panel says so differently.
    const missing = error.code === '42P01' || /does not exist/i.test(error.message || '');
    return { points: [], missing, message: error.message };
  }
  return {
    missing: false,
    points: (data || [])
      .map(r => ({ at: new Date(r.recorded_at).getTime(), y: Number(r.sbx) }))
      .filter(p => Number.isFinite(p.at) && Number.isFinite(p.y)),
  };
}

function useSbxHistory(userId, currentSbx) {
  const demo = useDemoMode();
  const [state, setState] = React.useState(null);
  React.useEffect(() => {
    let live = true;
    setState(null);
    if (demo) {
      // A plausible walk ending at the golfer's shown rating, so the demo
      // chart and the demo profile agree with each other.
      const r = rngFor(userId, 'sbx-history');
      const end = currentSbx != null ? Number(currentSbx) : 4.5;
      const n = 14;
      const pts = [];
      let v = end - (r() - 0.4) * 0.8;
      for (let i = n - 1; i >= 0; i -= 1) {
        pts.push({ at: Date.now() - i * 9 * 864e5, y: Math.round(v * 1000) / 1000 });
        v += (r() - 0.47) * 0.18;
      }
      pts[pts.length - 1].y = Math.round(end * 1000) / 1000;
      setState({ points: pts, missing: false, demo: true });
      return () => { live = false; };
    }
    fetchSbxHistory(userId)
      .then(res => { if (live) setState({ ...res, demo: false }); })
      .catch(e => { if (live) setState({ points: [], missing: false, message: e.message, demo: false }); });
    return () => { live = false; };
  }, [userId, demo, currentSbx]);
  return state;
}

// ─── Passwords ────────────────────────────────────────────────────────
// Two different mechanisms, and the difference is not cosmetic.
//
// A reset link is something the anon key can do on its own — it is the same
// call the public "forgot password" flow makes, so it works today. It needs
// the account's email, which this portal cannot look up: `profiles` has no
// email column and auth.users is not readable with the anon key. So the
// address is typed in, and what is really being confirmed is "send a link to
// this address", not "reset whoever this row is".
//
// Setting a password outright needs the service-role key, which must never
// ship in a browser bundle. It goes through an Edge Function the same way
// create-manager does; until that function is deployed the call fails with a
// message saying so rather than looking broken.
// The account's email, for the reset link. Returns null when the function
// isn't there yet, so the panel can fall back to asking for it rather than
// looking broken.
async function fetchUserEmail(userId) {
  if (!userId) return null;
  const { data, error } = await sbx.rpc('admin_user_email', { uid: userId });
  if (error) return null;
  return data || null;
}

function useUserEmail(userId) {
  const [state, setState] = React.useState(undefined); // undefined = looking
  React.useEffect(() => {
    let live = true;
    setState(undefined);
    fetchUserEmail(userId).then(e => { if (live) setState(e); }).catch(() => { if (live) setState(null); });
    return () => { live = false; };
  }, [userId]);
  return state;
}

async function sendPasswordReset(email, redirectTo) {
  const addr = (email || '').trim();
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr)) throw new Error('That does not look like an email address.');
  const { error } = await sbx.auth.resetPasswordForEmail(addr, {
    redirectTo: redirectTo || `${window.location.origin}/`,
  });
  if (error) throw new Error(error.message || 'Could not send the reset link.');
  return addr;
}

async function setUserPassword(userId, password) {
  if (!password || password.length < 8) throw new Error('Password must be at least 8 characters.');
  const { data, error } = await sbx.functions.invoke('set-password', { body: { userId, password } });
  if (error) {
    let msg = error.message || 'Could not set the password.';
    try { const body = await error.context.json(); if (body && body.error) msg = body.error; } catch (_) { /* noop */ }
    if (/Failed to send a request|Function not found|404/i.test(msg)) {
      msg = 'The set-password function isn\u2019t deployed yet. Deploy supabase/functions/set-password, then try again. A reset link works in the meantime.';
    }
    throw new Error(msg);
  }
  if (data && data.error) throw new Error(data.error);
  // { userId, email, emailConfirmed } — the caller shows the address back so
  // a wrong-account mistake is visible rather than silent.
  return data || {};
}

Object.assign(window, {
  useGolfers, useStaff, GOLFER_SORTS, sortGolfers, golfersCsv, ACTIVITY_DAYS,
  useMatchHistory, useMatchTrend, useSbxHistory, sendPasswordReset, setUserPassword, fetchUserEmail, useUserEmail,
});
