/**
 * POST /api/site-tag/page-links
 *
 * Site Tag reports every internal link it found on the current page.
 * We store the full list on the PageRecord (as JSON) so the Launch
 * Tracker can compute orphan pages, inbound link counts, and related
 * pages. On top of that, we cross-check each destination against the
 * DeadPage table — any link pointing at a known-dead URL is recorded
 * as a `broken_internal_link` SiteEvent so the Broken Links page can
 * display it under the "Broken Internal Links" tab + the /preview
 * inspector can highlight it on the live page.
 *
 * Payload (back-compat with the pre-existing shape):
 *   { siteId, sourceUrl, links: [{ href, anchorText, tag?, selector?, section? }] }
 *
 * GET /api/site-tag/page-links?brandId=X&pageUrl=/path
 *
 * Returns the known broken-link reports for a single page. Called by
 * the /preview page (server-to-server, auth'd user) and by the Site
 * Tag's inspect overlay (cross-origin from the customer's site).
 */

import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import type { Prisma } from "@prisma/client";
import {
  getCorsHeaders,
  resolveSiteFromTag,
  toPathKey,
} from "@/lib/services/page-lifecycle";

export const dynamic = "force-dynamic";

export async function OPTIONS(req: Request) {
  return new NextResponse(null, { status: 204, headers: getCorsHeaders(req) });
}

interface LinkItem {
  href?: string;
  anchorText?: string;
  /** DOM tag name (a, img). Lets the UI distinguish broken images. */
  tag?: string;
  /** CSS selector the inspect overlay uses to locate the element. */
  selector?: string;
  /** "header" | "footer" | "nav" | "main" | etc. — for grouping in the UI. */
  section?: string;
}
interface PageLinksBody {
  siteId?: string;
  sourceUrl?: string;
  links?: LinkItem[];
}

const MAX_LINKS_PER_PAGE = 500;
/** How many unknown (not-in-DeadPage) links we HEAD-check per request.
 *  Keep this small — a chatty Site Tag could otherwise fan-out to
 *  dozens of external fetches per pageview. */
const MAX_HEAD_CHECKS_PER_REQUEST = 5;
/** Dedupe window for SiteEvent writes: if the same source+destination
 *  broken link was logged within the last day, skip re-inserting. */
const BROKEN_LINK_DEDUPE_MS = 24 * 60 * 60 * 1000;

export async function POST(req: Request) {
  const cors = getCorsHeaders(req);
  let body: PageLinksBody;
  try {
    body = (await req.json()) as PageLinksBody;
  } catch {
    return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400, headers: cors });
  }

  const resolved = await resolveSiteFromTag(body.siteId, body.sourceUrl || "");
  if (!resolved.ok) {
    return NextResponse.json(
      { ok: false, error: resolved.error },
      { status: resolved.status, headers: cors },
    );
  }

  const sourcePath = toPathKey(body.sourceUrl || "/");
  const rawLinks = Array.isArray(body.links) ? body.links.slice(0, MAX_LINKS_PER_PAGE) : [];

  // Keep only internal links (host is one of the allowed domains, or relative).
  // Normalise each href to a path and dedupe. We keep the enriched metadata
  // (tag/selector/section) around — the PageRecord storage path drops it,
  // but broken-link validation further down uses it.
  const allowed = resolved.site.allowedDomains.map((d) => d.toLowerCase());
  const seen = new Set<string>();
  const internal: { href: string; anchorText: string }[] = [];
  const enrichedInternal: Array<{
    href: string;
    anchorText: string;
    tag: string;
    selector: string;
    section: string;
  }> = [];

  for (const link of rawLinks) {
    if (!link || typeof link.href !== "string") continue;
    const href = link.href.trim();
    if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) continue;

    let pathOnly: string | null = null;
    if (/^https?:\/\//i.test(href)) {
      try {
        const u = new URL(href);
        const host = u.hostname.toLowerCase();
        if (!allowed.some((d) => host === d || host.endsWith(`.${d}`))) continue;
        pathOnly = toPathKey(u.pathname);
      } catch { continue; }
    } else {
      pathOnly = toPathKey(href);
    }

    if (!pathOnly || seen.has(pathOnly)) continue;
    seen.add(pathOnly);

    const anchorText = typeof link.anchorText === "string"
      ? link.anchorText.trim().slice(0, 200)
      : "";
    internal.push({ href: pathOnly, anchorText });
    enrichedInternal.push({
      href: pathOnly,
      anchorText,
      tag: (link.tag || "a").toString().toLowerCase().slice(0, 16),
      selector: (link.selector || "").toString().slice(0, 512),
      section: (link.section || "body").toString().toLowerCase().slice(0, 32),
    });
  }

  // Update the source page's internalLinks. If the PageRecord doesn't exist
  // yet (rare race with /page-seen), create a minimal row so the link data
  // isn't lost.
  const fullUrl = body.sourceUrl && /^https?:\/\//i.test(body.sourceUrl)
    ? body.sourceUrl
    : sourcePath;
  await prisma.pageRecord.upsert({
    where: { brandId_url: { brandId: resolved.site.brandId, url: sourcePath } },
    create: {
      brandId: resolved.site.brandId,
      url: sourcePath,
      fullUrl,
      internalLinks: internal as unknown as object,
    },
    update: { internalLinks: internal as unknown as object },
  });

  // ── Broken link detection ──────────────────────────────────────────────
  // Compare this page's outbound links against the DeadPage table, then
  // (optionally) HEAD-check a small sample of unknowns in case they became
  // dead since the last site-wide scan. Everything below is best-effort —
  // we never fail the request if the validation path errors out, because
  // the Site Tag would just retry and spam events.
  const brokenCount = await detectAndRecordBrokenLinks({
    brandId: resolved.site.brandId,
    sourcePath,
    primaryDomain: allowed[0] || null,
    enrichedLinks: enrichedInternal,
  });

  return NextResponse.json(
    { ok: true, count: internal.length, broken: brokenCount },
    { headers: cors },
  );
}

