/**
 * Site Audit Crawler
 * ------------------
 * DataForSEO OnPage-based full-site crawler for the /site-audit module.
 * Designed to be resumable: submit task, store taskId, poll separately,
 * fetch results in batches, write with a pagesProcessed cursor.
 *
 * Distinct from src/lib/audit/scrapers/dataforseo-onpage.ts (which is the
 * 5-page technical audit scraper with browser rendering). This module uses
 * raw HTML crawl (no browser rendering) at scale (up to 3000 pages).
 */

import { prisma } from "@/lib/prisma";

// -- DFS client ---------------------------------------------------------------

const DFS_BASE = "https://api.dataforseo.com/v3";
const DFS_LOGIN = process.env.DATAFORSEO_LOGIN ?? "";
const DFS_PASSWORD = process.env.DATAFORSEO_PASSWORD ?? "";

export function isDfsConfigured(): boolean {
  return !!(DFS_LOGIN && DFS_PASSWORD);
}

function dfsAuth(): string {
  return "Basic " + Buffer.from(`${DFS_LOGIN}:${DFS_PASSWORD}`).toString("base64");
}

async function dfsPost<T = unknown>(endpoint: string, body: unknown[]): Promise<T> {
  if (!isDfsConfigured()) throw new SiteAuditError("DataForSEO credentials not configured");
  const res = await fetch(`${DFS_BASE}${endpoint}`, {
    method: "POST",
    headers: { Authorization: dfsAuth(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new SiteAuditError(`DataForSEO HTTP ${res.status} on ${endpoint}`);
  const data = (await res.json()) as { status_code?: number; tasks?: unknown[] };
  if (data.status_code !== 20000) {
    throw new SiteAuditError(`DataForSEO envelope error ${data.status_code} on ${endpoint}`);
  }
  return data as T;
}

// -- Error --------------------------------------------------------------------

export class SiteAuditError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "SiteAuditError";
  }
}

export type SiteAuditCrawlErrorReason =
  | "crawl_blocked"
  | "crawl_timeout"
  | "crawl_empty"
  | "site_unreachable"
  | "redirect_loop"
  | "dfs_fault"
  | "unknown";

export function classifyAuditCrawlError(err: unknown): { reason: SiteAuditCrawlErrorReason; detail: string } {
  const msg = err instanceof Error ? err.message : String(err);
  const detail = msg.slice(0, 500);
  if (/timeout|budget|exceeded/i.test(msg)) return { reason: "crawl_timeout", detail };
  if (/fault/i.test(msg)) return { reason: "dfs_fault", detail };
  if (/forbidden|blocked|403|crawl_blocked/i.test(msg)) return { reason: "crawl_blocked", detail };
  if (/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|unreachable/i.test(msg)) return { reason: "site_unreachable", detail };
  if (/redirect/i.test(msg)) return { reason: "redirect_loop", detail };
  return { reason: "unknown", detail };
}

// -- OnPage types -------------------------------------------------------------

interface DfsTaskCreateResponse {
  status_code: number;
  tasks: Array<{ id: string; status_code: number; status_message: string }>;
}

interface DfsSummaryResponse {
  tasks: Array<{
    id?: string;
    status_code?: number;
    status_message?: string;
    result: Array<{
      crawl_progress?: string;
      crawl_status?: {
        pages_crawled?: number;
        pages_in_queue?: number;
        max_crawl_pages?: number;
        pages_crawl_end?: number;
      };
      domain_info?: {
        total_pages?: number;
        checks?: {
          sitemap?: boolean;
          robots_txt?: boolean;
          mobile_friendliness?: number;
        };
      };
      page_metrics?: {
        broken_links?: number;
        broken_resources?: number;
        duplicate_meta_tags?: number;
        duplicate_title?: number;
      };
    }>;
  }>;
}

interface DfsPagesResponse {
  tasks: Array<{
    result: Array<{
      total_count?: number;
      items?: DfsPageItem[];
    }>;
  }>;
}

export interface DfsPageItem {
  url?: string;
  status_code?: number;
  resource_type?: string;
  extended_crawl_status?: string;
  checks?: {
    canonical?: boolean;
    is_4xx_code?: boolean;
    is_redirect?: boolean;
    no_index_canonical?: boolean;
    is_orphan_page?: boolean;
    has_links_from_orphan_pages?: boolean;
  };
  meta?: {
    title?: string;
    description?: string;
    canonical?: string;
    internal_links_count?: number;
    external_links_count?: number;
    images_count?: number;
    images_without_alt_attributes?: number;
    follow?: boolean;
    no_index?: boolean;
  };
  content?: {
    plain_text_word_count?: number;
    plain_text_size?: number;
  };
  heading_info?: {
    heading?: Array<{ type?: string; content?: string }>;
  };
  relative_url?: string;
  structured_data?: {
    types?: string[];
    count?: number;
  };
}

interface DfsRedirectChainsResponse {
  tasks: Array<{
    result: Array<{
      items?: Array<{
        chain?: Array<{ url?: string; status_code?: number }>;
      }>;
      total_count?: number;
    }>;
  }>;
}

interface DfsDuplicateGroupResponse {
  tasks: Array<{
    result: Array<{
      items?: Array<{
        pages?: Array<{ url?: string }>;
        similarity?: number;
      }>;
      total_count?: number;
    }>;
  }>;
}

// -- Crawl options ------------------------------------------------------------

const CHROME_UA =
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";

export interface CrawlOptions {
  maxPages?: number;      // default 3000
  crawlDepth?: number;    // default 10
  enableJs?: boolean;     // default false (only true when brand.renderTier="js")
  // crawlDelay: milliseconds between requests. Default 200ms is safe for
  // WAF/Cloudflare-protected sites. Pass 0 only for client-owned or
  // controlled domains where no rate-limit risk exists.
  crawlDelay?: number;    // default 200
}

function buildCrawlPayload(domain: string, opts: CrawlOptions) {
  return {
    target: domain,
    max_crawl_pages: opts.maxPages ?? 3000,
    crawl_depth: opts.crawlDepth ?? 10,
    crawl_delay: opts.crawlDelay ?? 200,
    load_resources: false,
    enable_content_parsing: true,
    enable_javascript: opts.enableJs ?? false,
    enable_browser_rendering: false, // NEVER enabled in site audit
    check_spell: false,
    support_cookies: false,
    custom_user_agent: CHROME_UA,
  };
}

// -- Phase 1: Submit crawl ----------------------------------------------------

export interface SubmitCrawlResult {
  taskId: string;
}

export async function submitCrawl(domain: string, opts: CrawlOptions = {}): Promise<SubmitCrawlResult> {
  const payload = buildCrawlPayload(domain, opts);
  console.log(
    `[site-audit] submitCrawl payload=${JSON.stringify(payload)}`,
  );

  const res = await dfsPost<DfsTaskCreateResponse>("/on_page/task_post", [payload]);
  const task = res.tasks?.[0];
  const taskId = task?.id;
  console.log(
    `[site-audit] task_post response: status_code=${task?.status_code} status_message="${task?.status_message}" taskId=${taskId}`,
  );
  if (task?.status_code !== 20100 || !taskId) {
    throw new SiteAuditError(
      `Crawl task rejected: status=${task?.status_code} msg="${task?.status_message}"`,
    );
  }
  return { taskId };
}

// -- Phase 2: Poll crawl status -----------------------------------------------

export type CrawlPollResult =
  | { done: true; crawled: number; progress: string }
  | { done: false; crawled: number; inQueue: number; progress: string };

export async function pollCrawlStatus(taskId: string): Promise<CrawlPollResult> {
  const res = await dfsPost<DfsSummaryResponse>("/on_page/summary", [{ id: taskId }]);
  const task = res.tasks?.[0];
  const result = task?.result?.[0];
  const progress = result?.crawl_progress ?? "unknown";
  const crawlStatus = result?.crawl_status;
  const crawled = crawlStatus?.pages_crawled ?? 0;
  const inQueue = crawlStatus?.pages_in_queue ?? 0;

  console.log(
    `[site-audit] summary taskId=${taskId}` +
    ` task_status=${task?.status_code} task_msg="${task?.status_message}"` +
    ` crawl_progress=${progress}` +
    ` pages_crawled=${crawlStatus?.pages_crawled ?? "null"}` +
    ` pages_in_queue=${crawlStatus?.pages_in_queue ?? "null"}` +
    ` max_crawl_pages=${crawlStatus?.max_crawl_pages ?? "null"}` +
    ` pages_crawl_end=${crawlStatus?.pages_crawl_end ?? "null"}`,
  );

  if (progress === "fault") throw new SiteAuditError("DataForSEO crawl faulted");
  if (progress === "finished") return { done: true, crawled, progress };
  return { done: false, crawled, inQueue, progress };
}

// Poll up to budgetMs. Returns true when finished, false if budget expires.
//
// Guard: DFS occasionally reports "finished" with 0 pages within seconds of
// task creation -- the crawler hasn't started yet. Enforce a 60s minimum
// before accepting any "finished" result so we don't read pages before the
// crawl has actually run.
export async function waitForCrawl(taskId: string, budgetMs: number): Promise<boolean> {
  const deadline = Date.now() + budgetMs;
  const POLL_INTERVAL = 15_000;
  // Minimum elapsed time before trusting a "finished" result.
  // Real crawls take at least this long; earlier "finished" is a DFS
  // task-not-yet-started artifact.
  const MIN_CRAWL_MS = 60_000;
  const startedAt = Date.now();

  while (Date.now() < deadline) {
    await new Promise((r) => setTimeout(r, POLL_INTERVAL));
    const status = await pollCrawlStatus(taskId);
    const elapsedS = Math.round((Date.now() - startedAt) / 1000);
    console.log(
      `[site-audit] poll taskId=${taskId} progress=${status.progress} crawled=${status.crawled}` +
      `${status.done ? "" : ` inQueue=${(status as { inQueue: number }).inQueue}`} elapsed=${elapsedS}s`,
    );

    if (status.done) {
      const elapsed = Date.now() - startedAt;
      if (elapsed < MIN_CRAWL_MS) {
        // Too soon -- DFS task likely hasn't started crawling yet.
        // Log and keep polling rather than accepting a premature 0-page result.
        console.warn(
          `[site-audit] ignoring premature "finished" at ${elapsedS}s (crawled=${status.crawled}) -- minimum is ${MIN_CRAWL_MS / 1000}s, continuing to poll`,
        );
        continue;
      }
      return true;
    }
  }
  return false; // budget expired, caller should return and let cron re-kick
}

// -- Phase 3: Fetch all crawled pages (no cap) --------------------------------

const PAGE_BATCH = 100;

export async function fetchAllCrawledPages(taskId: string): Promise<DfsPageItem[]> {
  const items: DfsPageItem[] = [];
  let offset = 0;

  while (true) {
    const res = await dfsPost<DfsPagesResponse>("/on_page/pages", [
      {
        id: taskId,
        limit: PAGE_BATCH,
        offset,
        filters: [["resource_type", "=", "html"]],
      },
    ]);

    const result = res.tasks?.[0]?.result?.[0];
    if (offset === 0) {
      console.log(
        `[site-audit] fetchAllPages taskId=${taskId} total_count=${result?.total_count ?? "?"}`,
      );
    }
    const batch = result?.items ?? [];
    items.push(...batch);

    if (batch.length < PAGE_BATCH) break; // last page
    offset += PAGE_BATCH;
  }

  console.log(`[site-audit] fetchAllPages done: ${items.length} HTML items`);
  return items;
}

// -- Phase 4: Fetch supplemental endpoints ------------------------------------

export interface RedirectChain {
  chain: Array<{ url: string; statusCode: number }>;
}

export async function fetchRedirectChains(taskId: string): Promise<RedirectChain[]> {
  try {
    const res = await dfsPost<DfsRedirectChainsResponse>("/on_page/redirect_chains", [
      { id: taskId, limit: 1000 },
    ]);
    const items = res.tasks?.[0]?.result?.[0]?.items ?? [];
    return items
      .filter((i) => (i.chain?.length ?? 0) > 1)
      .map((i) => ({
        chain: (i.chain ?? []).map((c) => ({ url: c.url ?? "", statusCode: c.status_code ?? 0 })),
      }));
  } catch {
    return [];
  }
}

export interface DuplicateGroup {
  urls: string[];
}

export async function fetchDuplicateTitles(taskId: string): Promise<DuplicateGroup[]> {
  return fetchDuplicateGroups(taskId, "/on_page/duplicate_title");
}

export async function fetchDuplicateDescriptions(taskId: string): Promise<DuplicateGroup[]> {
  return fetchDuplicateGroups(taskId, "/on_page/duplicate_description");
}

async function fetchDuplicateGroups(taskId: string, endpoint: string): Promise<DuplicateGroup[]> {
  try {
    const res = await dfsPost<DfsDuplicateGroupResponse>(endpoint, [{ id: taskId, limit: 1000 }]);
    const items = res.tasks?.[0]?.result?.[0]?.items ?? [];
    return items
      .filter((i) => (i.pages?.length ?? 0) > 1)
      .map((i) => ({ urls: (i.pages ?? []).map((p) => p.url ?? "").filter(Boolean) }));
  } catch {
    return [];
  }
}

// -- Site-level file checks ---------------------------------------------------

export interface SiteFileChecks {
  llmsTxt: boolean;
  robotsTxt: boolean;
  robotsAiBlocks: {
    gptbot: boolean;
    claudebot: boolean;
    perplexitybot: boolean;
    googleExtended: boolean;
    ccbot: boolean;
  };
  sitemapFound: boolean;
  sitemapUrl: string | null;
  sitemapUrls: string[]; // all URLs found in sitemap for inSitemap join
}

export async function checkSiteFiles(origin: string): Promise<SiteFileChecks> {
  const wwwOrigin = origin.replace(/^(https?:\/\/)(?!www\.)/, "$1www.");
  const origins = origin === wwwOrigin ? [origin] : [origin, wwwOrigin];

  async function probe(url: string): Promise<{ ok: boolean; text: string }> {
    try {
      const res = await fetch(url, {
        redirect: "follow",
        signal: AbortSignal.timeout(10_000),
        headers: { "User-Agent": CHROME_UA },
      });
      if (!res.ok) return { ok: false, text: "" };
      const text = await res.text();
      return { ok: true, text };
    } catch {
      return { ok: false, text: "" };
    }
  }

  // llms.txt
  const llmsTxtResults = await Promise.all(origins.map((o) => probe(`${o}/llms.txt`)));
  const llmsTxt = llmsTxtResults.some((r) => r.ok);

  // robots.txt
  const robotsResults = await Promise.all(origins.map((o) => probe(`${o}/robots.txt`)));
  const robotsOk = robotsResults.some((r) => r.ok);
  const robotsText = robotsResults.find((r) => r.ok)?.text ?? "";

  function isBlocked(ua: string): boolean {
    const lines = robotsText.split("\n");
    let inBlock = false;
    for (const line of lines) {
      const trimmed = line.trim();
      if (/^user-agent:/i.test(trimmed)) {
        const agent = trimmed.replace(/^user-agent:\s*/i, "").trim().toLowerCase();
        inBlock = agent === ua.toLowerCase() || agent === "*";
      }
      if (inBlock && /^disallow:\s*\//i.test(trimmed)) {
        const path = trimmed.replace(/^disallow:\s*/i, "").trim();
        if (path === "/" || path === "") return true;
      }
    }
    return false;
  }

  const robotsAiBlocks = {
    gptbot: isBlocked("GPTBot"),
    claudebot: isBlocked("ClaudeBot"),
    perplexitybot: isBlocked("PerplexityBot"),
    googleExtended: isBlocked("Google-Extended"),
    ccbot: isBlocked("CCBot"),
  };

  // sitemap
  const sitemapCandidates = origins.flatMap((o) => [
    `${o}/sitemap.xml`,
    `${o}/sitemap_index.xml`,
    `${o}/sitemap-index.xml`,
  ]);
  const sitemapResults = await Promise.all(
    sitemapCandidates.map(async (url) => {
      const r = await probe(url);
      return { url, ...r };
    }),
  );
  const foundSitemap = sitemapResults.find((r) => r.ok);
  const sitemapFound = !!foundSitemap;
  const sitemapUrl = foundSitemap?.url ?? null;

  // Extract URLs from sitemap XML for inSitemap join
  const sitemapUrls: string[] = [];
  if (foundSitemap?.text) {
    const matches = foundSitemap.text.match(/<loc>([^<]+)<\/loc>/g) ?? [];
    for (const m of matches) {
      const url = m.replace(/<\/?loc>/g, "").trim();
      if (url) sitemapUrls.push(url);
    }
  }

  return {
    llmsTxt,
    robotsTxt: robotsOk,
    robotsAiBlocks,
    sitemapFound,
    sitemapUrl,
    sitemapUrls,
  };
}

// -- Canonical host resolution ------------------------------------------------
// Follow redirects on the homepage to find the final hostname DFS should
// target. Handles www vs non-www and http vs https mismatches that cause DFS
// to finish with 0 pages because the start URL redirected off-scope.

export async function resolveCanonicalHost(domain: string): Promise<string> {
  const url = `https://${domain}`;
  const fetchOpts = {
    redirect: "follow" as const,
    signal: AbortSignal.timeout(10_000),
    headers: { "User-Agent": CHROME_UA },
  };
  try {
    // HEAD preferred -- no body download. Some servers 405 HEAD; fall back to GET.
    let res: Response;
    try {
      res = await fetch(url, { method: "HEAD", ...fetchOpts });
    } catch {
      res = await fetch(url, { method: "GET", ...fetchOpts });
    }
    const finalHost = new URL(res.url).hostname;
    console.log(
      `[site-audit] resolveCanonicalHost "${domain}" -> "${finalHost}"` +
      `${finalHost !== domain ? " (redirected)" : ""}`,
    );
    return finalHost;
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    console.warn(
      `[site-audit] resolveCanonicalHost failed for "${domain}": ${msg} -- using as-is`,
    );
    return domain;
  }
}

// -- Page normalization -------------------------------------------------------

export interface NormalizedPage {
  url: string;
  statusCode: number;
  title: string | null;
  h1: string | null;
  metaDescription: string | null;
  wordCount: number | null;
  thinContent: boolean;
  isIndexable: boolean;
  isOrphan: boolean;
  canonical: string | null;
  redirectTarget: string | null;
  hasSchema: boolean;
  schemaTypes: string[];
  hasFaq: boolean;
  hasHowTo: boolean;
  isRedirect: boolean;
  is4xx: boolean;
}

export function normalizePage(item: DfsPageItem): NormalizedPage {
  const schemaTypes = item.structured_data?.types ?? [];
  const hasSchema = schemaTypes.length > 0;
  const hasFaq = schemaTypes.some((t) => /faqpage/i.test(t));
  const hasHowTo = schemaTypes.some((t) => /howto/i.test(t));

  const h1s = (item.heading_info?.heading ?? [])
    .filter((h) => h.type?.toLowerCase() === "h1")
    .map((h) => h.content ?? "")
    .filter(Boolean);

  const wordCount = item.content?.plain_text_word_count ?? null;
  const plainTextSize = item.content?.plain_text_size ?? null;
  // Mark thin if: word count < 150 OR body text suspiciously small (JS-required site signal)
  const thinContent =
    (wordCount !== null && wordCount < 150) ||
    (plainTextSize !== null && plainTextSize < 200 && wordCount === 0);

  return {
    url: item.url ?? "",
    statusCode: item.status_code ?? 200,
    title: item.meta?.title ?? null,
    h1: h1s[0] ?? null,
    metaDescription: item.meta?.description ?? null,
    wordCount,
    thinContent,
    isIndexable: !(item.meta?.no_index ?? false) && !(item.checks?.no_index_canonical ?? false),
    isOrphan: item.checks?.is_orphan_page ?? false,
    canonical: item.meta?.canonical ?? null,
    redirectTarget: item.checks?.is_redirect ? (item.relative_url ?? null) : null,
    hasSchema,
    schemaTypes,
    hasFaq,
    hasHowTo,
    isRedirect: item.checks?.is_redirect ?? false,
    is4xx: item.checks?.is_4xx_code ?? false,
  };
}

// -- Page type classification -------------------------------------------------

export type PageType =
  | "homepage"
  | "service"
  | "product"
  | "blog"
  | "location"
  | "category"
  | "contact"
  | "legacy-slug"
  | "other";

function classifyByUrl(url: string): PageType | null {
  try {
    const { pathname } = new URL(url);
    const p = pathname.toLowerCase();

    if (p === "/" || p === "") return "homepage";

    const segments = p.split("/").filter(Boolean);

    if (
      segments.some((s) =>
        /^(blog|news|article|articles|insights|post|posts|press|media)$/.test(s),
      )
    )
      return "blog";
    if (
      segments.some((s) =>
        /^(product|products|shop|store|item|items|sku|p)$/.test(s),
      )
    )
      return "product";
    if (
      segments.some((s) =>
        /^(services?|solutions?|offerings?|capabilities)$/.test(s),
      )
    )
      return "service";
    if (
      segments.some((s) =>
        /^(categor(?:y|ies)|collections?|tags?|cat)$/.test(s),
      )
    )
      return "category";
    if (
      segments.some((s) =>
        /^(contact|contact-us|get-in-touch|reach-us|connect)$/.test(s),
      )
    )
      return "contact";
    if (
      segments.some((s) =>
        /^(locations?|areas?|cities|markets?|regions?)$/.test(s),
      )
    )
      return "location";

    // Legacy slug: single segment, all lowercase hyphens, no numbers at end (not a product ID)
    if (
      segments.length === 1 &&
      /^[a-z][a-z0-9-]{10,}$/.test(segments[0]) &&
      !/-\d{4,}$/.test(segments[0])
    ) {
      return "legacy-slug";
    }

    return null; // needs Haiku
  } catch {
    return "other";
  }
}

export async function classifyPageTypes(
  pages: Array<{ url: string; title: string | null; h1: string | null }>,
): Promise<Map<string, { type: PageType; source: "rule" | "ai" }>> {
  const result = new Map<string, { type: PageType; source: "rule" | "ai" }>();
  const needsAi: typeof pages = [];

  for (const page of pages) {
    const ruleType = classifyByUrl(page.url);
    if (ruleType) {
      result.set(page.url, { type: ruleType, source: "rule" });
    } else {
      needsAi.push(page);
    }
  }

  if (needsAi.length === 0) return result;

  // Batch Haiku classification in groups of 20
  const AI_MODEL = "claude-haiku-4-5-20251001";
  const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY?.trim();

  if (!ANTHROPIC_KEY) {
    // No AI key -- fall back to "other" for all
    for (const page of needsAi) result.set(page.url, { type: "other", source: "ai" });
    return result;
  }

  const BATCH_SIZE = 20;
  for (let i = 0; i < needsAi.length; i += BATCH_SIZE) {
    const batch = needsAi.slice(i, i + BATCH_SIZE);
    const input = batch
      .map(
        (p, idx) => `${idx + 1}. URL: ${p.url}\n   Title: ${p.title ?? ""}\n   H1: ${p.h1 ?? ""}`,
      )
      .join("\n");

    try {
      const res = await fetch("https://api.anthropic.com/v1/messages", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "x-api-key": ANTHROPIC_KEY,
          "anthropic-version": "2023-06-01",
        },
        body: JSON.stringify({
          model: AI_MODEL,
          max_tokens: 512,
          system:
            "Classify each URL as one of: homepage, service, product, blog, location, category, contact, legacy-slug, other. Return a JSON array of strings, one per URL, in the same order. No explanation.",
          messages: [{ role: "user", content: input }],
        }),
      });

      if (res.ok) {
        const data = (await res.json()) as { content?: Array<{ text?: string }> };
        const text = data.content?.[0]?.text ?? "[]";
        const jsonMatch = text.match(/\[[\s\S]*\]/);
        if (jsonMatch) {
          const types = JSON.parse(jsonMatch[0]) as string[];
          batch.forEach((page, idx) => {
            const t = (types[idx] ?? "other") as PageType;
            result.set(page.url, { type: t, source: "ai" });
          });
          continue;
        }
      }
    } catch {
      // fallthrough to default
    }

    // Fallback if AI call fails
    for (const page of batch) result.set(page.url, { type: "other", source: "ai" });
  }

  return result;
}

