/**
 * @license
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * POS REST client — the counter-sales surface served at
 * `https://fiesta.nearle.app/live/api/v1/pos`.
 *
 * This is a *different base* from `./fiestaApi` (which is `/live/api/v1/web`),
 * so it gets its own client rather than a helper on that one.
 *
 * Two things about this API shape every function below:
 *
 * 1. **There is no tenant parameter.** `locationid` is required on every read
 *    and is the authorisation boundary server-side. A tenant-wide view is
 *    therefore a fan-out, one request per outlet, merged here — see `mapLimit`.
 *
 * 2. **The health endpoints return a Redis hash**, so every value is a string —
 *    `"today_amount": "170"`, `"printer_reachable": "0"`. Nothing may be used
 *    as a number or a boolean without going through the parsers here.
 *
 * Components call the hooks in `./posQueries`, not these functions.
 */

const POS_BASE = import.meta.env.VITE_FIESTA_POS_URL || 'https://fiesta.nearle.app/live/api/v1/pos';

type QueryParams = Record<string, string | number | undefined | null>;

// ── Wire types ───────────────────────────────────────────────────────────────

/** One counter bill. Mirrors `models.PosOrders` — the list endpoint is a
 *  `SELECT *`, so every column arrives whether the doc's example shows it or not. */
export interface PosBill {
  posorderid: number;
  terminalorderid: string;
  invoicenumber: string;
  tenantid: number;
  locationid: number;
  terminalid: string;
  cashiername: string;
  customerid: number;
  customermobile: string;
  customername: string;
  billedat: string;
  businessdate: string;
  subtotal: number;
  discount: number;
  taxamount: number;
  roundoff: number;
  total: number;
  pointsearned: number;
  pointsredeemed: number;
  itemcount: number;
  paymentmode: string;
  paymentsjson: string;
  promosjson: string;
  taxbreakdownjson: string;
  batchid: string;
  receivedat: string;
  created?: string;
  updated?: string;
  /** Populated only by `/sales/detail`. The list endpoint sends `items: null`
   *  (the Go field is `gorm:"-"` with no `omitempty`), so this is nullable. */
  items?: PosBillItem[] | null;
}

export interface PosBillItem {
  posorderitemid: number;
  posorderid: number;
  tenantid: number;
  locationid: number;
  productid: number;
  productname: string;
  barcode: string;
  unitname: string;
  /** Fractional — a counter sells 1.5 kg. */
  quantity: number;
  unitprice: number;
  discountamount: number;
  /** A fraction (0.18), not a percentage (18). */
  gstrate: number;
  taxamount: number;
  linetotal: number;
}

export interface PosSalesPage {
  total: number;
  pageno: number;
  pagesize: number;
  bills: PosBill[];
}

export interface PosPaymentTotal { paymentmode: string; billcount: number; amount: number }
export interface PosDayTotal { businessdate: string; billcount: number; amount: number }
export interface PosTerminalTotal { terminalid: string; billcount: number; amount: number }

export interface PosSalesSummary {
  locationid: number;
  fromdate: string;
  todate: string;
  billcount: number;
  itemcount: number;
  /** Named "gross" by the API but computed as `SUM(total)` — i.e. **net of
   *  discount and round-off**. `grosssales + discountgiven` is the gross figure.
   *  Labelled honestly in the UI as "Net collected". */
  grosssales: number;
  taxcollected: number;
  discountgiven: number;
  roundoff: number;
  averagebill: number;
  bypaymentmode: PosPaymentTotal[];
  byday: PosDayTotal[];
  byterminal: PosTerminalTotal[];
}

/** Raw Redis hash for one till. Every value is a string; use `parseTerminal`. */
export type PosTerminalRaw = Record<string, string>;

export interface PosLocationHealth {
  location_id: string;
  total: number;
  online: number;
  terminals: PosTerminalRaw[];
}

export interface PosSalesFilter {
  locationid: number;
  fromdate?: string;
  todate?: string;
  terminalid?: string;
  /** A real filter the handler reads, applied as an exact case-sensitive match. */
  cashiername?: string;
  paymentmode?: string;
  pageno?: number;
  pagesize?: number;
}

// ── Transport ────────────────────────────────────────────────────────────────

async function posGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> {
  const qs = new URLSearchParams();
  Object.entries(params).forEach(([k, v]) => {
    if (v !== undefined && v !== null && v !== '') qs.append(k, String(v));
  });
  const query = qs.toString();
  const res = await fetch(`${POS_BASE}/${endpoint}${query ? `?${query}` : ''}`, {
    headers: { Accept: 'application/json' },
  });
  if (!res.ok) {
    // 404 from /sales/detail is a legitimate "no such bill at this outlet",
    // not a transport failure — surfaced as null by getPosSaleDetail.
    throw new Error(`POS ${endpoint} failed: ${res.status} ${res.statusText}`);
  }
  return res.json() as Promise<T>;
}

