/**
 * runSiteAudit(runId)
 *
 * Core site-audit worker. Safe to call directly (via waitUntil) or via the
 * HTTP process route (for cron re-kicks). Resumable: re-invoke on a crawling
 * or processing run picks up exactly where the previous invocation left off.
 *
 * Returns when the run reaches a terminal state (completed/failed) OR when
 * the DataForSEO crawl is still in progress and the cron should re-kick.
 * Throws only on unexpected errors that the caller should log.
 */
import { prisma } from "@/lib/prisma";
import {
  submitCrawl,
  waitForCrawl,
  fetchAllCrawledPages,
  fetchRedirectChains,
  fetchDuplicateTitles,
  fetchDuplicateDescriptions,
  checkSiteFiles,
  normalizePage,
  classifyPageTypes,
  buildIssues,
  computeScore,
  aggregateGscData,
  aggregateSiteTagData,
  matchUrlToJoin,
  classifyAuditCrawlError,
  isDfsConfigured,
  resolveCanonicalHost,
  type EnrichedPage,
  type PageType,
} from "@/lib/services/site-audit-crawler";

export async function runSiteAudit(runId: string): Promise<void> {
  const dfsConfigured = isDfsConfigured();
  console.log(
    `[site-audit-runner] starting run=${runId} dfsConfigured=${dfsConfigured}` +
    ` DATAFORSEO_LOGIN_set=${!!process.env.DATAFORSEO_LOGIN} DATAFORSEO_PASSWORD_set=${!!process.env.DATAFORSEO_PASSWORD}`,
  );

  // Sweep globally-stuck runs older than 30 minutes. The cleanup cron is
  // removed; this ensures stuck runs don't live forever -- they get marked
  // failed the next time any run starts.
  const stuckCutoff = new Date(Date.now() - 30 * 60 * 1000);
  await prisma.siteAuditRun.updateMany({
    where: {
      status: { in: ["pending", "crawling", "processing"] },
      updatedAt: { lt: stuckCutoff },
      id: { not: runId }, // never cancel the run we're about to execute
    },
    data: { status: "failed", errorMessage: "Stuck run expired (> 30 min without progress)", completedAt: new Date() },
  });

  const run = await prisma.siteAuditRun.findUnique({
    where: { id: runId },
    select: {
      id: true,
      status: true,
      taskId: true,
      pagesProcessed: true,
      brand: { select: { id: true, domain: true, renderTier: true } },
    },
  });

  if (!run) {
    console.error(`[site-audit-runner] run not found: ${runId}`);
    return;
  }
  if (run.status === "completed" || run.status === "failed") return;

  if (!isDfsConfigured()) {
    await prisma.siteAuditRun.update({
      where: { id: runId },
      data: { status: "failed", errorMessage: "DataForSEO not configured", completedAt: new Date() },
    });
    return;
  }

  const rawDomain = run.brand.domain ?? "";
  const domain = rawDomain.replace(/^https?:\/\//, "").replace(/\/$/, "");

  // Resolve the final canonical hostname by following the homepage redirect
  // chain. www vs non-www or http->https mismatches cause DFS to report
  // finished with 0 pages because the start URL redirected off-scope.
  const resolvedDomain = await resolveCanonicalHost(domain);
  console.log(
    `[site-audit-runner] run=${runId} originalDomain="${domain}" resolvedTarget="${resolvedDomain}"`,
  );
  const origin = `https://${resolvedDomain}`;

  let taskId = run.taskId;

  // -- Phase: submit crawl (if still pending) --
  if (run.status === "pending") {
    try {
      const crawlResult = await submitCrawl(resolvedDomain, {
        maxPages: 3000,
        crawlDepth: 10,
        enableJs: run.brand.renderTier === "js",
      });
      taskId = crawlResult.taskId;
      await prisma.siteAuditRun.update({
        where: { id: runId },
        data: { status: "crawling", taskId, updatedAt: new Date() },
      });
    } catch (err) {
      const { reason, detail } = classifyAuditCrawlError(err);
      await prisma.siteAuditRun.update({
        where: { id: runId },
        data: { status: "failed", crawlError: reason, errorMessage: detail, completedAt: new Date() },
      });
      return;
    }
  }

  // -- Phase: poll crawl (if crawling) --
  if ((run.status === "crawling" || run.status === "pending") && taskId) {
    // Budget: 600s poll window. If the crawl is not done, return -- cron will re-kick.
    const finished = await waitForCrawl(taskId, 600_000).catch(async (err: unknown) => {
      const { reason, detail } = classifyAuditCrawlError(err);
      await prisma.siteAuditRun.update({
        where: { id: runId },
        data: { status: "failed", crawlError: reason, errorMessage: detail, completedAt: new Date() },
      });
      return null; // null = faulted
    });

    if (finished === null) return; // already marked failed above

    if (!finished) {
      // Still crawling -- heartbeat so cron knows we're alive
      await prisma.siteAuditRun.update({
        where: { id: runId },
        data: { updatedAt: new Date() },
      });
      console.log(`[site-audit-runner] run ${runId} still crawling -- cron will re-kick`);
      return;
    }

    // Crawl done -- advance to processing
    await prisma.siteAuditRun.update({
      where: { id: runId },
      data: { status: "processing", updatedAt: new Date() },
    });
  }

  // -- Phase: process results --
  if (!taskId) {
    await prisma.siteAuditRun.update({
      where: { id: runId },
      data: { status: "failed", errorMessage: "No taskId on processing run", completedAt: new Date() },
    });
    return;
  }

  try {
    // Fetch all endpoints in parallel
    const [rawPages, redirectChains, dupTitles, dupDescs, siteFiles] = await Promise.all([
      fetchAllCrawledPages(taskId),
      fetchRedirectChains(taskId),
      fetchDuplicateTitles(taskId),
      fetchDuplicateDescriptions(taskId),
      checkSiteFiles(origin),
    ]);

    console.log(
      `[site-audit-runner] run=${runId} rawPages=${rawPages.length} redirectChains=${redirectChains.length} dupTitles=${dupTitles.length} dupDescs=${dupDescs.length}`,
    );

    // Guard: never score a run with zero crawled pages. A finished-0 result
    // means the target redirected off-host, was blocked, or DFS failed silently.
    if (rawPages.length === 0) {
      await prisma.siteAuditRun.update({
        where: { id: runId },
        data: {
          status: "failed",
          crawlError: "crawl_empty",
          errorMessage:
            "crawl returned 0 pages: target may redirect off-host or be blocked by robots.txt",
          completedAt: new Date(),
        },
      });
      console.warn(
        `[site-audit-runner] run=${runId} aborting: 0 pages returned by DFS (resolvedTarget="${resolvedDomain}")`,
      );
      return;
    }

    // Normalize pages
    const normalizedPages = rawPages.map(normalizePage).filter((p) => !!p.url);

    // Classify page types
    const typeMap = await classifyPageTypes(
      normalizedPages.map((p) => ({ url: p.url, title: p.title, h1: p.h1 })),
    );

    // Build sitemap URL set for inSitemap join
    const sitemapSet = new Set(
      siteFiles.sitemapUrls.map((u) => {
        try {
          return new URL(u).pathname.replace(/\/$/, "") || "/";
        } catch {
          return u;
        }
      }),
    );

    // Aggregate GSC and SiteTag data (set-based, one query each)
    const [gscMap, stMap] = await Promise.all([
      aggregateGscData(run.brand.id),
      aggregateSiteTagData(run.brand.id),
    ]);

    function urlPath(url: string): string {
      try {
        return new URL(url).pathname.replace(/\/$/, "") || "/";
      } catch {
        return url;
      }
    }

    type PageWrite = {
      runId: string;
      url: string;
      pageType: string | null;
      pageTypeSource: string | null;
      statusCode: number;
      title: string | null;
      h1: string | null;
      metaDescription: string | null;
      wordCount: number | null;
      thinContent: boolean;
      isIndexable: boolean;
      isOrphan: boolean;
      inSitemap: boolean;
      canonical: string | null;
      redirectTarget: string | null;
      hasSchema: boolean;
      schemaTypes: string[];
      hasFaq: boolean;
      hasHowTo: boolean;
      gscClicks: number | null;
      gscImpressions: number | null;
      gscPosition: number | null;
      stSessions: number | null;
      stConversions: number | null;
    };

    const pageRows: PageWrite[] = normalizedPages.map((p) => {
      const classification = typeMap.get(p.url);
      const inSitemap = sitemapSet.has(urlPath(p.url));
      const gsc = matchUrlToJoin(p.url, gscMap);
      const st = matchUrlToJoin(p.url, stMap);

      return {
        runId,
        url: p.url,
        pageType: classification?.type ?? null,
        pageTypeSource: classification?.source ?? null,
        statusCode: p.statusCode,
        title: p.title,
        h1: p.h1,
        metaDescription: p.metaDescription,
        wordCount: p.wordCount,
        thinContent: p.thinContent,
        isIndexable: p.isIndexable,
        isOrphan: p.isOrphan,
        inSitemap,
        canonical: p.canonical,
        redirectTarget: p.redirectTarget,
        hasSchema: p.hasSchema,
        schemaTypes: p.schemaTypes,
        hasFaq: p.hasFaq,
        hasHowTo: p.hasHowTo,
        gscClicks: gsc?.clicks ?? null,
        gscImpressions: gsc?.impressions ?? null,
        gscPosition: gsc?.position ?? null,
        stSessions: st?.sessions ?? null,
        stConversions: st?.conversions ?? null,
      };
    });

    // Write pages in batches of 200, advancing pagesProcessed cursor
    const WRITE_BATCH = 200;
    const startFrom = run.pagesProcessed ?? 0;
    for (let i = startFrom; i < pageRows.length; i += WRITE_BATCH) {
      const batch = pageRows.slice(i, i + WRITE_BATCH);
      await prisma.siteAuditPage.createMany({ data: batch, skipDuplicates: true });
      await prisma.siteAuditRun.update({
        where: { id: runId },
        data: { pagesProcessed: i + batch.length, updatedAt: new Date() },
      });
    }

    // Attach pageType to normalized pages for issue building
    const pagesWithType: EnrichedPage[] = normalizedPages.map((p) => ({
      ...p,
      pageType: typeMap.get(p.url)?.type as PageType | undefined,
      inSitemap: sitemapSet.has(urlPath(p.url)),
    }));

    // Build and write issues
    const issues = buildIssues(pagesWithType, siteFiles, redirectChains, dupTitles, dupDescs);

    if (issues.length > 0) {
      await prisma.siteAuditIssue.createMany({
        data: issues.map((issue) => ({
          runId,
          category: issue.category,
          severity: issue.severity,
          issueType: issue.issueType,
          title: issue.title,
          detail: issue.detail ?? null,
          affectedUrls: issue.affectedUrls,
          affected: issue.affected,
        })),
        skipDuplicates: true,
      });
    }

    // Tag each page with its worst issue severity
    const urlIssueSeverity = new Map<string, { worst: string; types: string[] }>();
    const SEVERITY_RANK: Record<string, number> = { error: 3, warning: 2, notice: 1, passed: 0 };
    for (const issue of issues) {
      for (const url of issue.affectedUrls) {
        const existing = urlIssueSeverity.get(url) ?? { worst: "passed", types: [] };
        if ((SEVERITY_RANK[issue.severity] ?? 0) > (SEVERITY_RANK[existing.worst] ?? 0)) {
          existing.worst = issue.severity;
        }
        if (!existing.types.includes(issue.issueType)) existing.types.push(issue.issueType);
        urlIssueSeverity.set(url, existing);
      }
    }

    const urlSeverityEntries = [...urlIssueSeverity.entries()];
    for (let i = 0; i < urlSeverityEntries.length; i += 200) {
      const chunk = urlSeverityEntries.slice(i, i + 200);
      await Promise.all(
        chunk.map(([url, { worst, types }]) =>
          prisma.siteAuditPage.updateMany({
            where: { runId, url },
            data: { severity: worst, issueTypes: types },
          }),
        ),
      );
    }

    // Compute composite score and mark complete
    const { score, subScores, categoryCounts } = computeScore(issues, pageRows.length);

    await prisma.siteAuditRun.update({
      where: { id: runId },
      data: {
        status: "completed",
        score,
        subScores,
        categoryCounts,
        pagesScanned: pageRows.length,
        issueCount: issues.length,
        llmsTxt: siteFiles.llmsTxt,
        robotsTxt: siteFiles.robotsTxt,
        robotsAiBlocks: siteFiles.robotsAiBlocks,
        sitemapFound: siteFiles.sitemapFound,
        sitemapUrl: siteFiles.sitemapUrl,
        completedAt: new Date(),
      },
    });

    console.log(
      `[site-audit-runner] run ${runId} completed: ${pageRows.length} pages, score=${score}, issues=${issues.length}`,
    );
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    console.error(`[site-audit-runner] process failed run=${runId}`, err);
    await prisma.siteAuditRun.update({
      where: { id: runId },
      data: { status: "failed", errorMessage: msg.slice(0, 500), completedAt: new Date() },
    });
  }
}