// -- GSC + SiteTag set-based joins --------------------------------------------

export interface GscPageAgg {
  clicks: number;
  impressions: number;
  position: number | null;
}

export async function aggregateGscData(brandId: string): Promise<Map<string, GscPageAgg>> {
  const since = new Date(Date.now() - 30 * 86_400_000);
  const rows = await prisma.gscPage.findMany({
    where: { brandId, date: { gte: since } },
    select: { pageUrl: true, clicks: true, impressions: true, position: true },
  });

  const agg = new Map<string, { clicks: number; impressions: number; positions: number[] }>();
  for (const row of rows) {
    const key = normalizeUrlForJoin(row.pageUrl);
    const existing = agg.get(key) ?? { clicks: 0, impressions: 0, positions: [] };
    existing.clicks += row.clicks;
    existing.impressions += row.impressions;
    existing.positions.push(row.position);
    agg.set(key, existing);
  }

  const result = new Map<string, GscPageAgg>();
  for (const [key, { clicks, impressions, positions }] of agg) {
    result.set(key, {
      clicks,
      impressions,
      position:
        positions.length > 0
          ? positions.reduce((a, b) => a + b, 0) / positions.length
          : null,
    });
  }
  return result;
}

export interface SiteTagPageAgg {
  sessions: number;
  conversions: number;
}