/** The sales reads use the `{code, status, details}` envelope. Note there is no
 *  `message` key on success — only on errors. */
function details<T>(json: unknown): T | null {
  if (json && typeof json === 'object' && 'details' in json) {
    return (json as { details: T }).details ?? null;
  }
  return null;
}

/**
 * Run `fn` over `items` with at most `limit` in flight.
 *
 * The tenant views fan out one request per outlet. The largest tenant on the
 * platform has 22 outlets, so an unbounded fan-out would open 44 sockets at
 * once (health + summary) on every refresh. Six keeps it civil without making
 * the page feel serial.
 */
export async function mapLimit<T, R>(
  items: T[],
  limit: number,
  fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
  const out = new Array<R>(items.length);
  let cursor = 0;
  const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
    for (;;) {
      const i = cursor++;
      if (i >= items.length) return;
      out[i] = await fn(items[i], i);
    }
  });
  await Promise.all(workers);
  return out;
}

// ── Reads ────────────────────────────────────────────────────────────────────

/**
 * A page of bills for one outlet, newest first.
 *
 * `pagesize` is clamped to 500 here because the server does *not* clamp — it
 * resets anything over 500 back to 50, so asking for 1000 quietly returns 50.
 */
export async function getPosSales(filter: PosSalesFilter): Promise<PosSalesPage> {
  const pagesize = Math.min(Math.max(filter.pagesize ?? 50, 1), 500);
  const json = await posGet('sales', {
    locationid: filter.locationid,
    fromdate: filter.fromdate,
    todate: filter.todate,
    terminalid: filter.terminalid,
    cashiername: filter.cashiername,
    paymentmode: filter.paymentmode,
    pageno: filter.pageno ?? 0,
    pagesize,
  });
  return (
    details<PosSalesPage>(json) ?? { total: 0, pageno: 0, pagesize, bills: [] }
  );
}

/**
 * One bill with its lines. `reference` matches any of three columns server-side
 * — the terminal's UUID, the invoice number, or the posorderid — so a support
 * call can start from whichever the caller is looking at.
 *
 * Returns null on 404, which is what the server sends when the reference is
 * real but belongs to a different outlet.
 */
export async function getPosSaleDetail(
  locationid: number,
  reference: string,
): Promise<PosBill | null> {
  try {
    const json = await posGet('sales/detail', { locationid, reference });
    return details<PosBill>(json);
  } catch {
    return null;
  }
}

export async function getPosSalesSummary(filter: PosSalesFilter): Promise<PosSalesSummary | null> {
  const json = await posGet('sales/summary', {
    locationid: filter.locationid,
    fromdate: filter.fromdate,
    todate: filter.todate,
    terminalid: filter.terminalid,
    cashiername: filter.cashiername,
    paymentmode: filter.paymentmode,
  });
  return details<PosSalesSummary>(json);
}

/** One product exactly as a till stores it. */
export interface PosCatalogueProduct {
  id: string;
  name: string;
  barcode: string;
  sku: string;
  category: string;
  price: number;
  mrp?: number;
  stock: number;
  unit: string;
  /** A fraction (0.05), matching how the till holds it. */
  gst_rate: number;
  hsn_code?: string;
  brand?: string;
  /** False for anything a till must not be able to ring up — chiefly unpriced
   *  products, which would otherwise sell at ₹0. */
  is_active: boolean;
}

export interface PosCatalogueCustomer {
  id: string;
  name: string;
  mobile: string;
  loyalty_points: number;
  lifetime_spend: number;
  visit_count: number;
  last_visit_at?: string;
}

export interface PosCatalogue {
  revision: string;
  is_delta: boolean;
  has_more: boolean;
  products: PosCatalogueProduct[];
  customers: PosCatalogueCustomer[];
  retired_product_ids: string[];
}

/**
 * What a till at this store currently sees.
 *
 * Answered as a **bare body**, not the `{code, status, details}` envelope the
 * sales reads use — it is a terminal-facing endpoint, so it is parsed directly.
 *
 * Always called without `since`, which yields a full snapshot. A delta would be
 * cheaper but describes only what changed, and this panel is asking what the
 * till can sell *right now*.
 */
