// Shared helpers, stage maps, and backend adapters.
// All API data is fetched per-view; this file only exposes pure helpers
// and a small in-memory contact cache that the CommandPalette + breadcrumbs use.

// ---- Stage mapping: backend stage strings -> design stage tokens ----
// Backend: NEW LEAD, FIRST TOUCH, INTERESTED, BOT ENGAGED, FOLLOW UP,
//          DAY PASS SCHEDULED, NEEDS_RESCHEDULE, SHOWED, JOINED, MEMBER,
//          DEAD, NO SHOW
const STAGE_GROUP = {
  'NEW LEAD':           'new',
  'FIRST TOUCH':        'new',
  'INTERESTED':         'contacted',
  'BOT ENGAGED':        'contacted',
  'FOLLOW UP':          'contacted',
  'DAY PASS SCHEDULED': 'booked',
  'FDP BOOKED':         'booked',
  // NEEDS_RESCHEDULE is its own group (amber chip) — separates contacts
  // waiting to rebook from active bookings without lumping them with
  // closed-out states like NO SHOW or DEAD.
  'NEEDS_RESCHEDULE':   'reschedule',
  'SHOWED':             'booked',
  'JOINED':             'member',
  'MEMBER':             'member',
  'DEAD':               'churned',
  'LOST':               'churned',
  'NO SHOW':            'churned',
};
function stageGroupOf(stageRaw) {
  const s = String(stageRaw || '').toUpperCase();
  return STAGE_GROUP[s] || 'new';
}
function stageLabelOf(stageRaw) {
  return String(stageRaw || 'NEW LEAD').toLowerCase();
}

// ---- Time helpers ----
// RAW gym is in Stuart, FL. All booking/appointment times are surfaced in
// gym-local time (ET) regardless of where the staff member is logged in from.
// Use the IANA zone — it auto-switches between EST and EDT.
const GYM_TZ = 'America/New_York';

// Extract { year, month, day, hour, minute } from a Date in GYM_TZ.
// Used wherever calendar positioning / day-grouping needs gym-local fields
// instead of the browser's local fields.
function gymParts(d) {
  if (!(d instanceof Date) || isNaN(d.getTime())) return null;
  const parts = new Intl.DateTimeFormat('en-US', {
    timeZone: GYM_TZ,
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', hour12: false,
  }).formatToParts(d).reduce((a, p) => (a[p.type] = p.value, a), {});
  return {
    year:   parseInt(parts.year,  10),
    month:  parseInt(parts.month, 10),
    day:    parseInt(parts.day,   10),
    hour:   parseInt(parts.hour,  10) % 24, // '24' → 0 at midnight in some locales
    minute: parseInt(parts.minute,10),
  };
}
function gymIsoDay(d) {
  const p = gymParts(d);
  return p ? `${p.year}-${String(p.month).padStart(2,'0')}-${String(p.day).padStart(2,'0')}` : null;
}
function gymMinutesOfDay(d) {
  const p = gymParts(d);
  return p ? p.hour * 60 + p.minute : null;
}
// Return a JS Date pinned to noon UTC on the Monday of the gym-local week that
// contains `now`. Noon UTC is safely mid-day in GYM_TZ regardless of DST
// (gym sees 7am EDT / 8am EST), so adding/subtracting whole days never rolls
// the date over an unexpected boundary.
function mondayOfGymWeek(now) {
  const gp = gymParts(now);
  if (!gp) return null;
  const probe = new Date(Date.UTC(gp.year, gp.month - 1, gp.day, 12, 0, 0));
  const wd = new Intl.DateTimeFormat('en-US', { timeZone: GYM_TZ, weekday: 'short' }).format(probe);
  const idx = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'].indexOf(wd);
  return new Date(probe.getTime() - (idx < 0 ? 0 : idx) * 86400000);
}

function parseDate(v) {
  if (!v) return null;
  if (v instanceof Date) return v;
  if (typeof v === 'number') return new Date(v);
  // asyncpg often returns "2026-04-21 12:00:00+00:00" or ISO
  const s = String(v).replace(' ', 'T');
  const d = new Date(s);
  return isNaN(d.getTime()) ? null : d;
}
function minutesAgo(v) {
  const d = parseDate(v);
  if (!d) return null;
  return Math.max(0, Math.round((Date.now() - d.getTime()) / 60000));
}
function fmtAgo(minutes) {
  if (minutes == null) return '—';
  if (minutes < 1)   return 'just now';
  if (minutes < 60)  return `${minutes}m ago`;
  const h = Math.floor(minutes / 60);
  if (h < 24)        return `${h}h ago`;
  const d = Math.floor(h / 24);
  return `${d}d ago`;
}
function fmtAgoFrom(v) { return fmtAgo(minutesAgo(v)); }
// Whole days since `v` (used by the lead-detail badge "Waiting to reschedule
// for N days"). Returns 0 for "less than a day ago" so the badge reads
// "Waiting to reschedule for 0 days" right after the click, which is more
// truthful than rounding up. parseDate is permissive about ISO/ms inputs.
function daysSince(v) {
  const d = parseDate(v);
  if (!d) return null;
  return Math.max(0, Math.floor((Date.now() - d.getTime()) / 86400000));
}
function fmtMin(t) {
  const h = Math.floor(t / 60);
  const m = t % 60;
  const ampm = h >= 12 ? 'p' : 'a';
  const h12 = ((h + 11) % 12) + 1;
  return m === 0 ? `${h12}${ampm}` : `${h12}:${String(m).padStart(2,'0')}${ampm}`;
}
function fmtDateTime(v) {
  const d = parseDate(v);
  if (!d) return '—';
  const day = d.toLocaleDateString(undefined, { weekday:'short', month:'short', day:'numeric' });
  const time = d.toLocaleTimeString(undefined, { hour:'numeric', minute:'2-digit' });
  return `${day} · ${time}`;
}
function fmtShortDay(v) {
  const d = parseDate(v);
  if (!d) return '—';
  return d.toLocaleDateString(undefined, { weekday:'short', month:'short', day:'numeric' });
}
function fmtClock(v) {
  const d = parseDate(v);
  if (!d) return '—';
  return d.toLocaleTimeString(undefined, { timeZone: GYM_TZ, hour:'numeric', minute:'2-digit' });
}