export async function aggregateSiteTagData(brandId: string): Promise<Map<string, SiteTagPageAgg>> {
  // Look up the TrackedSite for this brand
  const trackedSite = await prisma.trackedSite.findUnique({
    where: { brandId },
    select: { id: true },
  });
  if (!trackedSite) return new Map();

  const since = new Date(Date.now() - 30 * 86_400_000);

  // Fetch all page_view events grouped by pageUrl (in memory aggregation)
  const pageViews = await prisma.trackedEvent.findMany({
    where: {
      trackedSiteId: trackedSite.id,
      eventType: "page_view",
      timestamp: { gte: since },
    },
    select: { pageUrl: true, sessionId: true },
  });

  // Fetch all SiteConversion events for this brand
  // intentionally unfiltered: internal service; uses raw counts intentionally
  const conversions = await prisma.siteConversion.findMany({
    where: { brandId, timestamp: { gte: since } },
    select: { pageUrl: true },
  });

  const sessions = new Map<string, Set<string>>();
  for (const pv of pageViews) {
    const key = normalizeUrlForJoin(pv.pageUrl);
    if (!sessions.has(key)) sessions.set(key, new Set());
    if (pv.sessionId) sessions.get(key)!.add(pv.sessionId);
  }

  const convCounts = new Map<string, number>();
  for (const cv of conversions) {
    const key = normalizeUrlForJoin(cv.pageUrl);
    convCounts.set(key, (convCounts.get(key) ?? 0) + 1);
  }

  const result = new Map<string, SiteTagPageAgg>();
  const allKeys = new Set([...sessions.keys(), ...convCounts.keys()]);
  for (const key of allKeys) {
    result.set(key, {
      sessions: sessions.get(key)?.size ?? 0,
      conversions: convCounts.get(key) ?? 0,
    });
  }
  return result;
}

