import { load } from "cheerio";

type Env = {
  ALLOWED_ORIGIN?: string;
  RENDER_PROXY_URL?: string;
  RENDER_PROXY_SECRET?: string;
  PURGE_SECRET?: string;
  IMAGE_CACHE_BUST?: string;
};

const ZEROZERO_ORIGIN = "https://www.zerozero.pt";
const TEAM_GAMES_PATH = "/equipa/fc-foz/8493/jogos";
const TEAM_NEWS_PATH = "/equipa/fc-foz/8493/noticias";
const LIGA_PRO_PATH = "/competicao/af-porto-divisao-liga-pro-2383";
const TACA_PATH = "/competicao/af-porto-taca";
const FC_FOZ_NAME = "FC Foz";

const CACHE_TTL = {
  matches: 86_400,
  /** Com live ou jogo hoje: TTL curto para estados atualizarem rápido (mais idas ao proxy; mitigado por SWR no browser e cache do Worker). */
  matchesLive: 60,
  news: 86_400,
  newsDetail: 86_400,
  standings: 86_400,
  proxy: 86_400,
  zapping: 86_400,
};

const RETRY_ATTEMPTS = 3;
const RETRY_BASE_DELAY_MS = 600;
const NEWS_DETAILS_CONCURRENCY = 4;
const EXCERPT_MAX_LENGTH = 180;

// ---------------------------------------------------------------------------
// CORS
// ---------------------------------------------------------------------------

function corsHeaders(request: Request, env: Env) {
  const requestOrigin = request.headers.get("origin") || "";
  const allowedOrigin = env.ALLOWED_ORIGIN || requestOrigin || "*";

  return {
    "access-control-allow-origin": allowedOrigin,
    "access-control-allow-methods": "GET,OPTIONS",
    "access-control-allow-headers":
      request.headers.get("access-control-request-headers") || "content-type",
    "access-control-max-age": "86400",
    vary: "origin",
  };
}

function withCors(request: Request, env: Env, response: Response) {
  const headers = new Headers(response.headers);
  const cors = corsHeaders(request, env);
  for (const [k, v] of Object.entries(cors)) headers.set(k, v);
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });
}

function optionsResponse(request: Request, env: Env) {
  return new Response(null, { status: 204, headers: corsHeaders(request, env) });
}

// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------

const USER_AGENTS = [
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0",
  "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
];

function randomUserAgent() {
  return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}

function browserLikeHeaders(referer?: string): Record<string, string> {
  return {
    "user-agent": randomUserAgent(),
    accept:
      "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
    "accept-language": "pt-PT,pt;q=0.9,en-US;q=0.8,en;q=0.7",
    "accept-encoding": "gzip, deflate, br",
    "cache-control": "no-cache",
    pragma: "no-cache",
    "sec-ch-ua": '"Chromium";v="125", "Not.A/Brand";v="24"',
    "sec-ch-ua-mobile": "?0",
    "sec-ch-ua-platform": '"Windows"',
    "sec-fetch-dest": "document",
    "sec-fetch-mode": "navigate",
    "sec-fetch-site": "none",
    "sec-fetch-user": "?1",
    "upgrade-insecure-requests": "1",
    referer: referer || `${ZEROZERO_ORIGIN}/`,
  };
}

async function readHtmlResponse(response: Response) {
  const buffer = await response.arrayBuffer();
  const bytes = new Uint8Array(buffer);
  const contentType = String(response.headers.get("content-type") || "");
  const headerCharset = contentType.match(/charset\s*=\s*([^\s;]+)/i)?.[1];
  const probe = new TextDecoder("latin1").decode(bytes.slice(0, 2048));
  const metaCharset = probe.match(
    /charset\s*=\s*["']?\s*([a-z0-9\-_]+)/i,
  )?.[1];
  const charset = String(headerCharset || metaCharset || "").toLowerCase();
  const decoder =
    charset.includes("iso-8859-1") ||
    charset.includes("latin1") ||
    charset.includes("windows-1252")
      ? new TextDecoder("latin1")
      : new TextDecoder("utf-8");
  return decoder.decode(bytes);
}

async function fetchWithRetry(
  url: string,
  options: RequestInit,
  attempts = RETRY_ATTEMPTS,
  baseDelay = RETRY_BASE_DELAY_MS,
): Promise<Response> {
  let lastError: unknown = null;
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      const response = await fetch(url, options);
      if (!response.ok) {
        const snippet = await response.text().then(
          (t) => t.slice(0, 300),
          () => "(unreadable)",
        );
        throw new Error(
          `HTTP ${response.status} from ${url} – ${snippet}`,
        );
      }
      return response;
    } catch (error) {
      lastError = error;
      console.error(
        `[fetchWithRetry] attempt ${attempt}/${attempts} for ${url}: ${error}`,
      );
      if (attempt < attempts) {
        await new Promise((r) =>
          setTimeout(r, baseDelay * 2 ** (attempt - 1)),
        );
      }
    }
  }
  throw lastError || new Error("Fetch failed");
}

async function fetchZerozeroHtml(env: Env, path: string, referer?: string) {
  const targetUrl = `${ZEROZERO_ORIGIN}${path}`;

  if (env.RENDER_PROXY_URL) {
    const proxyBase = env.RENDER_PROXY_URL.replace(/\/+$/, "");
    const proxyUrl = `${proxyBase}/fetch?url=${encodeURIComponent(targetUrl)}`;
    const headers: Record<string, string> = {};
    if (env.RENDER_PROXY_SECRET) {
      headers["x-proxy-secret"] = env.RENDER_PROXY_SECRET;
    }
    const response = await fetchWithRetry(proxyUrl, {
      method: "GET",
      headers,
    });
    return readHtmlResponse(response);
  }

  const response = await fetchWithRetry(targetUrl, {
    method: "GET",
    headers: browserLikeHeaders(referer || `${ZEROZERO_ORIGIN}/`),
    redirect: "follow",
  });
  return readHtmlResponse(response);
}

// ---------------------------------------------------------------------------
// Parsing utilities
// ---------------------------------------------------------------------------

function toAbsoluteUrl(url?: string | null) {
  if (!url) return undefined;
  if (url.startsWith("http://") || url.startsWith("https://")) return url;
  if (url.startsWith("//")) return `https:${url}`;
  return `${ZEROZERO_ORIGIN}${url.startsWith("/") ? "" : "/"}${url}`;
}

function upgradeLogoUrl(url: string): string {
  // Transforma URLs de logos de equipas para CDN de alta resolução
  const patterns = [/\/logos\/equipas\/(.+)$/i, /\/img\/logos\/equipas\/(.+)$/i];
  for (const re of patterns) {
    const logoMatch = url.match(re);
    if (logoMatch) {
      return `https://cdn-img.staticzz.com/img/logos/equipas/${logoMatch[1]}`;
    }
  }
  return url;
}