// ---- Avatar helpers ----
function initialsOf(nameOrEmail) {
  if (!nameOrEmail) return '?';
  const s = String(nameOrEmail).trim();
  if (s.includes(' ')) {
    return s.split(/\s+/).map(p => p[0]).join('').slice(0,2).toUpperCase();
  }
  if (s.includes('@')) return s[0].toUpperCase() + (s[1] || '').toUpperCase();
  return s.slice(0,2).toUpperCase();
}
const PALETTE = [
  'oklch(0.68 0.18 52)',
  'oklch(0.55 0.14 240)',
  'oklch(0.60 0.14 160)',
  'oklch(0.58 0.15 300)',
  'oklch(0.62 0.14 110)',
  'oklch(0.58 0.14 190)',
  'oklch(0.62 0.17 20)',
];
function colorFor(key) {
  const s = String(key || '');
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
  return PALETTE[h % PALETTE.length];
}
function staffBadge(name) {
  return { initials: initialsOf(name), color: colorFor(name) };
}

// ---- Contact cache (shared across views) ----
// Populated by LeadsView / HomeView as data comes in so CommandPalette can
// offer members without having to re-fetch.
const _contactCache = new Map();
function cacheContacts(list) {
  (list || []).forEach(c => { if (c && c.id) _contactCache.set(c.id, c); });
}
function cachedContacts() { return Array.from(_contactCache.values()); }
function contactById(id) { return _contactCache.get(id) || null; }

// ---- Lead-row adapter (backend -> design shape) ----
function adaptLead(raw) {
  if (!raw) return null;
  const group = stageGroupOf(raw.stage);
  return {
    ...raw,
    id: raw.id,
    name: raw.name || 'Unknown',
    phone: raw.phone || '',
    email: raw.email || '',
    stage: group,
    stage_raw: raw.stage || 'NEW LEAD',
    source: raw.source || '',
    interest: (raw.tags || []).slice(0, 3).map(t => String(t).replace(/-/g, ' ')).join(', ') || (raw.stage || '').toLowerCase(),
    owner: null,
    hot: !!raw.hot,
    is_member: !!raw.is_member,
    tommy_contacted: !!raw.tommy_contacted,
    tommy_status: raw._has_pending_approval ? 'pending_approval'
                : raw._has_unread_inbound    ? 'unanswered'
                : 'idle',
    scheduled_at: raw.scheduled_at || null,
    scheduled_label: raw.scheduled_at ? fmtDateTime(raw.scheduled_at) : (raw.scheduled_for || null),
    lastActivity: raw.updated_at ? minutesAgo(raw.updated_at) : (raw.age_days ? raw.age_days * 1440 : null),
    age_days: raw.age_days || 0,
  };
}

// ---- Friendly error translation ----
// The API returns 502 when GoHighLevel is unreachable (see quick-wins 2026-04).
// Surfacing the raw FastAPI detail string confuses staff, so normalize by status.
function friendlyApiError(e, fallback) {
  const msg = fallback || 'Request failed';
  if (!e) return msg;
  const status = e.status;
  if (status === 502) return 'GoHighLevel is unreachable right now. Your change did not sync — try again in a moment.';
  if (status === 401) return 'Your session expired. Sign in again.';
  if (status === 403) return 'You don’t have access to that action.';
  if (status === 429) return 'Too many requests. Wait a few seconds and retry.';
  if (status >= 500)  return msg + '. Backend responded ' + status + '.';
  return e.message || msg;
}

// Quiet-hours 409 from /api/v2/tommy/approve and /api/v2/leads/<id>/message.
// FastAPI nests the structured error under .detail; the api client preserves
// the full payload on err.payload.
function isQuietHoursError(e) {
  return e && e.status === 409 && e.payload && e.payload.detail
      && e.payload.detail.error === 'quiet_hours';
}
function quietHoursPrompt(e, action) {
  const d = (e && e.payload && e.payload.detail) || {};
  const where = d.timezone ? ` (${d.timezone})` : '';
  const when = d.local_time ? ` It is ${d.local_time} for them${where}.` : '';
  return (
    `Quiet-hours policy: we don't send between 8 PM and 8 AM in the recipient's timezone.` +
    when +
    `\n\nOverride and ${action} anyway?`
  );
}

Object.assign(window, {
  stageGroupOf, stageLabelOf, STAGE_GROUP,
  parseDate, minutesAgo, fmtAgo, fmtAgoFrom, fmtMin, daysSince,
  fmtDateTime, fmtShortDay, fmtClock,
  initialsOf, colorFor, staffBadge,
  cacheContacts, cachedContacts, contactById,
  adaptLead,
  friendlyApiError,
  isQuietHoursError,
  quietHoursPrompt,
});
