/**
 * GET /api/content-audit/page-detail?brandId=X&pageUrl=/some/path
 * ──────────────────────────────────────────────────────────────
 * Aggregates everything the Content Intel expandable panel needs for a
 * single page, in one round-trip:
 *
 *   - performance : 28-day GSC clicks / impressions / position / CTR
 *                   plus a 14d-vs-prior-14d trend bucket.
 *   - behavior    : Site Tag pageviews / landing sessions / bounce rate /
 *                   outbound clicks / form interactions / conversions.
 *   - technical   : open TechnicalIssue rows matching this URL, latest
 *                   PerformancePageScore (LCP/CLS/INP), open DeadPage hit.
 *
 * The AI recommendations live in a separate endpoint
 * (/api/content-audit/page-recommendations) so the cheap DB path can
 * render immediately and the slower LLM call can stream in behind it.
 *
 * All DB queries fan out via Promise.all. Everything uses
 * normalizePath() from src/lib/services/url-normalize.ts so the
 * Site Tag (pathname-only) and GSC (absolute-URL) sources line up.
 */

import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { normalizePath } from "@/lib/services/url-normalize";
import { countedConversionWhere } from "@/lib/conversions/counted-where";

export const dynamic = "force-dynamic";
export const maxDuration = 30;

interface PerformanceBlock {
  clicks28d: number;
  impressions28d: number;
  avgPosition: number | null;
  ctr: number | null;
  trend: "improving" | "declining" | "stable";
  trendPct: number;
  clicksPrev14d: number;
  clicksLast14d: number;
}

interface BehaviorBlock {
  pageViews: number;
  landingSessions: number;
  bouncedSessions: number;
  bounceRate: number;
  outboundClicks: number;
  formStarts: number;
  conversions: number;
  /** Present = Site Tag is installed for this brand. Absent = skip the
   *  behavior section in the UI entirely. */
  siteTagInstalled: boolean;
}

interface TechnicalBlock {
  openIssues: Array<{ title: string; severity: string; category: string }>;
  performance: { lcpMs: number; inpMs: number; clsVal: number; performance: number } | null;
  deadPage: { priority: string; status: string } | null;
  internalLinkCount: number | null;
}

interface MetaBlock {
  /** Page title text or null when missing. */
  title: string | null;
  /** Meta description text or null when missing. */
  description: string | null;
  /** Where the data came from — lets the UI show a freshness hint. */
  source: "site_tag_snapshot" | "page_record" | "live_fetch" | "none";
}