export async function getPosCatalogue(storeId: number, pageSize = 500): Promise<PosCatalogue> {
  const json = await posGet<PosCatalogue>('catalogue', { store_id: storeId, page_size: pageSize });
  return {
    revision: json?.revision ?? '',
    is_delta: Boolean(json?.is_delta),
    has_more: Boolean(json?.has_more),
    products: json?.products ?? [],
    customers: json?.customers ?? [],
    retired_product_ids: json?.retired_product_ids ?? [],
  };
}

export async function getPosLocationHealth(locationid: number): Promise<PosLocationHealth> {
  const json = await posGet('health/location', { location_id: locationid });
  return (
    details<PosLocationHealth>(json) ?? {
      location_id: String(locationid),
      total: 0,
      online: 0,
      terminals: [],
    }
  );
}

// ── Parsing the health hash ──────────────────────────────────────────────────

/**
 * Four states, not two.
 *
 * The heartbeat is every 30s under a 90s TTL, so between those a live till has
 * no fresh reading and is *not* dead — showing two states would make healthy
 * tills blink red. And a key can exist while saying "offline", because the
 * broker's Last Will overwrites the status when a till loses power mid-shift.
 * That is the only way to tell *closed for the night* from *unplugged*, so it
 * is kept distinct from a key that simply expired.
 */
export type TerminalState = 'online' | 'stale' | 'offline_declared' | 'offline_vanished';

export interface PosTerminal {
  terminalId: string;
  locationId: string;
  storeName: string;
  appVersion: string;
  state: TerminalState;
  /** Server-supplied explanation on the expired-key stub. */
  reason: string;
  pendingBills: number;
  pendingRegistrations: number;
  oldestPendingAt: string;
  todayBills: number;
  todayAmount: number;
  lastBillAt: string;
  reportedAt: string;
  receivedAt: string;
  /** Seconds since the *server* stamped the reading. Deliberately measured from
   *  `received_at`, never `reported_at` — a till with a wrong clock must not be
   *  able to make itself look fresh. */
  ageSeconds: number | null;
  /** `reported_at` minus `received_at`, in seconds. A large value means the
   *  till's clock is wrong, which matters because bills are filed under the
   *  business date the till decided. */
  clockDriftSeconds: number | null;
  /** Absent when the till does not collect them — never coerced to zero, or a
   *  board would show every terminal on a flat battery. */
  batteryLevel: number | null;
  batteryCharging: boolean | null;
  storageFreeMb: number | null;
  printerReachable: boolean | null;
  drawerStatus: string | null;
}

const s = (h: PosTerminalRaw, k: string): string => (h[k] ?? '').trim();

function optNum(h: PosTerminalRaw, k: string): number | null {
  const raw = s(h, k);
  if (raw === '') return null;
  const n = Number(raw);
  return Number.isFinite(n) ? n : null;
}

/** Redis stores a Go bool as "1"/"0"; tolerate "true"/"false" too. */
function optBool(h: PosTerminalRaw, k: string): boolean | null {
  const raw = s(h, k).toLowerCase();
  if (raw === '') return null;
  return raw === '1' || raw === 'true';
}

function secondsSince(iso: string, now: number): number | null {
  if (!iso) return null;
  const t = Date.parse(iso);
  return Number.isFinite(t) ? Math.round((now - t) / 1000) : null;
}

export function parseTerminal(h: PosTerminalRaw, now: number = Date.now()): PosTerminal {
  const status = s(h, 'status').toLowerCase();
  const reason = s(h, 'reason');
  const receivedAt = s(h, 'received_at');
  const reportedAt = s(h, 'reported_at');
  const ageSeconds = secondsSince(receivedAt, now);

  // The expired-key stub is exactly {terminal_id, location_id, status, reason}
  // — it carries no heartbeat fields at all, which is how it's told apart from
  // a till that is present and declaring itself offline.
  const isStub = status === 'offline' && reason !== '' && receivedAt === '';

  let state: TerminalState;
  if (isStub) state = 'offline_vanished';
  else if (status !== 'online') state = 'offline_declared';
  else if (ageSeconds == null || ageSeconds <= 40) state = 'online';
  else state = 'stale';

  const reportedT = reportedAt ? Date.parse(reportedAt) : NaN;
  const receivedT = receivedAt ? Date.parse(receivedAt) : NaN;

  return {
    terminalId: s(h, 'terminal_id'),
    locationId: s(h, 'location_id'),
    storeName: s(h, 'store_name'),
    appVersion: s(h, 'app_version'),
    state,
    reason,
    pendingBills: optNum(h, 'pending_bills') ?? 0,
    pendingRegistrations: optNum(h, 'pending_registrations') ?? 0,
    oldestPendingAt: s(h, 'oldest_pending_at'),
    todayBills: optNum(h, 'today_bills') ?? 0,
    todayAmount: optNum(h, 'today_amount') ?? 0,
    lastBillAt: s(h, 'last_bill_at'),
    reportedAt,
    receivedAt,
    ageSeconds,
    clockDriftSeconds:
      Number.isFinite(reportedT) && Number.isFinite(receivedT)
        ? Math.round((reportedT - receivedT) / 1000)
        : null,
    batteryLevel: optNum(h, 'battery_level'),
    batteryCharging: optBool(h, 'battery_charging'),
    storageFreeMb: optNum(h, 'storage_free_mb'),
    printerReachable: optBool(h, 'printer_reachable'),
    drawerStatus: s(h, 'drawer_status') || null,
  };
}

