import { marketingRoutes } from './site';

/**
 * Pure helpers for the cookie-less page-view beacon (app/api/a/route.ts) and the reports built on
 * it. Unit-tested. We keep a path and a referrer *host* — nothing that identifies a person.
 */

export const MAX_BEACON_BYTES = 2048;

/** The beacon only runs in the marketing layout, so these are the only paths a real view can have. */
const TRACKED_PATHS: ReadonlySet<string> = new Set(marketingRoutes.map((r) => r.path));

/**
 * The canonical route for a beacon path, or null when it isn't a site route. Only known routes are
 * stored, so scripted beacons can't fill the table (and the admin's top paths) with an unbounded set
 * of invented paths. A trailing slash is tolerated and dropped.
 */
export function trackedPath(path: string): string | null {
  const canonical = /^\/[^/].*\/$/.test(path) ? path.slice(0, -1) : path;
  return TRACKED_PATHS.has(canonical) ? canonical : null;
}

/** Declared body size, or why it is unusable: a beacon must say how big it is before we read it. */
export function declaredBodyLength(header: string | null): { ok: true; bytes: number } | { ok: false; status: 400 | 411 | 413 } {
  if (header === null) return { ok: false, status: 411 };
  if (!/^\d{1,10}$/.test(header.trim())) return { ok: false, status: 400 };
  const bytes = Number(header.trim());
  if (bytes > MAX_BEACON_BYTES) return { ok: false, status: 413 };
  return { ok: true, bytes };
}

/**
 * Read at most `max` bytes of a body. Returns null as soon as more arrive (the stream is cancelled,
 * not drained), so a lying or missing Content-Length can never make us buffer a huge upload.
 */
export async function readBodyCapped(body: ReadableStream<Uint8Array> | null, max: number): Promise<string | null> {
  if (!body) return '';
  const reader = body.getReader();
  const chunks: Uint8Array[] = [];
  let size = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    size += value.byteLength;
    if (size > max) {
      await reader.cancel().catch(() => undefined);
      return null;
    }
    chunks.push(value);
  }
  return new TextDecoder().decode(Buffer.concat(chunks, size));
}

const BOT_PATTERN =
  /bot|crawl|spider|slurp|bingpreview|mediapartners|facebookexternalhit|embedly|quora link preview|outbrain|pinterest|vkshare|w3c_validator|whatsapp|telegram|discord|slack|lighthouse|pagespeed|headless|phantomjs|puppeteer|playwright|selenium|curl|wget|python|axios|node-fetch|undici|go-http-client|java\/|okhttp|httpclient|libwww|preview/i;

/** Crawlers, link unfurlers, monitoring and automation (including our own e2e runs) are not visitors. */
export function isBot(userAgent: string | null | undefined): boolean {
  if (!userAgent || userAgent.trim().length < 8) return true;
  return BOT_PATTERN.test(userAgent);
}

/** Visitor asked not to be tracked (Do Not Track or Global Privacy Control). */
export function prefersNoTracking(headers: Pick<Headers, 'get'>): boolean {
  return headers.get('dnt') === '1' || headers.get('sec-gpc') === '1';
}

/** Keep only a clean same-site pathname: no query string or fragment (they can carry personal data). */
export function sanitizePath(value: unknown): string | null {
  if (typeof value !== 'string') return null;
  const path = value.split(/[?#]/, 1)[0] ?? '';
  if (!path.startsWith('/') || path.startsWith('//') || path.length > 256) return null;
  if (!/^\/[A-Za-z0-9\-._~/%]*$/.test(path)) return null;
  return path;
}

/** Reduce a referrer URL to its host; drop internal, non-web and malformed referrers. */
export function referrerHost(value: unknown, ownHost: string): string | null {
  if (typeof value !== 'string' || value.length === 0 || value.length > 2048) return null;
  let url: URL;
  try {
    url = new URL(value);
  } catch {
    return null;
  }
  if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
  const host = url.hostname.toLowerCase().replace(/^www\./, '');
  if (!host || host.length > 253) return null;
  if (host === ownHost.toLowerCase().replace(/^www\./, '')) return null;
  return host;
}

export interface PageView {
  path: string;
  referrer: string | null;
}

/** Parse the beacon body `{"p": "/path", "r": "https://referrer"}`. Null when unusable. */
export function parseBeacon(body: string, ownHost: string): PageView | null {
  if (body.length === 0 || body.length > MAX_BEACON_BYTES) return null;
  let data: unknown;
  try {
    data = JSON.parse(body);
  } catch {
    return null;
  }
  if (typeof data !== 'object' || data === null) return null;
  const record = data as Record<string, unknown>;
  const path = sanitizePath(record.p);
  if (!path) return null;
  return { path, referrer: referrerHost(record.r, ownHost) };
}

// ───────────────────────────── Reports ─────────────────────────────

/** Spec §11 pilot hypotheses — targets to judge the pilot against, not benchmarks. */
export const PILOT_TARGETS = {
  /** Identifiable players returning within 30 days. */
  returningGuests: 0.3,
  /** Players playing more than one round per visit. */
  multiRoundVisits: 0.5,
} as const;

/** part / whole, or null when there is nothing to divide (so the UI can say "no data" instead of 0%). */
export function share(part: number, whole: number): number | null {
  return whole > 0 ? part / whole : null;
}

export type TargetStatus = 'met' | 'below' | 'no-data';

export function targetStatus(value: number | null, target: number): TargetStatus {
  if (value === null) return 'no-data';
  return value >= target ? 'met' : 'below';
}

/** "1.7 s" for a latency in milliseconds; "—" without data. */
export function formatSeconds(ms: number | null): string {
  if (ms === null || !Number.isFinite(ms)) return '—';
  return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} s`;
}

/** Calendar date arithmetic on 'YYYY-MM-DD' strings (UTC, so no DST surprises). */
function shiftDay(day: string, delta: number): string {
  const [y, m, d] = day.split('-').map(Number) as [number, number, number];
  return new Date(Date.UTC(y, m - 1, d + delta)).toISOString().slice(0, 10);
}

/**
 * One entry per calendar day for the `days` days ending at `lastDay`, oldest first, with 0 for
 * days that have no row — so a chart shows quiet days instead of silently skipping them.
 */
export function fillDailySeries(rows: readonly { day: string; count: number }[], lastDay: string, days: number): { day: string; count: number }[] {
  const byDay = new Map(rows.map((r) => [r.day, r.count]));
  return Array.from({ length: days }, (_, i) => {
    const day = shiftDay(lastDay, i - days + 1);
    return { day, count: byDay.get(day) ?? 0 };
  });
}