export async function GET(req: Request) {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const { searchParams } = new URL(req.url);
  const brandId = searchParams.get("brandId");
  const pageUrl = searchParams.get("pageUrl");
  if (!brandId || !pageUrl) return NextResponse.json({ error: "brandId and pageUrl required" }, { status: 400 });

  const brand = await prisma.brand.findUnique({
    where: { id: brandId },
    include: { organization: { include: { memberships: true } } },
  });
  if (!brand) return NextResponse.json({ error: "Brand not found" }, { status: 404 });
  if (!brand.organization.memberships.some((m) => m.userId === user.id)) {
    return NextResponse.json({ error: "Access denied" }, { status: 403 });
  }

  const normalized = normalizePath(pageUrl);
  const now = new Date();
  const d28 = new Date(now.getTime() - 28 * 86_400_000);
  const d14 = new Date(now.getTime() - 14 * 86_400_000);
  const d30 = new Date(now.getTime() - 30 * 86_400_000);

  // Path matching — `contains: "/"` matches every row, so the homepage
  // uses exact-match instead. Defined early so all parallel queries share it.
  const isRoot = normalized === "/";
  const pageUrlMatch = isRoot
    ? { pageUrl: { in: ["/", ""] } }
    : { pageUrl: { contains: normalized, mode: "insensitive" as const } };
  const entryPageMatch = isRoot
    ? { entryPage: { in: ["/", ""] } }
    : { entryPage: { contains: normalized, mode: "insensitive" as const } };

  // Resolve the brand's TrackedSite once so every Site Tag query shares
  // the same scope without a second round trip each.
  const [site, gscRows, latestTechAudit, latestPerfAudit, deadPage, seoSnapshot, pageRecord] = await Promise.all([
    prisma.trackedSite.findUnique({ where: { brandId }, select: { id: true } }),
    prisma.gscPage.findMany({
      where: { brandId, ...pageUrlMatch, date: { gte: d28 } },
      orderBy: { date: "asc" },
      select: { date: true, clicks: true, impressions: true, position: true },
    }),
    // Most recent completed technical audit — used to scope
    // TechnicalIssue queries below.
    prisma.technicalAudit.findFirst({
      where: { brandId, status: "completed" },
      orderBy: { createdAt: "desc" },
      select: { id: true },
    }),
    // Most recent performance audit — used for PerformancePageScore.
    prisma.performanceAudit.findFirst({
      where: { brandId },
      orderBy: { createdAt: "desc" },
      select: { id: true },
    }),
    prisma.deadPage.findFirst({
      where: { brandId, ...(isRoot ? { deadUrl: { in: ["/", ""] } } : { deadUrl: { contains: normalized, mode: "insensitive" } }), status: "open" },
      select: { priority: true, status: true },
    }),
    prisma.pageSEOSnapshot.findFirst({
      where: { brandId, ...pageUrlMatch },
      orderBy: { detectedAt: "desc" },
      select: { title: true, metaDescription: true },
    }),
    prisma.pageRecord.findFirst({
      where: { brandId, ...(isRoot ? { url: { in: ["/", ""] } } : { url: { contains: normalized, mode: "insensitive" } }) },
      select: { metaDescription: true },
    }),
  ]);

  // ── Performance aggregation ───────────────────────────────────────
  const performance: PerformanceBlock = aggregatePerformance(gscRows, d14);

  // ── Behavior (parallel) ───────────────────────────────────────────
  let behavior: BehaviorBlock;
  if (!site) {
    behavior = {
      pageViews: 0, landingSessions: 0, bouncedSessions: 0, bounceRate: 0,
      outboundClicks: 0, formStarts: 0, conversions: 0, siteTagInstalled: false,
    };
  } else {
    const [pageViews, landingSessions, bouncedSessions, outboundClicks, formStarts, conversions] = await Promise.all([
      prisma.trackedEvent.count({
        where: { trackedSiteId: site.id, eventType: "page_view", timestamp: { gte: d30 }, ...pageUrlMatch },
      }),
      prisma.trackedSession.count({
        where: { trackedSiteId: site.id, ...entryPageMatch, startedAt: { gte: d30 } },
      }),
      prisma.trackedSession.count({
        where: { trackedSiteId: site.id, ...entryPageMatch, pageCount: 1, startedAt: { gte: d30 } },
      }),
      prisma.trackedEvent.count({
        where: { trackedSiteId: site.id, eventType: "outbound_click", timestamp: { gte: d30 }, ...pageUrlMatch },
      }),
      prisma.trackedEvent.count({
        where: { trackedSiteId: site.id, eventType: { in: ["form_start", "form_focus", "form_submit"] }, timestamp: { gte: d30 }, ...pageUrlMatch },
      }),
      prisma.siteConversion.count({
        where: { ...countedConversionWhere(brandId, { since: d30 }), ...pageUrlMatch },
      }),
    ]);
    const bounceRate = landingSessions > 0 ? Math.round((bouncedSessions / landingSessions) * 1000) / 10 : 0;
    behavior = {
      pageViews, landingSessions, bouncedSessions, bounceRate,
      outboundClicks, formStarts, conversions, siteTagInstalled: true,
    };
  }

  // ── Technical ────────────────────────────────────────────────────
  const [techIssues, perfPage] = await Promise.all([
    latestTechAudit
      ? prisma.technicalIssue.findMany({
          where: { auditId: latestTechAudit.id, status: "open", ...pageUrlMatch },
          orderBy: { severity: "desc" },
          take: 10,
          select: { title: true, severity: true, category: true },
        })
      : Promise.resolve([]),
    latestPerfAudit
      ? prisma.performancePageScore.findFirst({
          where: { auditId: latestPerfAudit.id, ...pageUrlMatch },
          orderBy: { performance: "asc" }, // worst device view first (mobile typically)
          select: { lcpMs: true, inpMs: true, clsVal: true, performance: true },
        })
      : Promise.resolve(null),
  ]);

  const technical: TechnicalBlock = {
    openIssues: techIssues,
    performance: perfPage,
    deadPage,
    // Internal link counting would require a crawled-link table we
    // don't have for all brands. Leave as null and render "—" in UI.
    internalLinkCount: null,
  };

  // ── Meta (title + description) ───────────────────────────────────
  // Priority: Site Tag snapshot (has both) → PageRecord (description
  // only) → live fetch as a last resort. The live fetch is gated on
  // BOTH sources being empty so the common path stays DB-only.
  const meta = await resolveMeta(seoSnapshot, pageRecord, pageUrl);

  return NextResponse.json({ pageUrl: normalized, performance, behavior, technical, meta });
}

