import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';

const ROOT = process.cwd();
const FIXTURES = path.join(ROOT, 'fixtures');
const CACHE = path.join(ROOT, '.cache', 'sources');

export const useFixtures = process.env.GNV_FIXTURES === '1';

/** Identify ourselves honestly so brewery webmasters know who's calling. */
const USER_AGENT = 'gnv.beer-aggregator/1.0 (+https://gnv.beer)';
const TIMEOUT_MS = 15_000;

interface FetchOpts {
  /** Fixture filename under ./fixtures used when GNV_FIXTURES=1. */
  fixture: string;
  /** Check the site's robots.txt before fetching (for HTML pages we scrape). */
  respectRobots?: boolean;
}

export class RobotsDisallowed extends Error {
  constructor(url: string) {
    super(`robots.txt disallows ${url}`);
  }
}

export async function fetchText(url: string, opts: FetchOpts): Promise<string> {
  if (useFixtures) {
    return readFile(path.join(FIXTURES, opts.fixture), 'utf8');
  }
  if (opts.respectRobots && !(await robotsAllows(url))) {
    throw new RobotsDisallowed(url);
  }
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
  try {
    const res = await fetch(url, {
      signal: ctrl.signal,
      headers: { 'user-agent': USER_AGENT, accept: '*/*' },
      redirect: 'follow',
    });
    if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
    return await res.text();
  } finally {
    clearTimeout(timer);
  }
}

export async function fetchJson<T = any>(url: string, opts: FetchOpts): Promise<T> {
  const text = await fetchText(url, opts);
  try {
    return JSON.parse(text) as T;
  } catch {
    throw new Error(`Expected JSON from ${url}, got: ${text.slice(0, 80)}`);
  }
}

// ---------------------------------------------------------------- robots.txt

const robotsCache = new Map<string, Promise<string>>();

/**
 * Minimal robots.txt check: honours Disallow/Allow in the "*" group and any
 * group naming gnv.beer. Longest match wins, as in RFC 9309.
 */
export async function robotsAllows(url: string): Promise<boolean> {
  const u = new URL(url);
  let txtP = robotsCache.get(u.origin);
  if (!txtP) {
    txtP = fetch(`${u.origin}/robots.txt`, { headers: { 'user-agent': USER_AGENT } })
      .then((r) => (r.ok ? r.text() : ''))
      .catch(() => '');
    robotsCache.set(u.origin, txtP);
  }
  const txt = await txtP;
  const rules = parseRobots(txt);
  const target = u.pathname + u.search;
  let best: { allow: boolean; len: number } = { allow: true, len: -1 };
  for (const r of rules) {
    if (r.path && target.startsWith(r.path) && r.path.length > best.len) {
      best = { allow: r.allow, len: r.path.length };
    }
  }
  return best.allow;
}

function parseRobots(txt: string): { allow: boolean; path: string }[] {
  const groups: { agents: string[]; rules: { allow: boolean; path: string }[] }[] = [];
  let cur: (typeof groups)[number] | null = null;
  let lastWasAgent = false;
  for (const raw of txt.split(/\r?\n/)) {
    const line = raw.replace(/#.*/, '').trim();
    const m = line.match(/^([a-z-]+)\s*:\s*(.*)$/i);
    if (!m) continue;
    const key = m[1].toLowerCase();
    const val = m[2].trim();
    if (key === 'user-agent') {
      if (!cur || !lastWasAgent) {
        cur = { agents: [], rules: [] };
        groups.push(cur);
      }
      cur.agents.push(val.toLowerCase());
      lastWasAgent = true;
    } else {
      lastWasAgent = false;
      if (!cur) continue;
      if (key === 'disallow' || key === 'allow') {
        cur.rules.push({ allow: key === 'allow', path: val });
      }
    }
  }
  const mine = groups.filter((g) => g.agents.some((a) => a.includes('gnv.beer')));
  const chosen = mine.length ? mine : groups.filter((g) => g.agents.includes('*'));
  // An empty "Disallow:" means "allow everything", so it contributes no rule.
  return chosen.flatMap((g) => g.rules).filter((r) => r.path !== '');
}

// ------------------------------------------------------------- last-good cache

export async function saveLastGood(key: string, data: unknown) {
  if (useFixtures) return;
  await mkdir(CACHE, { recursive: true });
  await writeFile(path.join(CACHE, `${key}.json`), JSON.stringify({ savedAt: new Date().toISOString(), data }));
}

export async function loadLastGood<T>(key: string): Promise<{ savedAt: string; data: T } | null> {
  const file = path.join(CACHE, `${key}.json`);
  if (!existsSync(file)) return null;
  try {
    return JSON.parse(await readFile(file, 'utf8'));
  } catch {
    return null;
  }
}