/** Mesmo padrão dos logos: ficheiros em /img/news|noticias|galerias/ servidos direto da CDN. */
function upgradeNewsImageToStaticzzCdn(url: string): string {
  try {
    const u = new URL(url);
    const hz = u.hostname.toLowerCase();
    if (hz !== "zerozero.pt" && !hz.endsWith(".zerozero.pt")) return url;
    const m = u.pathname.match(/\/img\/(news|noticias|galerias)\/(.+)$/i);
    if (!m) return url;
    return `https://cdn-img.staticzz.com/img/${m[1].toLowerCase()}/${m[2]}`;
  } catch {
    return url;
  }
}

/**
 * Variante `imgS{width}`: listagens e headers usam S200–S400; o hero do artigo costuma ser S620+.
 * Normaliza sempre para `cdn-img.staticzz.com` e força largura mínima 620 quando o path é notícia.
 */
const NOTICIAS_IMG_S_TARGET_MIN = 620;

function normalizeNoticiasHeroImageUrl(url: string): string {
  try {
    const u = new URL(url);
    const m = u.pathname.match(
      /^(\/img\/(?:noticias|news)\/\d+\/)imgS(\d+)(I\d+T[^/]+\.(?:png|jpe?g|webp))$/i,
    );
    if (!m) return url;
    const w = parseInt(m[2], 10);
    const targetW =
      Number.isNaN(w) || w < NOTICIAS_IMG_S_TARGET_MIN ? NOTICIAS_IMG_S_TARGET_MIN : w;
    u.protocol = "https:";
    u.hostname = "cdn-img.staticzz.com";
    u.pathname = `${m[1]}imgS${targetW}${m[3]}`;
    u.search = "";
    return u.toString();
  } catch {
    return url;
  }
}

function looksLikeNewsThumbUrl(url: string): boolean {
  return /_(thumb|thumbs|t|sm|mini)(\.|$|\/)/i.test(url) ||
    /\/thumb|thumbnails?|\/mini|\/small\//i.test(url) ||
    /[?&]w=(?:[1-9]|[1-9]\d|1\d{2}|2\d{2}|300)\b/i.test(url) ||
    /[?&]h=(?:[1-9]|[1-9]\d|1\d{2}|2\d{2}|300)\b/i.test(url);
}

function scoreNewsHeroCandidate(url: string): number {
  let s = 0;
  const lower = url.toLowerCase();
  if (looksLikeNewsThumbUrl(url)) s -= 80;
  if (lower.includes("cdn-img.staticzz") || lower.includes("cdn-img.zerozero")) s += 25;
  if (/\b(1200|1000|960|900|800|720|640)\b/.test(url)) s += 20;
  const imgSw = url.match(/imgS(\d+)/i);
  if (imgSw) {
    const ww = parseInt(imgSw[1], 10);
    if (!Number.isNaN(ww)) s += Math.min(ww / 8, 45);
  }
  s += Math.min(url.length, 320) * 0.04;
  return s;
}

/**
 * Escolhe o melhor URL para hero (meta sociais costumam ser maiores que .photo_frame em mobile).
 */
function pickNewsHeroImage(
  candidates: Array<string | null | undefined>,
): string | undefined {
  const urls = candidates
    .map((c) => (c ? String(c).trim() : ""))
    .filter(Boolean)
    .map((c) => toAbsoluteUrl(c) || c)
    .filter(Boolean) as string[];

  const seen = new Set<string>();
  const unique: string[] = [];
  for (const u of urls) {
    const norm = u.split("?")[0].toLowerCase();
    if (seen.has(norm)) continue;
    seen.add(norm);
    unique.push(u);
  }

  if (!unique.length) return undefined;
  unique.sort((a, b) => scoreNewsHeroCandidate(b) - scoreNewsHeroCandidate(a));
  return unique[0];
}

/** Tenta subir resolução em URLs com query w/h ou sufixo _s. */
function tryUpscaleNewsMediaUrl(url: string | undefined): string | undefined {
  if (!url) return undefined;
  try {
    const u = new URL(url);
    const host = u.hostname.toLowerCase();
    const isZz =
      host === "zerozero.pt" ||
      host.endsWith(".zerozero.pt") ||
      host === "cdn-img.staticzz.com" ||
      host === "cdn-img.zerozero.pt";

    if (!isZz) return url;

    let changed = false;
    const w = u.searchParams.get("w");
    if (w) {
      const n = parseInt(w, 10);
      if (n > 0 && n < 900) {
        u.searchParams.set("w", "1200");
        changed = true;
      }
    }
    const h = u.searchParams.get("h");
    if (h) {
      const n = parseInt(h, 10);
      if (n > 0 && n < 900) {
        u.searchParams.set("h", "800");
        changed = true;
      }
    }
    if (changed) return u.toString();

    const path = u.pathname;
    const sm = path.match(/^(.+)_s(\.(jpe?g|png|webp))$/i);
    if (sm) {
      u.pathname = `${sm[1]}${sm[2]}`;
      return u.toString();
    }
    const md = path.match(/^(.+)_m(\.(jpe?g|png|webp))$/i);
    if (md) {
      u.pathname = `${md[1]}${md[2]}`;
      return u.toString();
    }
  } catch {
    return url;
  }
  return url;
}