function normalizeUrlForJoin(url: string): string {
  try {
    const u = new URL(url);
    // Strip protocol, host, trailing slash for matching
    return (u.pathname + u.search).replace(/\/$/, "") || "/";
  } catch {
    return url.replace(/\/$/, "") || "/";
  }
}

export function matchUrlToJoin<T>(pageUrl: string, joinMap: Map<string, T>): T | undefined {
  // Try exact normalized path first, then path prefix match
  const path = normalizeUrlForJoin(pageUrl);
  if (joinMap.has(path)) return joinMap.get(path);
  // Try stripping query string
  const pathOnly = path.split("?")[0];
  if (joinMap.has(pathOnly)) return joinMap.get(pathOnly);
  return undefined;
}

// -- Issue normalization ------------------------------------------------------

// Extended NormalizedPage with optional pageType and inSitemap, set post-classification
export interface EnrichedPage extends NormalizedPage {
  pageType?: PageType;
  inSitemap?: boolean;
}

export interface AuditIssue {
  category: "technical" | "ai_readiness" | "aeo_faq";
  severity: "error" | "warning" | "notice" | "passed";
  issueType: string;
  title: string;
  detail?: string;
  affectedUrls: string[];
  affected: number;
}

export function buildIssues(
  pages: EnrichedPage[],
  siteFiles: SiteFileChecks,
  redirectChains: RedirectChain[],
  duplicateTitles: DuplicateGroup[],
  duplicateDescriptions: DuplicateGroup[],
): AuditIssue[] {
  const issues: AuditIssue[] = [];
  const indexablePages = pages.filter((p) => p.isIndexable && !p.is4xx);

  function pageIssue(
    category: AuditIssue["category"],
    severity: AuditIssue["severity"],
    issueType: string,
    title: string,
    detail: string | undefined,
    affected: string[],
  ) {
    if (affected.length === 0) return;
    issues.push({
      category,
      severity,
      issueType,
      title,
      detail,
      affectedUrls: affected,
      affected: affected.length,
    });
  }

  // Missing title
  const missingTitle = indexablePages.filter((p) => !p.title).map((p) => p.url);
  pageIssue("technical", "error", "missing_title", "Missing page title", undefined, missingTitle);

  // Missing meta description
  const missingMeta = indexablePages.filter((p) => !p.metaDescription).map((p) => p.url);
  pageIssue(
    "technical",
    "warning",
    "missing_meta_description",
    "Missing meta description",
    undefined,
    missingMeta,
  );

  // Missing H1
  const missingH1 = indexablePages.filter((p) => !p.h1).map((p) => p.url);
  pageIssue("technical", "warning", "missing_h1", "Missing H1", undefined, missingH1);

  // 4xx pages
  const brokenPages = pages.filter((p) => p.is4xx).map((p) => p.url);
  pageIssue(
    "technical",
    "error",
    "broken_page",
    "Page returns 4xx error",
    undefined,
    brokenPages,
  );

  // Orphan pages (indexable, no inbound internal links)
  const orphanPages = indexablePages.filter((p) => p.isOrphan).map((p) => p.url);
  pageIssue(
    "technical",
    "warning",
    "orphan_page",
    "Orphan page (no inbound internal links)",
    undefined,
    orphanPages,
  );

  // Thin content
  const thinPages = indexablePages.filter((p) => p.thinContent).map((p) => p.url);
  pageIssue(
    "technical",
    "warning",
    "thin_content",
    "Thin or empty content",
    "Fewer than 150 words or empty body. May indicate JavaScript rendering required.",
    thinPages,
  );

  // Noindex but in sitemap
  const noindexInSitemap = pages
    .filter((p) => !p.isIndexable && p.inSitemap)
    .map((p) => p.url);
  pageIssue(
    "technical",
    "warning",
    "noindex_in_sitemap",
    "Noindex page listed in sitemap",
    undefined,
    noindexInSitemap,
  );

  // Missing canonical on non-homepage indexable pages
  const noCanonical = indexablePages
    .filter((p) => !p.canonical && !p.url.endsWith("/"))
    .map((p) => p.url);
  pageIssue(
    "technical",
    "notice",
    "no_canonical",
    "Missing canonical tag",
    undefined,
    noCanonical,
  );

  // Redirect chains (> 1 hop)
  const chainUrls = redirectChains.map((c) => c.chain[0]?.url ?? "").filter(Boolean);
  pageIssue(
    "technical",
    "warning",
    "redirect_chain",
    "Multi-hop redirect chain",
    "3xx redirect through more than one intermediate URL.",
    chainUrls,
  );

  // Duplicate titles (from DFS endpoint)
  for (const group of duplicateTitles) {
    pageIssue("technical", "error", "duplicate_title", "Duplicate page title", undefined, group.urls);
  }

  // Duplicate meta descriptions
  for (const group of duplicateDescriptions) {
    pageIssue(
      "technical",
      "warning",
      "duplicate_meta_description",
      "Duplicate meta description",
      undefined,
      group.urls,
    );
  }

  // Site-level: no sitemap
  if (!siteFiles.sitemapFound) {
    issues.push({
      category: "technical",
      severity: "warning",
      issueType: "no_sitemap",
      title: "No sitemap.xml found",
      affectedUrls: [],
      affected: 0,
    });
  }
  if (!siteFiles.robotsTxt) {
    issues.push({
      category: "technical",
      severity: "notice",
      issueType: "no_robots_txt",
      title: "No robots.txt found",
      affectedUrls: [],
      affected: 0,
    });
  }

  // AI-Readiness issues
  if (!siteFiles.llmsTxt) {
    issues.push({
      category: "ai_readiness",
      severity: "warning",
      issueType: "no_llms_txt",
      title: "No llms.txt file found",
      detail: "llms.txt signals to AI crawlers what content is available.",
      affectedUrls: [],
      affected: 0,
    });
  }
  if (siteFiles.robotsAiBlocks.gptbot) {
    issues.push({
      category: "ai_readiness",
      severity: "error",
      issueType: "robots_blocks_gptbot",
      title: "robots.txt blocks GPTBot (ChatGPT)",
      affectedUrls: [],
      affected: 0,
    });
  }
  if (siteFiles.robotsAiBlocks.claudebot) {
    issues.push({
      category: "ai_readiness",
      severity: "warning",
      issueType: "robots_blocks_claudebot",
      title: "robots.txt blocks ClaudeBot",
      affectedUrls: [],
      affected: 0,
    });
  }
  if (siteFiles.robotsAiBlocks.perplexitybot) {
    issues.push({
      category: "ai_readiness",
      severity: "warning",
      issueType: "robots_blocks_perplexitybot",
      title: "robots.txt blocks PerplexityBot",
      affectedUrls: [],
      affected: 0,
    });
  }
  if (siteFiles.robotsAiBlocks.googleExtended) {
    issues.push({
      category: "ai_readiness",
      severity: "warning",
      issueType: "robots_blocks_google_extended",
      title: "robots.txt blocks Google-Extended (Gemini)",
      affectedUrls: [],
      affected: 0,
    });
  }
  if (siteFiles.robotsAiBlocks.ccbot) {
    issues.push({
      category: "ai_readiness",
      severity: "notice",
      issueType: "robots_blocks_ccbot",
      title: "robots.txt blocks CCBot (Common Crawl)",
      affectedUrls: [],
      affected: 0,
    });
  }

  // Missing schema on service/product pages
  const schemaEligible = indexablePages.filter(
    (p) => p.pageType && ["service", "product"].includes(p.pageType),
  );
  if (schemaEligible.length > 0) {
    const missingSchema = schemaEligible.filter((p) => !p.hasSchema).map((p) => p.url);
    const pct = missingSchema.length / schemaEligible.length;
    if (pct > 0.3) {
      pageIssue(
        "ai_readiness",
        "warning",
        "missing_schema",
        "Missing structured data on service/product pages",
        `${Math.round(pct * 100)}% of service/product pages lack schema markup.`,
        missingSchema,
      );
    }
  }

  // AEO/FAQ: no FAQ schema on blog/service pages
  const faqEligible = indexablePages.filter(
    (p) => p.pageType && ["blog", "service"].includes(p.pageType),
  );
  if (faqEligible.length > 0) {
    const missingFaq = faqEligible.filter((p) => !p.hasFaq).map((p) => p.url);
    const pct = missingFaq.length / faqEligible.length;
    if (pct > 0.4) {
      pageIssue(
        "aeo_faq",
        "warning",
        "no_faq_schema",
        "FAQ schema missing on blog/service pages",
        `${Math.round(pct * 100)}% of eligible pages lack FAQ markup.`,
        missingFaq,
      );
    }
  }

  return issues;
}