export const TERMINAL_STATE_LABEL: Record<TerminalState, string> = {
  online: 'Online',
  stale: 'Stale',
  offline_declared: 'Offline',
  offline_vanished: 'No heartbeat',
};

export const TERMINAL_STATE_COLOR: Record<TerminalState, string> = {
  online: '#10b981',
  stale: '#f59e0b',
  offline_declared: '#ef4444',
  offline_vanished: '#94a3b8',
};

// ── Merging a fan-out ────────────────────────────────────────────────────────

export interface PosOutletSummary {
  locationid: number;
  name: string;
  summary: PosSalesSummary | null;
}

/**
 * Fold per-outlet summaries into one.
 *
 * `averagebill` is **recomputed** as `Σtotal / Σbills`, never averaged. Taking
 * the mean of twelve outlets' averages weights a four-bill kiosk the same as a
 * nine-hundred-bill store, which is not a number anyone should act on.
 */
export function mergeSummaries(parts: Array<PosSalesSummary | null>): PosSalesSummary {
  const merged: PosSalesSummary = {
    locationid: 0,
    fromdate: '',
    todate: '',
    billcount: 0,
    itemcount: 0,
    grosssales: 0,
    taxcollected: 0,
    discountgiven: 0,
    roundoff: 0,
    averagebill: 0,
    bypaymentmode: [],
    byday: [],
    byterminal: [],
  };

  const byMode = new Map<string, PosPaymentTotal>();
  const byDay = new Map<string, PosDayTotal>();
  const byTerm = new Map<string, PosTerminalTotal>();

  for (const p of parts) {
    if (!p) continue;
    merged.billcount += p.billcount || 0;
    merged.itemcount += p.itemcount || 0;
    merged.grosssales += p.grosssales || 0;
    merged.taxcollected += p.taxcollected || 0;
    merged.discountgiven += p.discountgiven || 0;
    merged.roundoff += p.roundoff || 0;
    merged.fromdate ||= p.fromdate;
    merged.todate ||= p.todate;

    for (const m of p.bypaymentmode ?? []) {
      const key = m.paymentmode || '';
      const at = byMode.get(key) ?? { paymentmode: key, billcount: 0, amount: 0 };
      at.billcount += m.billcount || 0;
      at.amount += m.amount || 0;
      byMode.set(key, at);
    }
    for (const d of p.byday ?? []) {
      const at = byDay.get(d.businessdate) ?? { businessdate: d.businessdate, billcount: 0, amount: 0 };
      at.billcount += d.billcount || 0;
      at.amount += d.amount || 0;
      byDay.set(d.businessdate, at);
    }
    for (const t of p.byterminal ?? []) {
      const key = t.terminalid || '';
      const at = byTerm.get(key) ?? { terminalid: key, billcount: 0, amount: 0 };
      at.billcount += t.billcount || 0;
      at.amount += t.amount || 0;
      byTerm.set(key, at);
    }
  }

  merged.averagebill = merged.billcount > 0 ? merged.grosssales / merged.billcount : 0;
  merged.bypaymentmode = [...byMode.values()].sort((a, b) => b.amount - a.amount);
  merged.byday = [...byDay.values()].sort((a, b) => a.businessdate.localeCompare(b.businessdate));
  merged.byterminal = [...byTerm.values()].sort((a, b) => b.amount - a.amount);
  return merged;
}

/** Average bill for one outlet, recomputed rather than trusted — same trap as
 *  the merge above, and the store leaderboard is where it would bite. */
export const averageBill = (sm: PosSalesSummary | null): number =>
  sm && sm.billcount > 0 ? sm.grosssales / sm.billcount : 0;

// ── Bill helpers ─────────────────────────────────────────────────────────────

export interface PosPaymentSplit { method: string; amount: number; reference?: string }
export interface PosPromo { id?: string; name?: string; type?: string; amount?: number }