function pickLargestFromSrcset(srcset: string | undefined): string | undefined {
  if (!srcset?.trim()) return undefined;
  let bestUrl: string | undefined;
  let bestW = 0;
  for (const part of srcset.split(",")) {
    const p = part.trim();
    if (!p) continue;
    const wMatch = p.match(/\s+(\d+)w\s*$/i);
    const xMatch = p.match(/\s+([\d.]+)x\s*$/i);
    let w = 0;
    let urlPart = p;
    if (wMatch) {
      w = parseInt(wMatch[1], 10);
      urlPart = p.slice(0, p.length - wMatch[0].length).trim();
    } else if (xMatch) {
      w = Math.round(parseFloat(xMatch[1]) * 960);
      urlPart = p.slice(0, p.length - xMatch[0].length).trim();
    } else {
      urlPart = p.split(/\s+/)[0] || p;
      w = 1;
    }
    const url = urlPart.replace(/^["']|["']$/g, "");
    if (!url) continue;
    let abs: string | undefined;
    if (url.startsWith("//")) abs = `https:${url}`;
    else if (/^https?:\/\//i.test(url)) abs = url;
    else if (url.startsWith("/")) abs = `${ZEROZERO_ORIGIN}${url}`;
    else continue;
    if (w >= bestW) {
      bestW = w;
      bestUrl = abs;
    }
  }
  return bestUrl;
}

function gatherUrlsFromImgElement(
  $: ReturnType<typeof load>,
  el: unknown,
): string[] {
  if (!el) return [];
  const $img = $(el as never);
  const out: string[] = [];
  for (const attr of [
    "data-src",
    "data-lazy-src",
    "data-original",
    "data-full",
    "data-large",
    "data-zoom-image",
    "data-image",
    "data-srcset",
  ]) {
    const v = $img.attr(attr);
    if (v?.trim()) {
      if (attr === "data-srcset") {
        const best = pickLargestFromSrcset(v);
        if (best) out.push(best);
      } else {
        out.push(v.trim());
      }
    }
  }
  const fromSs = pickLargestFromSrcset($img.attr("srcset"));
  if (fromSs) out.push(fromSs);
  const src = $img.attr("src");
  if (src?.trim()) out.push(src.trim());
  return out;
}

/** Hero a partir do DOM (lazy-load e srcset costumam ter versão maior que og:image). */
function extractHeroImageFromArticleDom($: ReturnType<typeof load>): string | undefined {
  const urls: string[] = [];

  $("#news_body picture source, #news_body .photo_frame picture source").each(
    (_, el) => {
      const best = pickLargestFromSrcset($(el).attr("srcset"));
      if (best) urls.push(best);
      const s = $(el).attr("src");
      if (s?.trim()) urls.push(s.trim());
    },
  );

  const imgSelectors = [
    "#news_body .photo_frame img",
    "#news_body .photo_frame a img",
    "#news_body .article-photo img",
    "#news_body .main_photo img",
    "#news_body .headline-photo img",
    "#news_body figure.photo img",
    "#news_body .news_photo img",
    "#news_body .news-photo img",
  ];
  for (const sel of imgSelectors) {
    const el = $(sel).get(0);
    if (el) urls.push(...gatherUrlsFromImgElement($, el));
  }

  if (!urls.length) return undefined;
  const resolved = urls
    .map((u) => toAbsoluteUrl(u) || u)
    .filter((u): u is string => Boolean(u && /^https?:\/\//i.test(u)));
  return pickNewsHeroImage(resolved);
}

/**
 * Imagens em application/ld+json (NewsArticle/Article) costumam ser o URL “editorial”
 * completo, por vezes melhor que og:image igual à miniatura da listagem.
 */
function findNewsArticleImageFromJsonLd($: ReturnType<typeof load>): string | undefined {
  const urls: string[] = [];
  $('script[type="application/ld+json"]').each((_, el) => {
    const raw = $(el).html();
    if (!raw?.trim()) return;
    let data: unknown;
    try {
      data = JSON.parse(raw.trim());
    } catch {
      return;
    }

    const blocks: unknown[] = [];
    const collect = (node: unknown) => {
      if (node == null) return;
      if (Array.isArray(node)) {
        node.forEach(collect);
        return;
      }
      if (typeof node !== "object") return;
      const o = node as Record<string, unknown>;
      if (Array.isArray(o["@graph"])) {
        for (const g of o["@graph"]) collect(g);
        return;
      }
      if (o.mainEntity) collect(o.mainEntity);
      blocks.push(node);
    };
    collect(data);

    for (const block of blocks) {
      if (!block || typeof block !== "object") continue;
      const o = block as Record<string, unknown>;
      const typeStr = Array.isArray(o["@type"])
        ? o["@type"].map((t) => String(t).toLowerCase()).join(" ")
        : String(o["@type"] || "").toLowerCase();
      if (!typeStr.includes("newsarticle") && !typeStr.includes("article")) continue;

      const imgVal = o.image;
      const pushImageVal = (v: unknown) => {
        if (typeof v === "string" && /^https?:\/\//i.test(v.trim())) {
          urls.push(v.trim());
        } else if (v && typeof v === "object") {
          const io = v as Record<string, unknown>;
          if (typeof io.url === "string" && /^https?:\/\//i.test(io.url.trim())) {
            urls.push(io.url.trim());
          }
        }
      };
      if (Array.isArray(imgVal)) {
        for (const v of imgVal) pushImageVal(v);
      } else {
        pushImageVal(imgVal);
      }
    }
  });

  if (!urls.length) return undefined;
  const unique = [...new Set(urls)];
  unique.sort((a, b) => scoreNewsHeroCandidate(b) - scoreNewsHeroCandidate(a));
  return unique[0];
}

function rewriteImageUrl(url: string | undefined, workerOrigin: string, env: Env): string | undefined {
  if (!url) return undefined;
  try {
    const parsed = new URL(url);
    const hz = parsed.hostname.toLowerCase();

    if (hz === "cdn-img.staticzz.com" || hz === "cdn-img.zerozero.pt") {
      return normalizeNoticiasHeroImageUrl(url);
    }

    if (hz === "zerozero.pt" || hz.endsWith(".zerozero.pt")) {
      const upgradedLogo = upgradeLogoUrl(url);
      if (upgradedLogo !== url) return normalizeNoticiasHeroImageUrl(upgradedLogo);
      const upgradedNews = upgradeNewsImageToStaticzzCdn(url);
      if (upgradedNews !== url) {
        return normalizeNoticiasHeroImageUrl(upgradedNews);
      }
      const encoded = btoa(url);
      const bust = env.IMAGE_CACHE_BUST ? `?v=${env.IMAGE_CACHE_BUST}` : "";
      return `${workerOrigin}/api/media/r/${encoded}${bust}`;
    }
  } catch {
    // url inválido, retorna como está
  }
  return normalizeNoticiasHeroImageUrl(url);
}

function rewriteUrls(data: unknown, workerOrigin: string, env: Env): unknown {
  if (Array.isArray(data)) return data.map((item) => rewriteUrls(item, workerOrigin, env));
  if (data && typeof data === "object") {
    const result: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
      if (["logo", "image", "homeLogo", "awayLogo", "opponentLogo", "teamLogo", "externalUrl"].includes(key)) {
        result[key] = rewriteImageUrl(value as string | undefined, workerOrigin, env);
      } else {
        result[key] = rewriteUrls(value, workerOrigin, env);
      }
    }
    return result;
  }
  return data;
}

function parseMatchday(value: string) {
  const match = String(value || "").match(/J\s*(\d+)/i);
  return match ? Number(match[1]) : undefined;
}

function parseScore(value: string) {
  const cleaned = String(value || "")
    .replace(/\s+/g, "")
    .trim();
  const match = cleaned.match(/^(\d+)-(\d+)$/);
  if (!match) return {};
  return { homeScore: Number(match[1]), awayScore: Number(match[2]) };
}

function todayInLisbon(): string {
  return new Date()
    .toLocaleDateString("en-CA", { timeZone: "Europe/Lisbon" })
    .slice(0, 10);
}

function parseStatusFromRow(
  resultCell: string,
  date: string,
  time: string,
  rowClasses?: string,
): "finished" | "scheduled" | "postponed" | "live" {
  if (rowClasses && /\b(live|ao-?vivo|em-?jogo)\b/i.test(rowClasses))
    return "live";

  const { homeScore, awayScore } = parseScore(resultCell);
  const hasScore = Number.isFinite(homeScore) && Number.isFinite(awayScore);
  const today = todayInLisbon();

  if (hasScore && date === today && time) {
    const [h, m] = time.split(":").map(Number);
    if (!isNaN(h) && !isNaN(m)) {
      const lisbonOffset = getLisbonOffsetMinutes(date, h, m);
      const kickoffUtcMs =
        Date.UTC(
          Number(date.slice(0, 4)),
          Number(date.slice(5, 7)) - 1,
          Number(date.slice(8, 10)),
          h,
          m,
        ) -
        lisbonOffset * 60_000;
      const elapsed = (Date.now() - kickoffUtcMs) / 60_000;
      if (elapsed >= -15 && elapsed <= 135) return "live";
    }
  }

  if (hasScore) return "finished";
  if (!date) return "scheduled";
  return date < today ? "postponed" : "scheduled";
}

function getLisbonOffsetMinutes(
  date: string,
  hour: number,
  minute: number,
): number {
  const utcGuess = Date.UTC(
    Number(date.slice(0, 4)),
    Number(date.slice(5, 7)) - 1,
    Number(date.slice(8, 10)),
    hour,
    minute,
  );

  const lisbonFmt = new Intl.DateTimeFormat("en-CA", {
    timeZone: "Europe/Lisbon",
    hour12: false,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
  });

  const lisbonWallAtUtcGuess = (() => {
    const parts = lisbonFmt.formatToParts(new Date(utcGuess));
    const n = (type: Intl.DateTimeFormatPartTypes) => {
      const v = parts.find((p) => p.type === type)?.value;
      return v === undefined ? NaN : Number(v);
    };
    return {
      y: n("year"),
      mo: n("month"),
      d: n("day"),
      h: n("hour"),
      m: n("minute"),
    };
  })();

  if (
    [lisbonWallAtUtcGuess.y, lisbonWallAtUtcGuess.mo, lisbonWallAtUtcGuess.d, lisbonWallAtUtcGuess.h, lisbonWallAtUtcGuess.m].some(
      (x) => !Number.isFinite(x),
    )
  ) {
    const month = Number(date.slice(5, 7));
    return month >= 4 && month <= 9 ? 60 : 0;
  }

  const { y, mo, d, h, m } = lisbonWallAtUtcGuess;
  const lisbonMs = Date.UTC(y, mo - 1, d, h, m);
  return (lisbonMs - utcGuess) / 60_000;
}

function parsePtDateTime(value: string | undefined): string {
  const raw = String(value || "").trim();
  const match = raw.match(/^(\d{2})-(\d{2})-(\d{4})\s+(\d{2}):(\d{2})$/);
  if (!match) return new Date().toISOString();
  const [, day, month, year, hour, minute] = match;
  return `${year}-${month}-${day}T${hour}:${minute}:00Z`;
}

function parsePublishedAtFromArticle(value?: string) {
  const raw = String(value || "").trim();
  const match = raw.match(/(\d{4})\/(\d{2})\/(\d{2})\s+(\d{2}):(\d{2})/);
  if (!match) return undefined;
  const [, year, month, day, hour, minute] = match;
  return `${year}-${month}-${day}T${hour}:${minute}:00Z`;
}

function normalizeNewsCategory(value: string | undefined) {
  const normalized = String(value || "").toLowerCase();
  if (normalized.includes("opini")) return "opinião";
  if (normalized.includes("comunicad")) return "comunicado";
  if (normalized.includes("transfer")) return "transferências";
  if (normalized.includes("match") || normalized.includes("jogo"))
    return "match-report";
  return "notícias";
}

function truncateText(value: string, maxLength: number) {
  const text = String(value || "")
    .trim()
    .replace(/\s+/g, " ");
  if (!text) return "";
  if (text.length <= maxLength) return text;
  return `${text.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
}

// ---------------------------------------------------------------------------
// HTML Parsers
// ---------------------------------------------------------------------------

/**
 * A página /equipa/.../jogos cresce ao longo da época; `load(html)` sobre o documento
 * inteiro pode exceder limites do Workers (erro Cloudflare 1102). Cortamos à zona da
 * tabela "todos os jogos" antes do parse.
 */
function htmlSliceAroundAllMatchesTable(html: string): string {
  const lower = html.toLowerCase();
  const idx = lower.indexOf("todos os jogos");
  if (idx === -1) return html;
  const start = Math.max(0, idx - 5000);
  const maxSpan = 1_800_000;
  return html.slice(start, Math.min(html.length, start + maxSpan));
}

function parseMatchesHtml(html: string) {
  const $ = load(htmlSliceAroundAllMatchesTable(html));
  const teamLogo = toAbsoluteUrl(
    $(".zz-entity-header-team img[alt='FC Foz']").attr("src"),
  );

  const headerEl = $("h2.header")
    .toArray()
    .find((el) => $(el).text().toLowerCase().includes("todos os jogos"));

  const rows = headerEl
    ? $(headerEl).closest(".box").find("table.zztable.stats tbody tr.parent")
    : $();

  return rows
    .toArray()
    .map((row) => {
      const $row = $(row);
      const tds = $row.find("td").toArray();
      if (tds.length < 9) return null;

      const matchId =
        $row.attr("id") || `ext-${Math.random().toString(36).slice(2)}`;
      const date = $(tds[1]).text().trim();
      const time = $(tds[2]).text().trim();
      const side = $(tds[3]).text().trim();
      const opponentLogo = toAbsoluteUrl($(tds[4]).find("img").attr("src"));
      const opponentName = $(tds[5]).text().trim();
      const resultCell = $(tds[6]).text().trim();
      const competition = $(tds[7]).text().trim();
      const round = $(tds[8]).text().trim();

      if (!date || !opponentName) return null;

      const isHome = side.includes("(C)");
      const { homeScore, awayScore } = parseScore(resultCell);

      return {
        id: matchId,
        homeTeam: isHome ? FC_FOZ_NAME : opponentName,
        awayTeam: isHome ? opponentName : FC_FOZ_NAME,
        homeLogo: isHome ? teamLogo : opponentLogo,
        awayLogo: isHome ? opponentLogo : teamLogo,
        homeScore,
        awayScore,
        date,
        time,
        venue: isHome ? "Casa" : "Fora",
        competition: competition || "Competição",
        status: parseStatusFromRow(resultCell, date, time, $row.attr("class")),
        matchday: parseMatchday(round),
        source: "external",
      };
    })
    .filter(Boolean)
    .sort((a, b) =>
      `${a!.date} ${a!.time}`.localeCompare(`${b!.date} ${b!.time}`),
    );
}

function parseNewsListHtml(html: string) {
  const $ = load(html);
  const seen = new Set<string>();
  const news: Array<Record<string, unknown>> = [];

  const pushItem = (item: Record<string, unknown> | null) => {
    const slug = String(item?.slug || "");
    if (!slug || seen.has(slug)) return;
    seen.add(slug);
    news.push(item!);
  };

  $("#highlights > a")
    .toArray()
    .forEach((a) => {
      const href = $(a).attr("href") || "";
      const slug = href.split("/").filter(Boolean)[1] || "";
      const title = $(a).find("h2").text().trim();
      const excerpt = $(a).find("span.text").text().trim();
      const dateAuthor = $(a).find(".dateauthor").text().trim();
      const image = toAbsoluteUrl($(a).find("img").attr("src"));
      const categoryText = $(a).find(".keywords div").text().trim();

      if (!slug || !title) return;

      pushItem({
        id: `ext-${slug}`,
        title,
        slug,
        excerpt: excerpt || title,
        content: "",
        image,
        category: normalizeNewsCategory(categoryText),
        publishedAt: parsePtDateTime(dateAuthor),
        author: "Redação",
        source: "external",
        externalUrl: toAbsoluteUrl(href),
      });
    });

  $(".news_box_list")
    .toArray()
    .forEach((box) => {
      const titleLink = $(box).find(".newstitle a").first();
      const href = titleLink.attr("href") || "";
      const slug = href.split("/").filter(Boolean)[1] || "";
      const title = titleLink.text().trim();
      const excerpt = $(box).find(".newstext").text().trim();
      const dateAuthor = $(box).find(".dateauthor").text().trim();
      const image = toAbsoluteUrl(
        $(box).find(".newsphoto img").attr("src"),
      );
      const categoryText = $(box).find(".keywords div").text().trim();

      if (!slug || !title) return;

      pushItem({
        id: `ext-${slug}`,
        title,
        slug,
        excerpt: excerpt || title,
        content: "",
        image,
        category: normalizeNewsCategory(categoryText),
        publishedAt: parsePtDateTime(dateAuthor),
        author: "Redação",
        source: "external",
        externalUrl: toAbsoluteUrl(href),
      });
    });

  return news
    .filter((item) => !isOpinionContent(item))
    .sort((a, b) =>
      String(b.publishedAt || "").localeCompare(String(a.publishedAt || "")),
    );
}

function isOpinionContent(item: Record<string, unknown>, html?: string) {
  const url = String(item.externalUrl || "").toLowerCase();
  const title = String(item.title || "").toLowerCase();
  const category = String(item.category || "").toLowerCase();

  if (url.includes("/opiniao/") || url.includes("/opiniao-")) return true;
  if (title.includes("colunas de opini") || title.includes("opinião")) return true;
  if (category.includes("opini")) return true;
  if (html) {
    const lower = html.toLowerCase();
    if (lower.includes("hp_widget_opinions")) return true;
    if (lower.includes("colunas de opinião") || lower.includes("colunas de opini")) return true;
  }
  return false;
}

function parseNewsArticleHtml(
  html: string,
  baseItem: Record<string, unknown>,
): Record<string, unknown> | null {
  if (isOpinionContent(baseItem, html)) return null;

  const $ = load(html);
  const article = $("#news_body");
  const metaTitle = $("meta[property='og:title']").attr("content");
  const metaOgImage = $("meta[property='og:image']").attr("content");
  const metaOgImageSecure = $("meta[property='og:image:secure_url']").attr(
    "content",
  );
  const metaTwImage =
    $("meta[name='twitter:image']").attr("content") ||
    $("meta[name='twitter:image:src']").attr("content");
  const linkImageSrc = $("link[rel='image_src']").attr("href");
  const jsonLdImage = findNewsArticleImageFromJsonLd($);
  const domHeroImage = extractHeroImageFromArticleDom($);

  const title =
    article.find("header.title h1").first().text().trim() ||
    String(metaTitle || "").trim() ||
    String(baseItem.title || "");

  if (title.toLowerCase().includes("colunas de opini")) return null;

  const photoFrame =
    article.find(".photo_frame img").first().attr("src") ||
    article.find(".photo_frame a img").first().attr("src") ||
    article.find(".article-photo img").first().attr("src") ||
    article.find(".news-photo img").first().attr("src") ||
    article.find(".news_photo img").first().attr("src") ||
    article.find(".photo img").first().attr("src") ||
    article.find("figure.photo img").first().attr("src");

  const fromArticleOnly = [
    domHeroImage,
    jsonLdImage,
    metaOgImage,
    metaOgImageSecure,
    metaTwImage,
    linkImageSrc,
    photoFrame,
  ];
  const imageRaw =
    pickNewsHeroImage(fromArticleOnly) ||
    pickNewsHeroImage([baseItem.image as string | undefined]);
  const imageUpscaled = tryUpscaleNewsMediaUrl(imageRaw) || imageRaw;
  const image = normalizeNoticiasHeroImageUrl(
    upgradeNewsImageToStaticzzCdn(imageUpscaled),
  );

  const authorRaw = article.find(".author-name").first().text().trim();
  const authorResolved = authorRaw
    ? authorRaw.replace(/^Autor:\s*/i, "").trim()
    : baseItem.author;
  const author =
    authorResolved === "zerozero.pt" ? "Redação" : authorResolved;

  const publishedAt =
    parsePublishedAtFromArticle(article.find(".info_bar").text()) ||
    baseItem.publishedAt;

  const candidates = [
    article.find(".text.article-content").first(),
    article.find(".article-content").first(),
    article.find(".text").first(),
    article,
  ].filter((c) => c && c.length);

  const contentRoot =
    candidates
      .slice()
      .sort((a, b) => b.text().trim().length - a.text().trim().length)[0] ||
    article;

  contentRoot.find(".mid_article, script, style, noscript").remove();
  const paragraphs = contentRoot
    .find("p")
    .toArray()
    .map((p) => $(p).text().trim())
    .filter(Boolean);
  const content = paragraphs.length
    ? paragraphs.join("\n\n")
    : contentRoot.text().trim();

  return {
    ...baseItem,
    title,
    image,
    author,
    publishedAt,
    content,
    excerpt:
      baseItem.excerpt || truncateText(content, EXCERPT_MAX_LENGTH) || title,
  };
}

function parseStandingsHtml(html: string) {
  const $ = load(html);

  const header = $("h2.header")
    .toArray()
    .map((el) => $(el))
    .find((el) => el.text().trim().toLowerCase().includes("classificação"));

  let table: ReturnType<typeof $> | null = null;

  if (header) {
    const box = header.closest(".box, .card-data");
    const found = box.find("table").first();
    if (found.length) table = found;
  }

  if (!table) {
    const allTables = $("table").toArray().map((el) => $(el));
    for (const t of allTables) {
      const headerText = t
        .find("thead th")
        .toArray()
        .map((th) => $(th).text().trim().toLowerCase())
        .join("|");
      if (
        headerText.includes("pts") &&
        headerText.includes("j") &&
        (headerText.includes("v") || headerText.includes("e"))
      ) {
        table = t;
        break;
      }
    }
  }

  if (!table) return [];

  const headers = table
    .find("thead th")
    .toArray()
    .map((th) => $(th).text().trim().toLowerCase());

  const indexOf = (label: string) =>
    headers.findIndex((h) => h === label || h.includes(label));

  let idxPos = headers.findIndex(
    (h) => h === "#" || h === "pos" || h.includes("pos") || h === "º",
  );
  let idxTeam = headers.findIndex(
    (h) =>
      h.includes("equipa") ||
      h.includes("team") ||
      h.includes("clube") ||
      h === "clube",
  );
  let idxPts = headers.findIndex(
    (h) => h === "pts" || h === "p" || h.includes("pontos"),
  );
  let idxJ = indexOf("j");
  let idxV = indexOf("v");
  let idxE = indexOf("e");
  let idxD = indexOf("d");
  let idxGM = headers.findIndex(
    (h) => h.includes("gm") || h.includes("gp") || h === "gm",
  );
  let idxGS = headers.findIndex(
    (h) => h.includes("gs") || h.includes("gc") || h === "gs",
  );

  if (idxPos === -1) idxPos = 0;
  if (idxTeam === -1) idxTeam = 2;
  if (idxPts === -1) idxPts = 3;
  if (idxJ === -1) idxJ = 4;
  if (idxV === -1) idxV = 5;
  if (idxE === -1) idxE = 6;
  if (idxD === -1) idxD = 7;
  if (idxGM === -1) idxGM = 8;
  if (idxGS === -1) idxGS = 9;

  const rows = table.find("tbody tr").toArray();
  return rows
    .map((row) => {
      const cells = $(row).find("td").toArray();
      if (cells.length < 5) return null;

      const posValue = $(cells[idxPos]).text().trim();
      const position = parseInt(posValue, 10);
      if (isNaN(position)) return null;

      const teamCell = cells[idxTeam];
      const team = $(teamCell).text().trim().replace(/\s+/g, " ");
      const logo =
        toAbsoluteUrl($(teamCell).find("img").first().attr("src")) ||
        (idxTeam > 0
          ? toAbsoluteUrl(
              $(cells[idxTeam - 1])
                .find("img")
                .first()
                .attr("src"),
            )
          : undefined);

      const numberAt = (idx: number) => {
        if (idx === -1 || idx >= cells.length) return 0;
        const cleaned = $(cells[idx])
          .text()
          .trim()
          .replace(/[^\d]/g, "");
        const num = parseInt(cleaned, 10);
        return isNaN(num) ? 0 : num;
      };

      return {
        position,
        team,
        played: numberAt(idxJ),
        won: numberAt(idxV),
        drawn: numberAt(idxE),
        lost: numberAt(idxD),
        goalsFor: numberAt(idxGM),
        goalsAgainst: numberAt(idxGS),
        points: numberAt(idxPts),
        logo,
      };
    })
    .filter(Boolean);
}

function parseCupProgressHtml(html: string) {
  const $ = load(html);
  const tables = $("table.zztable, table.zztable.stats").toArray();
  const matches: Array<Record<string, unknown>> = [];

  for (const tableEl of tables) {
    const rows = $(tableEl).find("tbody tr").toArray();
    for (const row of rows) {
      const cells = $(row).find("td").toArray();
      const rowText = $(row).text();
      if (!rowText.includes(FC_FOZ_NAME)) continue;

      const href = $(row)
        .find("a[href*='/jogo/'], a[href*='/match/']")
        .first()
        .attr("href");
      const matchId = href
        ? href.split("/").filter(Boolean).pop() ||
          `ext-${Math.random().toString(36).slice(2)}`
        : `ext-${Math.random().toString(36).slice(2)}`;

      const date = $(cells[0]).text().trim();
      const homeTeam = $(cells[1]).text().trim();
      const awayTeam = $(cells[3]).text().trim();
      const result = $(cells[2]).text().trim();
      const { homeScore, awayScore } = parseScore(result);

      if (!date || !homeTeam || !awayTeam) continue;

      matches.push({
        id: matchId,
        homeTeam,
        awayTeam,
        homeScore,
        awayScore,
        date,
        time: "",
        venue:
          homeTeam === FC_FOZ_NAME
            ? "Casa"
            : awayTeam === FC_FOZ_NAME
              ? "Fora"
              : "-",
        competition: "AF Porto Taça",
        status: parseStatusFromRow(result, date, ""),
        source: "external",
      });
    }
  }

  return matches.sort((a, b) =>
    String(b.date).localeCompare(String(a.date)),
  );
}

// ---------------------------------------------------------------------------
// Concurrent mapper for news details
// ---------------------------------------------------------------------------

async function mapWithConcurrency<T, R>(
  items: T[],
  concurrency: number,
  mapper: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
  const results = new Array<R>(items.length);
  let nextIndex = 0;

  const worker = async () => {
    while (nextIndex < items.length) {
      const current = nextIndex;
      nextIndex += 1;
      results[current] = await mapper(items[current], current);
    }
  };

  await Promise.all(
    Array.from({ length: Math.max(1, concurrency) }, () => worker()),
  );
  return results;
}

// ---------------------------------------------------------------------------
// Cache helpers
// ---------------------------------------------------------------------------

/** Unix segundos quando a entrada foi guardada na Cache API (removido antes de servir ao cliente). */
const EDGE_CACHE_TIMESTAMP_HEADER = "x-edge-cached-at";

function stampEdgeCachedResponse(response: Response): Response {
  const headers = new Headers(response.headers);
  headers.set(
    EDGE_CACHE_TIMESTAMP_HEADER,
    String(Math.floor(Date.now() / 1000)),
  );
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });
}

function cacheAgeSeconds(cached: Response): number {
  const raw = cached.headers.get(EDGE_CACHE_TIMESTAMP_HEADER);
  if (!raw) return 0;
  const stored = Number.parseInt(raw, 10);
  if (!Number.isFinite(stored)) return 0;
  return Math.max(0, Math.floor(Date.now() / 1000) - stored);
}

/**
 * Hit da Cache API: expor `Age` (segundos no edge) e manter o `Cache-Control` original.
 * Clientes e caches HTTP calculam a frescura com `max-age`/`stale-while-revalidate` *e* `Age`;
 * não reduzir `max-age` aqui — seria dupla contagem com `Age`.
 */
function responseFromCachedHit(
  cached: Response,
  options: { setXCacheHit?: boolean } = {},
): Response {
  const { setXCacheHit = true } = options;
  const ageSec = cacheAgeSeconds(cached);
  const headers = new Headers(cached.headers);
  headers.delete(EDGE_CACHE_TIMESTAMP_HEADER);
  if (ageSec > 0) {
    headers.set("Age", String(ageSec));
  }
  if (setXCacheHit) {
    headers.set("x-cache", "HIT");
  }
  return new Response(cached.body, {
    status: cached.status,
    statusText: cached.statusText,
    headers,
  });
}

function jsonResponse(
  data: unknown,
  ttl: number,
  cacheHit: boolean,
): Response {
  /**
   * SWR após max-age: para TTL curto (ex. jogos live, ≤180s), usar no máximo ttl/2 para não
   * prolongar dados sensíveis 2× mais que a janela “fresca”; TTL longos mantêm SWR maior.
   */
  const staleWhileRevalidate =
    ttl <= 180
      ? Math.max(0, Math.floor(ttl / 2))
      : Math.min(900, Math.max(60, ttl * 2));
  return new Response(JSON.stringify(data), {
    status: 200,
    headers: {
      "content-type": "application/json; charset=utf-8",
      "cache-control": `public, max-age=${ttl}, stale-while-revalidate=${staleWhileRevalidate}`,
      "x-cache": cacheHit ? "HIT" : "MISS",
    },
  });
}

async function cachedJsonHandler(
  request: Request,
  env: Env,
  cacheKeyUrl: string,
  ttl: number,
  fetcher: () => Promise<unknown>,
  workerOrigin: string,
): Promise<Response> {
  if (request.method === "OPTIONS") return optionsResponse(request, env);
  if (request.method !== "GET") {
    return withCors(
      request,
      env,
      new Response("Method Not Allowed", { status: 405 }),
    );
  }

  const cacheKey = new Request(cacheKeyUrl, { method: "GET" });
  const cached = await caches.default.match(cacheKey);
  if (cached) {
    return withCors(request, env, responseFromCachedHit(cached));
  }

  try {
    const data = await fetcher();
    const response = jsonResponse(rewriteUrls(data, workerOrigin, env), ttl, false);
    const corsResponse = withCors(request, env, response);
    await caches.default.put(
      cacheKey,
      stampEdgeCachedResponse(corsResponse.clone()),
    );
    return corsResponse;
  } catch {
    return withCors(
      request,
      env,
      new Response(JSON.stringify({ error: "Upstream error" }), {
        status: 502,
        headers: { "content-type": "application/json" },
      }),
    );
  }
}

// ---------------------------------------------------------------------------
// Route handlers
// ---------------------------------------------------------------------------

async function handleMatches(request: Request, env: Env, workerOrigin: string) {
  if (request.method === "OPTIONS") return optionsResponse(request, env);
  if (request.method !== "GET") {
    return withCors(
      request,
      env,
      new Response("Method Not Allowed", { status: 405 }),
    );
  }

  const cacheKey = new Request("https://cache.local/zerozero-matches", {
    method: "GET",
  });
  const cached = await caches.default.match(cacheKey);
  if (cached) {
    return withCors(request, env, responseFromCachedHit(cached));
  }

  try {
    const html = await fetchZerozeroHtml(env, TEAM_GAMES_PATH);
    const matches = parseMatchesHtml(html);
    const today = todayInLisbon();
    const hasLiveOrToday = matches.some(
      (m) => m && (m.status === "live" || m.date === today),
    );
    const ttl = hasLiveOrToday ? CACHE_TTL.matchesLive : CACHE_TTL.matches;
    const response = jsonResponse(rewriteUrls({ matches }, workerOrigin, env), ttl, false);
    const corsResponse = withCors(request, env, response);
    await caches.default.put(
      cacheKey,
      stampEdgeCachedResponse(corsResponse.clone()),
    );
    return corsResponse;
  } catch {
    return withCors(
      request,
      env,
      new Response(JSON.stringify({ error: "Upstream error" }), {
        status: 502,
        headers: { "content-type": "application/json" },
      }),
    );
  }
}

async function handleNews(request: Request, env: Env, workerOrigin: string) {
  const url = new URL(request.url);
  const targetSlug = url.searchParams.get("slug");
  const cacheKeySuffix = targetSlug ? `-slug-${targetSlug}` : "";

  return cachedJsonHandler(
    request,
    env,
    `https://cache.local/zerozero-news${cacheKeySuffix}`,
    targetSlug ? CACHE_TTL.newsDetail : CACHE_TTL.news,
    async () => {
      const html = await fetchZerozeroHtml(env, TEAM_NEWS_PATH);
      const baseNews = parseNewsListHtml(html);

      const detailedNews = await mapWithConcurrency(
        baseNews,
        NEWS_DETAILS_CONCURRENCY,
        async (item, _index) => {
          const isTarget = targetSlug && item.slug === targetSlug;
          const isTopNews = !targetSlug;

          if (!item.externalUrl || (!isTarget && !isTopNews)) return item;

          try {
            const articleUrl = String(item.externalUrl);
            const articlePath = articleUrl.replace(ZEROZERO_ORIGIN, "");
            const articleHtml = await fetchZerozeroHtml(
              env,
              articlePath,
              `${ZEROZERO_ORIGIN}${TEAM_NEWS_PATH}`,
            );
            const detailed = parseNewsArticleHtml(articleHtml, item);
            if (detailed === null) return null;
            return detailed;
          } catch {
            return item;
          }
        },
      );

      const news = detailedNews
        .filter((item): item is Record<string, unknown> => item != null)
        .filter((item) => !isOpinionContent(item))
        .sort((a, b) =>
          String(b.publishedAt || "").localeCompare(
            String(a.publishedAt || ""),
          ),
        );

      return { news };
    },
    workerOrigin,
  );
}

async function handleStandingsLiga(request: Request, env: Env, workerOrigin: string) {
  return cachedJsonHandler(
    request,
    env,
    "https://cache.local/zerozero-standings-liga",
    CACHE_TTL.standings,
    async () => {
      const html = await fetchZerozeroHtml(env, LIGA_PRO_PATH);
      return { standings: parseStandingsHtml(html) };
    },
    workerOrigin,
  );
}

async function handleStandingsTaca(request: Request, env: Env, workerOrigin: string) {
  return cachedJsonHandler(
    request,
    env,
    "https://cache.local/zerozero-standings-taca",
    CACHE_TTL.standings,
    async () => {
      const html = await fetchZerozeroHtml(env, TACA_PATH);
      return { standings: parseStandingsHtml(html) };
    },
    workerOrigin,
  );
}

async function handleCupProgress(request: Request, env: Env, workerOrigin: string) {
  return cachedJsonHandler(
    request,
    env,
    "https://cache.local/zerozero-cup-progress",
    CACHE_TTL.standings,
    async () => {
      const html = await fetchZerozeroHtml(env, TEAM_GAMES_PATH);
      const allMatches = parseMatchesHtml(html);
      const cupMatches = allMatches.filter((m) => {
        const comp = String(m!.competition)
          .toLowerCase()
          .normalize("NFC");
        return comp.includes("ta\u00E7a") || comp.includes("taca") || /\bta.a\b/.test(comp);
      });
      return { matches: cupMatches };
    },
    workerOrigin,
  );
}

async function handleProxy(
  request: Request,
  env: Env,
  targetUrl: string,
  ttlSeconds: number,
) {
  if (request.method === "OPTIONS") return optionsResponse(request, env);
  if (request.method !== "GET") {
    return withCors(
      request,
      env,
      new Response("Method Not Allowed", { status: 405 }),
    );
  }

  const cacheKey = new Request(request.url, { method: "GET" });
  const cached = await caches.default.match(cacheKey);
  if (cached) {
    return withCors(
      request,
      env,
      responseFromCachedHit(cached, { setXCacheHit: false }),
    );
  }

  let upstream: Response;
  if (env.RENDER_PROXY_URL && targetUrl.startsWith(ZEROZERO_ORIGIN)) {
    const proxyBase = env.RENDER_PROXY_URL.replace(/\/+$/, "");
    const proxyUrl = `${proxyBase}/fetch?url=${encodeURIComponent(targetUrl)}`;
    const proxyHeaders: Record<string, string> = {};
    if (env.RENDER_PROXY_SECRET) {
      proxyHeaders["x-proxy-secret"] = env.RENDER_PROXY_SECRET;
    }
    upstream = await fetch(proxyUrl, { method: "GET", headers: proxyHeaders });
  } else {
    upstream = await fetch(targetUrl, {
      method: "GET",
      headers: browserLikeHeaders(),
      cf: { cacheEverything: true, cacheTtl: ttlSeconds },
    });
  }

  const headers = new Headers(upstream.headers);
  headers.set("cache-control", `public, max-age=${ttlSeconds}`);

  const response = new Response(upstream.body, {
    status: upstream.status,
    headers,
  });
  if (upstream.ok) {
    await caches.default.put(
      cacheKey,
      stampEdgeCachedResponse(response.clone()),
    );
  }

  return withCors(request, env, response);
}

async function handleCachePurge(request: Request, env: Env): Promise<Response> {
  if (request.method === "OPTIONS") return optionsResponse(request, env);

  const headerSecret = request.headers.get("x-purge-secret") || "";
  if (!env.PURGE_SECRET || headerSecret !== env.PURGE_SECRET) {
    return withCors(
      request,
      env,
      new Response(JSON.stringify({ error: "Unauthorized" }), {
        status: 401,
        headers: { "content-type": "application/json; charset=utf-8" },
      }),
    );
  }

  const cacheKeys = [
    "https://cache.local/zerozero-matches",
    "https://cache.local/zerozero-news",
    "https://cache.local/zerozero-standings-liga",
    "https://cache.local/zerozero-standings-taca",
    "https://cache.local/zerozero-cup-progress",
  ];

  for (const keyUrl of cacheKeys) {
    await caches.default.delete(new Request(keyUrl, { method: "GET" }));
  }

  return withCors(
    request,
    env,
    new Response(JSON.stringify({ purged: true }), {
      status: 200,
      headers: { "content-type": "application/json; charset=utf-8" },
    }),
  );
}

// ---------------------------------------------------------------------------
// Main router
// ---------------------------------------------------------------------------

export default {
  async fetch(request: Request, env: Env) {
    const url = new URL(request.url);
    const workerOrigin = url.origin;

    if (url.pathname === "/api/cache-purge")
      return handleCachePurge(request, env);

    if (url.pathname === "/api/matches-feed")
      return handleMatches(request, env, workerOrigin);

    if (url.pathname === "/api/news-feed")
      return handleNews(request, env, workerOrigin);

    if (url.pathname === "/api/standings-liga")
      return handleStandingsLiga(request, env, workerOrigin);

    if (url.pathname === "/api/standings-taca")
      return handleStandingsTaca(request, env, workerOrigin);

    if (url.pathname === "/api/cup-progress")
      return handleCupProgress(request, env, workerOrigin);

    if (url.pathname.startsWith("/api/media/r/")) {
      const encoded = url.pathname.slice("/api/media/r/".length);
      let targetUrl: string;
      try {
        targetUrl = atob(encoded);
        const parsed = new URL(targetUrl);
        if (!parsed.hostname.endsWith(".zerozero.pt") && parsed.hostname !== "zerozero.pt") {
          return withCors(request, env, new Response("Forbidden", { status: 403 }));
        }
      } catch {
        return withCors(request, env, new Response("Invalid", { status: 400 }));
      }
      return handleProxy(request, env, targetUrl, CACHE_TTL.proxy);
    }

    if (url.pathname.startsWith("/api/media/")) {
      const targetPath = url.pathname.replace("/api/media", "");
      return handleProxy(
        request,
        env,
        `${ZEROZERO_ORIGIN}${targetPath}${url.search}`,
        CACHE_TTL.proxy,
      );
    }

    if (url.pathname === "/api/health") {
      try {
        const start = Date.now();
        const testUrl = `${ZEROZERO_ORIGIN}${TEAM_GAMES_PATH}`;
        const res = await fetch(testUrl, {
          method: "GET",
          headers: browserLikeHeaders(),
          redirect: "follow",
        });
        const elapsed = Date.now() - start;
        const body = await res.text();
        return withCors(
          request,
          env,
          new Response(
            JSON.stringify({
              ok: res.ok,
              status: res.status,
              statusText: res.statusText,
              elapsed_ms: elapsed,
              response_length: body.length,
              has_html: body.includes("<html"),
              has_challenge: body.includes("challenge") || body.includes("cf-browser-verification"),
              snippet: body.slice(0, 500),
            }),
            { status: 200, headers: { "content-type": "application/json" } },
          ),
        );
      } catch (err) {
        return withCors(
          request,
          env,
          new Response(
            JSON.stringify({ error: String(err) }),
            { status: 200, headers: { "content-type": "application/json" } },
          ),
        );
      }
    }

    if (url.pathname.startsWith("/api/live/")) {
      const targetPath = url.pathname.replace(
        "/api/live/",
        "/api/v1/getZapping/",
      );
      return handleProxy(
        request,
        env,
        `${ZEROZERO_ORIGIN}${targetPath}${url.search}`,
        CACHE_TTL.zapping,
      );
    }

    return withCors(
      request,
      env,
      new Response("Not Found", { status: 404 }),
    );
  },
};