// ─── Broken-link detection helper ───────────────────────────────────────────

/**
 * Validate the supplied internal links against known dead pages, then
 * record any matches as `broken_internal_link` SiteEvents. Returns the
 * number of broken links recorded on this call (already-dedupe'd hits
 * don't count). Safe to fire-and-forget: all DB work is awaited so the
 * response reports accurate numbers, but any failure is swallowed.
 */
async function detectAndRecordBrokenLinks(args: {
  brandId: string;
  sourcePath: string;
  primaryDomain: string | null;
  enrichedLinks: Array<{
    href: string;
    anchorText: string;
    tag: string;
    selector: string;
    section: string;
  }>;
}): Promise<number> {
  const { brandId, sourcePath, primaryDomain, enrichedLinks } = args;
  if (enrichedLinks.length === 0) return 0;

  // Load every known dead URL for this brand into a Set for O(1) lookup.
  let deadSet: Set<string>;
  try {
    const deadPages = await prisma.deadPage.findMany({
      where: { brandId, status: { in: ["open", "confirmed", "in_progress"] } },
      select: { deadUrl: true },
    });
    deadSet = new Set(
      deadPages.map((d) => {
        try { return toPathKey(new URL(d.deadUrl).pathname); }
        catch { return toPathKey(d.deadUrl); }
      }),
    );
  } catch { return 0; }

  const matches: Array<{
    link: typeof enrichedLinks[number];
    statusCode: number;
  }> = [];

  // Pass 1 — anything that matches a known dead URL is a definite hit.
  const unknown: typeof enrichedLinks = [];
  for (const link of enrichedLinks) {
    // Broken <img> detection doesn't depend on DeadPage membership — the
    // Site Tag only reports an image if `naturalWidth === 0 && complete`.
    if (link.tag === "img") {
      matches.push({ link, statusCode: 404 });
      continue;
    }
    if (deadSet.has(link.href)) {
      matches.push({ link, statusCode: 404 });
    } else {
      unknown.push(link);
    }
  }

  // Pass 2 — HEAD-check a small sample of unknowns so recently-broken
  // links surface before the nightly site crawl runs. Bounded fetches
  // with a hard timeout to avoid tying up the request.
  if (primaryDomain && unknown.length > 0) {
    const sample = unknown.slice(0, MAX_HEAD_CHECKS_PER_REQUEST);
    for (const link of sample) {
      try {
        const controller = new AbortController();
        const timer = setTimeout(() => controller.abort(), 5000);
        const url = `https://${primaryDomain}${link.href}`;
        const res = await fetch(url, {
          method: "HEAD",
          redirect: "manual",
          signal: controller.signal,
        });
        clearTimeout(timer);
        if (res.status === 404 || res.status === 410) {
          matches.push({ link, statusCode: res.status });
        }
      } catch { /* network error / timeout — treat as unknown */ }
    }
  }

  if (matches.length === 0) return 0;

  // Dedupe: skip matches that already got an event in the last 24h.
  // Doing this per-match keeps the query count bounded by `matches.length`
  // (usually tiny — a page has < 10 broken links worth reporting).
  const cutoff = new Date(Date.now() - BROKEN_LINK_DEDUPE_MS);
  let recorded = 0;

  for (const { link, statusCode } of matches) {
    try {
      const already = await prisma.siteEvent.findFirst({
        where: {
          brandId,
          eventType: "broken_internal_link",
          pageUrl: sourcePath,
          timestamp: { gte: cutoff },
          eventData: { path: ["destinationUrl"], equals: link.href } as unknown as Prisma.JsonFilter,
        },
        select: { id: true },
      });
      if (already) continue;

      await prisma.siteEvent.create({
        data: {
          brandId,
          pageUrl: sourcePath,
          eventType: "broken_internal_link",
          eventData: {
            destinationUrl: link.href,
            linkText: link.anchorText,
            elementType: link.tag,
            selector: link.selector,
            parentSection: link.section,
            statusCode,
            source: "site_tag",
          } as Prisma.InputJsonValue,
        },
      });
      recorded++;
    } catch { /* dedup race / JSON filter edge case — skip */ }
  }

  return recorded;
}