function parseJsonField<T>(raw: string | undefined, fallback: T): T {
  if (!raw) return fallback;
  try {
    const v = JSON.parse(raw);
    return (v ?? fallback) as T;
  } catch {
    return fallback;
  }
}

/** The full tender split. A bill can be part cash, part card, part loyalty, and
 *  `paymentmode` only names the largest of them. */
export const billPayments = (b: PosBill): PosPaymentSplit[] =>
  parseJsonField<PosPaymentSplit[]>(b.paymentsjson, []);

/** GST per slab, as printed on the tax invoice. Keys are rate fractions
 *  ("0.05"), values are the tax amount at that slab. */
export const billTaxBreakdown = (b: PosBill): Array<{ rate: number; amount: number }> => {
  const raw = parseJsonField<Record<string, number>>(b.taxbreakdownjson, {});
  return Object.entries(raw)
    .map(([rate, amount]) => ({ rate: Number(rate), amount: Number(amount) }))
    .filter((r) => Number.isFinite(r.rate) && Number.isFinite(r.amount))
    // Tills send a `{"0.0": 0}` placeholder on zero-rated bills. Rendering it
    // as a "0.00% — ₹0.00" slab reads like a real tax line for an untaxed sale.
    .filter((r) => r.amount !== 0 || r.rate !== 0)
    .sort((a, b2) => a.rate - b2.rate);
};

/** Who the bill was rung for. A till can attach a customer id without a name or
 *  mobile, so "Walk-in" is reserved for bills with no customer at all. */
export function billCustomer(b: PosBill): string {
  const named = (b.customername || '').trim() || (b.customermobile || '').trim();
  if (named) return named;
  return b.customerid > 0 ? `Customer #${b.customerid}` : 'Walk-in';
}

export const billPromos = (b: PosBill): PosPromo[] => parseJsonField<PosPromo[]>(b.promosjson, []);

export type SyncLag =
  | { kind: 'legacy'; seconds: number }
  | { kind: 'ok'; seconds: number }
  | { kind: 'delayed'; seconds: number }
  | { kind: 'unknown' };

/**
 * How long a bill took to reach us.
 *
 * A negative value is **not** a fast upload — it is a bill written before the
 * till started sending a UTC offset, whose `billedat` holds IST wall-clock
 * stamped as though it were UTC. Those come back exactly 5h30m "early". They
 * are classed `legacy` rather than alerted on, because flooring the panel at a
 * date would still leak the ones rung on the morning of the fix.
 *
 * `businessdate` is unaffected by that skew (storing local wall-clock as UTC
 * preserves the date), so day bucketing and the date filters stay correct — the
 * damage is confined to this one comparison.
 */
export function syncLag(b: PosBill): SyncLag {
  const billed = Date.parse(b.billedat);
  const received = Date.parse(b.receivedat);
  if (!Number.isFinite(billed) || !Number.isFinite(received)) return { kind: 'unknown' };
  const seconds = Math.round((received - billed) / 1000);
  if (seconds < 0) return { kind: 'legacy', seconds };
  if (seconds > 900) return { kind: 'delayed', seconds };
  return { kind: 'ok', seconds };
}

/** Terminal ids are blank on bills ingested before the fallback landed. Shown
 *  as a named bucket rather than an empty cell so the column reads honestly. */
export const terminalLabel = (t: string): string => (t && t.trim()) || 'Unassigned';

export const todayISO = (): string => {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};

export const daysAgoISO = (days: number): string => {
  const d = new Date();
  d.setDate(d.getDate() - days);
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};

export const inr = (n: number): string =>
  `₹${(Number.isFinite(n) ? n : 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;

export const inrCompact = (n: number): string =>
  `₹${(Number.isFinite(n) ? n : 0).toLocaleString('en-IN', { maximumFractionDigits: 0 })}`;

/** Duration in the units a person reading a sync board actually wants. */
export function humanDuration(seconds: number): string {
  const abs = Math.abs(seconds);
  if (abs < 60) return `${abs}s`;
  if (abs < 3600) return `${Math.round(abs / 60)}m`;
  if (abs < 86400) return `${(abs / 3600).toFixed(1)}h`;
  return `${(abs / 86400).toFixed(1)}d`;
}

/** `2026-08-05T17:29:00Z` → `17:29`. Rendered as sent; no timezone maths, since
 *  the stored value's offset is not trustworthy across the fix boundary. */
export function billTime(iso: string): string {
  if (!iso) return '—';
  const m = iso.match(/T(\d{2}:\d{2})/);
  return m ? m[1] : '—';
}