/** Resolve title + meta description from the cheapest source available.
 *  Live fetch is bounded to a 4s timeout + 128KB of body so a slow or
 *  oversize origin can't stall the panel load. */
async function resolveMeta(
  snapshot: { title: string | null; metaDescription: string | null } | null,
  pageRecord: { metaDescription: string | null } | null,
  pageUrl: string,
): Promise<MetaBlock> {
  if (snapshot && (snapshot.title || snapshot.metaDescription)) {
    return {
      title: snapshot.title ?? null,
      description: snapshot.metaDescription ?? null,
      source: "site_tag_snapshot",
    };
  }
  if (pageRecord?.metaDescription) {
    return { title: null, description: pageRecord.metaDescription, source: "page_record" };
  }
  // Live fetch fallback — only attempted when pageUrl is absolute so
  // we can actually reach the origin. normalizePath strips hosts; the
  // caller's original pageUrl is what we need here.
  if (!/^https?:\/\//i.test(pageUrl)) return { title: null, description: null, source: "none" };
  try {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), 4000);
    const res = await fetch(pageUrl, {
      signal: controller.signal,
      redirect: "follow",
      headers: { "User-Agent": "meSEO/1.0 ContentAuditBot (+https://app.meseo.io)" },
    }).catch(() => null);
    clearTimeout(timer);
    if (!res || !res.ok) return { title: null, description: null, source: "none" };
    // Read a bounded chunk; the <head> is virtually always in the
    // first 128KB. Avoids pulling huge pages into memory.
    const reader = res.body?.getReader();
    if (!reader) return { title: null, description: null, source: "none" };
    const decoder = new TextDecoder("utf-8");
    let html = "";
    const MAX_BYTES = 128 * 1024;
    let bytes = 0;
    while (bytes < MAX_BYTES) {
      const { value, done } = await reader.read();
      if (done) break;
      bytes += value.byteLength;
      html += decoder.decode(value, { stream: true });
      if (/<\/head>/i.test(html)) break;
    }
    reader.cancel().catch(() => {});
    const title = extractTitle(html);
    const description = extractMetaDescription(html);
    if (title || description) return { title, description, source: "live_fetch" };
    return { title: null, description: null, source: "none" };
  } catch {
    return { title: null, description: null, source: "none" };
  }
}

function extractTitle(html: string): string | null {
  const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
  if (!m) return null;
  const t = m[1].replace(/\s+/g, " ").trim();
  return t.length > 0 ? decodeEntities(t) : null;
}

function extractMetaDescription(html: string): string | null {
  // Handle both attribute orderings — `<meta name="description" content="…">`
  // and `<meta content="…" name="description">`.
  const m1 = html.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i);
  if (m1) return decodeEntities(m1[1].trim()) || null;
  const m2 = html.match(/<meta[^>]+content=["']([^"']*)["'][^>]*name=["']description["']/i);
  if (m2) return decodeEntities(m2[1].trim()) || null;
  return null;
}

function decodeEntities(s: string): string {
  return s
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&apos;/g, "'")
    .replace(/&nbsp;/g, " ");
}

/** Aggregate daily GSC rows into the 28-day totals + a 14-vs-prior-14
 *  trend bucket. Trend ignored when either half is empty. */
function aggregatePerformance(
  rows: Array<{ date: Date; clicks: number; impressions: number; position: number }>,
  midpoint: Date,
): PerformanceBlock {
  let clicks = 0, imps = 0, posSum = 0;
  let clicksLast = 0, clicksPrev = 0;
  for (const r of rows) {
    clicks += r.clicks;
    imps += r.impressions;
    posSum += r.position;
    if (r.date >= midpoint) clicksLast += r.clicks;
    else clicksPrev += r.clicks;
  }
  const avgPosition = rows.length > 0 ? Math.round((posSum / rows.length) * 10) / 10 : null;
  const ctr = imps > 0 ? Math.round((clicks / imps) * 10000) / 100 : null;

  // Trend: >5% shift declares a direction; anything else is stable.
  let trendPct = 0;
  let trend: PerformanceBlock["trend"] = "stable";
  if (clicksPrev > 0) {
    trendPct = Math.round(((clicksLast - clicksPrev) / clicksPrev) * 1000) / 10;
    if (trendPct > 5) trend = "improving";
    else if (trendPct < -5) trend = "declining";
  }

  return {
    clicks28d: clicks,
    impressions28d: imps,
    avgPosition,
    ctr,
    trend, trendPct,
    clicksPrev14d: clicksPrev,
    clicksLast14d: clicksLast,
  };
}