// -- Scoring ------------------------------------------------------------------

export interface SiteHealthScore {
  score: number;
  subScores: { technical: number; aiReadiness: number; aeoFaq: number };
  categoryCounts: { error: number; warning: number; notice: number; passed: number };
}

export function computeScore(issues: AuditIssue[], _totalPages: number): SiteHealthScore {
  const counts = { error: 0, warning: 0, notice: 0, passed: 0 };
  for (const issue of issues) {
    const sev = issue.severity as keyof typeof counts;
    if (sev in counts) counts[sev] += 1;
  }

  const techIssues = issues.filter((i) => i.category === "technical");
  const aiIssues = issues.filter((i) => i.category === "ai_readiness");
  const aeoIssues = issues.filter((i) => i.category === "aeo_faq");

  function subScore(relevant: AuditIssue[]): number {
    let score = 100;
    const errors = relevant.filter((i) => i.severity === "error").length;
    const warnings = relevant.filter((i) => i.severity === "warning").length;
    const notices = relevant.filter((i) => i.severity === "notice").length;
    score -= Math.min(errors * 15, 60);
    score -= Math.min(warnings * 5, 25);
    score -= Math.min(notices * 2, 10);
    return Math.max(0, Math.min(100, score));
  }

  const technical = subScore(techIssues);
  const aiReadiness = subScore(aiIssues);
  const aeoFaq = subScore(aeoIssues);
  const score = Math.round(0.4 * technical + 0.35 * aiReadiness + 0.25 * aeoFaq);

  return { score, subScores: { technical, aiReadiness, aeoFaq }, categoryCounts: counts };
}