// ─── GET: fetch broken links for a given page ───────────────────────────────

interface BrokenLinkResponse {
  destinationUrl: string;
  linkText: string;
  elementType: string;
  selector: string;
  parentSection: string;
  statusCode: number;
  detectedAt: string;
}

interface BrokenEventData {
  destinationUrl?: string;
  linkText?: string;
  elementType?: string;
  selector?: string;
  parentSection?: string;
  statusCode?: number;
}

const BROKEN_LINK_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000;

/**
 * Returns known broken links for a page. Two callers:
 *   • /preview (authed UI) passes `brandId` + `pageUrl`
 *   • Site Tag inspect overlay passes `siteId` + `pageUrl` (no user auth —
 *     the siteId gate is the same pattern the POST handler uses, and the
 *     response is never privileged data)
 */
export async function GET(req: Request) {
  const cors = getCorsHeaders(req);
  const { searchParams } = new URL(req.url);
  const brandIdParam = searchParams.get("brandId");
  const siteIdParam = searchParams.get("siteId");
  const pageUrl = searchParams.get("pageUrl");

  if (!pageUrl) {
    return NextResponse.json(
      { ok: false, error: "pageUrl required" },
      { status: 400, headers: cors },
    );
  }

  // Resolve brandId — either directly (trusted UI caller) or via siteId
  // (cross-origin Site Tag caller).
  let brandId: string | null = null;
  if (brandIdParam) {
    brandId = brandIdParam;
  } else if (siteIdParam) {
    const site = await prisma.trackedSite.findUnique({
      where: { siteId: siteIdParam },
      select: { brandId: true },
    });
    brandId = site?.brandId ?? null;
  }

  if (!brandId) {
    return NextResponse.json(
      { ok: false, error: "brandId or siteId required" },
      { status: 400, headers: cors },
    );
  }

  const normalisedPath = toPathKey(pageUrl);
  const since = new Date(Date.now() - BROKEN_LINK_LOOKBACK_MS);

  const events = await prisma.siteEvent.findMany({
    where: {
      brandId,
      eventType: "broken_internal_link",
      pageUrl: normalisedPath,
      timestamp: { gte: since },
    },
    orderBy: { timestamp: "desc" },
    select: { eventData: true, timestamp: true },
    take: 200,
  });

  // Collapse duplicate destinations — if the same link was logged twice
  // (e.g. dedupe race across concurrent pageviews) we only want the
  // most recent entry in the response.
  const seen = new Set<string>();
  const brokenLinks: BrokenLinkResponse[] = [];
  for (const ev of events) {
    const data = (ev.eventData ?? {}) as BrokenEventData;
    const dest = data.destinationUrl || "";
    if (!dest || seen.has(dest)) continue;
    seen.add(dest);
    brokenLinks.push({
      destinationUrl: dest,
      linkText: data.linkText || "",
      elementType: data.elementType || "a",
      selector: data.selector || "",
      parentSection: data.parentSection || "body",
      statusCode: typeof data.statusCode === "number" ? data.statusCode : 404,
      detectedAt: ev.timestamp.toISOString(),
    });
  }

  return NextResponse.json(
    { ok: true, brokenLinks, count: brokenLinks.length },
    { headers: cors },
  );
}
