/**
 * GET /api/competitors/[id]/keyword-overlap?location=United+States[&limit=200]
 * ────────────────────────────────────────────────────────────────────────────
 * Returns the keyword overlap between the brand and a single competitor.
 *
 * Primary path — DataForSEO Domain Intersection API. Returns keywords
 * both domains rank for in the top 100, each domain's position, landing
 * URL, volume, CPC, difficulty, and SERP features. ~$0.02 per call.
 *
 * Fallback path — when Domain Intersection returns zero rows (small
 * local practices routinely lack enough ranking data in DataForSEO's
 * corpus to produce a meaningful intersection), we pivot to an AI
 * estimation grounded in:
 *   • the brand's top GSC queries (source of truth for what the
 *     brand actually ranks for)
 *   • the competitor's crawled sitemap pages (titles + paths we
 *     already have in CompetitorPage)
 * The AI scores likely shared keywords without guessing at numbers
 * that don't exist — returns { source: "ai_estimate" } so the UI
 * can label the results accordingly.
 *
 * Returns 503 when DataForSEO isn't configured AND no GSC data
 * exists for the fallback. Otherwise always returns a usable rows
 * array (possibly empty with an explicit note).
 */

import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getDomainIntersection, isDataForSeoConfigured, recordCost } from "@/lib/services/dataforseo";
import { callLlm } from "@/lib/ai-avatar/llm-client";
import { logAiInteraction } from "@/lib/services/ai-interaction-log";

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

/** Cache TTL for keyword-overlap responses in SiteEvent rows.
 *  DataForSEO data shifts slowly (weekly SERP scrapes are typical)
 *  and each miss costs ~$0.02, so a 7-day window cuts the repeat-
 *  view cost by orders of magnitude without users seeing meaning-
 *  fully stale data. AI-estimate responses get the same TTL for
 *  consistency; the AI output is deterministic enough that a re-run
 *  in 2 days would return nearly-identical rows anyway. */
const CACHE_TTL_MS = 7 * 86_400_000;
const CACHE_EVENT_TYPE = "keyword_battle_cache";

type OverlapStatus = "winning" | "losing" | "brand_only" | "competitor_only" | "unknown";

interface OverlapRow {
  keyword: string;
  volume: number | null;
  cpc: number | null;
  difficulty: number | null;
  brandPosition: number | null;
  brandUrl: string | null;
  competitorPosition: number | null;
  competitorUrl: string | null;
  status: OverlapStatus;
  serpFeatures: string[];
}

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

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

  const { searchParams } = new URL(req.url);
  const location = searchParams.get("location") ?? "United States";
  const limit = Math.min(parseInt(searchParams.get("limit") ?? "200", 10), 1000);
  // `?fresh=1` bypasses the 7-day cache for users who want to force
  // a re-fetch (e.g. they know the competitor just launched a big
  // content push). Defaults to cached — the cost savings matter more
  // than sub-weekly freshness for the typical user.
  const forceFresh = searchParams.get("fresh") === "1";

  console.info(`[keyword-overlap] request brand=${competitor.brand.domain} competitor=${competitor.domain} location=${location}`);

  // ── Cache read: 7-day SiteEvent lookup ───────────────────────────
  // Keyed by competitorId + location inside eventData so a brand with
  // multiple competitors each has its own cache slot, and a re-query
  // with a different location (?location=United+Kingdom) gets fresh
  // data rather than US-localized rows.
  if (!forceFresh) {
    const cutoff = new Date(Date.now() - CACHE_TTL_MS);
    const cached = await prisma.siteEvent.findFirst({
      where: {
        brandId: competitor.brand.id,
        eventType: CACHE_EVENT_TYPE,
        timestamp: { gte: cutoff },
      },
      orderBy: { timestamp: "desc" },
      select: { eventData: true, timestamp: true },
    }).catch(() => null);
    if (cached) {
      const data = cached.eventData as { competitorId?: string; location?: string; payload?: unknown } | null;
      if (data?.competitorId === params.id && data?.location === location && data.payload) {
        const ageHours = Math.round((Date.now() - cached.timestamp.getTime()) / 3600_000);
        console.info(`[keyword-overlap] cache HIT (${ageHours}h old)`);
        // Annotate the response with cache metadata so the UI can
        // flash a subtle "cached" badge + let users trigger a
        // forced refresh without reading server logs to understand
        // where the data came from.
        return NextResponse.json({
          ...(data.payload as object),
          cached: true,
          cachedAgeHours: ageHours,
        });
      }
    }
    console.info(`[keyword-overlap] cache MISS — fetching fresh`);
  } else {
    console.info(`[keyword-overlap] cache BYPASS — ?fresh=1 was set`);
  }

  /** Cache-write helper. Fire-and-forget so a SiteEvent write failure
   *  (pool pressure, etc.) can't break a successful response. Keyed
   *  by competitorId + location inside eventData; the cutoff-based
   *  read path above then picks the freshest matching row. pageUrl
   *  uses a sentinel value since the column is non-nullable and
   *  cache rows aren't tied to a specific page. */
  const writeCache = (payload: object) => {
    prisma.siteEvent.create({
      data: {
        brandId: competitor.brand.id,
        eventType: CACHE_EVENT_TYPE,
        pageUrl: `internal:keyword-battle-cache`,
        eventData: {
          competitorId: params.id,
          location,
          payload,
          cachedAt: new Date().toISOString(),
        },
      },
    }).catch((err) => {
      console.warn("[keyword-overlap] cache write failed", err instanceof Error ? err.message : err);
    });
    return payload;
  };

  // ── Primary: DataForSEO Domain Intersection ──────────────────────
  if (isDataForSeoConfigured()) {
    try {
      const result = await getDomainIntersection(
        competitor.brand.domain,
        competitor.domain,
        location,
        "English",
        limit,
      );
      if (result) {
        recordCost(result.costUsd);
        console.info(`[dataforseo] intersection ${competitor.brand.domain} vs ${competitor.domain}: rows=${result.rows.length} cost=$${result.costUsd.toFixed(4)}`);
        if (result.rows.length > 0) {
          // Wire-format discriminator stays generic so the response
          // doesn't expose the SERP vendor name to clients.
          return NextResponse.json(writeCache({ ...result, source: "live" }));
        }
        console.info(`[keyword-overlap] DataForSEO returned 0 rows — trying AI fallback`);
      }
    } catch (err) {
      const msg = err instanceof Error ? err.message : "DataForSEO request failed";
      console.error(`[keyword-overlap] DataForSEO failed for ${competitor.domain}:`, msg);
      // Fall through to the AI fallback rather than short-circuiting —
      // an AI estimate is strictly better than a cryptic error banner.
    }
  }

  // ── Fallback: AI estimation from GSC + crawled pages ─────────────
  const fallback = await buildAiFallback(competitor.brand.id, competitor.brand.domain, competitor.id, competitor.domain);
  if (fallback) {
    return NextResponse.json(writeCache(fallback));
  }

  // Nothing to show — DataForSEO returned empty, GSC is empty, or
  // the competitor hasn't been crawled. Return an empty rows array
  // with an explanatory note so the UI renders a clear empty state.
  // NOT cached: empty responses aren't worth persisting, and a future
  // request after more GSC/crawl data lands should immediately produce
  // real rows rather than hit a stale "nothing to show" cache entry.
  return NextResponse.json({
    rows: [],
    totalCount: 0,
    costUsd: 0,
    source: "none",
    note: isDataForSeoConfigured()
      ? "No shared ranking keywords found. The competitor's domain may not be indexed by DataForSEO, and we don't have enough GSC or crawl data for an AI estimate."
      : "DataForSEO not configured and insufficient fallback data available.",
    configured: isDataForSeoConfigured(),
  });
}

/**
 * Fallback path — ask the LLM to identify likely shared keywords
 * given the brand's top GSC queries and the competitor's crawled
 * page titles. Returns null when the inputs are too thin (under 3
 * GSC keywords or under 3 crawled pages) — the caller falls
 * through to the generic empty state.
 */
async function buildAiFallback(
  brandId: string,
  brandDomain: string,
  competitorId: string,
  competitorDomain: string,
): Promise<{ rows: OverlapRow[]; totalCount: number; costUsd: number; source: "ai_estimate" | "ai_estimate_enriched"; enrichedCount: number; note: string } | null> {
  const [brandKeywords, compPages] = await Promise.all([
    prisma.gscQuery.findMany({
      where: { brandId },
      orderBy: { clicks: "desc" },
      take: 50,
      select: { query: true, clicks: true, impressions: true, position: true },
    }),
    prisma.competitorPage.findMany({
      where: { competitorId, status: { notIn: ["removed"] } },
      orderBy: { firstSeenAt: "desc" },
      take: 100,
      select: { url: true, title: true, h1: true },
    }),
  ]);
  if (brandKeywords.length < 3 || compPages.length < 3) {
    console.info(`[keyword-overlap] AI fallback skipped: gsc=${brandKeywords.length} pages=${compPages.length} (need 3+ each)`);
    return null;
  }

  const prompt = `You are analyzing keyword overlap between two websites.

Brand: ${brandDomain}
Brand's top organic keywords from Google Search Console:
${JSON.stringify(brandKeywords.slice(0, 30).map((k) => ({
  keyword: k.query,
  position: Math.round(k.position),
  clicks: k.clicks,
  impressions: k.impressions,
})))}

Competitor: ${competitorDomain}
Competitor's crawled pages (titles + H1s from sitemap):
${JSON.stringify(compPages.slice(0, 50).map((p) => ({
  url: p.url.replace(/^https?:\/\/[^/]+/, ""),
  title: p.title,
})))}

Identify 10-20 keywords BOTH sites likely target. For each:
- keyword: the search query string
- brandPosition: use the brand's GSC position when the keyword matches a GSC query; otherwise null
- competitorPosition: estimate 1-30 based on how prominent the topic is on the competitor's pages; null if unclear
- volume: estimate monthly search volume if you can, otherwise null
- status: "winning" (brandPosition < competitorPosition), "losing" (competitorPosition < brandPosition), "competitor_only" (brand doesn't rank), or "unknown"

Return ONLY a JSON array. No markdown, no prose.`;

  const llm = await callLlm(
    [
      { role: "system", content: "You are an SEO keyword analyst. Respond ONLY with a valid JSON array — no markdown, no commentary." },
      { role: "user", content: prompt },
    ],
    () => "[]",
    { maxTokens: 2500 },
  );

  logAiInteraction({
    brandId,
    feature: "competitor_discovery",
    userPrompt: prompt,
    aiResponse: llm.reply,
    model: llm.model,
    brandDomain,
  });

  let parsed: Array<Record<string, unknown>> = [];
  try {
    let s = llm.reply.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
    const f = s.indexOf("["), l = s.lastIndexOf("]");
    if (f >= 0 && l > f) s = s.slice(f, l + 1);
    const json = JSON.parse(s);
    if (Array.isArray(json)) parsed = json;
  } catch (err) {
    console.error("[keyword-overlap] AI fallback parse failed:", err instanceof Error ? err.message : err);
  }

  const rows: OverlapRow[] = parsed
    .filter((r) => typeof r?.keyword === "string")
    .slice(0, 30)
    .map((r) => {
      const brandPos = typeof r.brandPosition === "number" ? r.brandPosition : null;
      const compPos = typeof r.competitorPosition === "number" ? r.competitorPosition : null;
      let status: OverlapStatus = "unknown";
      if (typeof r.status === "string" && ["winning", "losing", "brand_only", "competitor_only", "unknown"].includes(r.status)) {
        status = r.status as OverlapStatus;
      } else if (brandPos != null && compPos != null) {
        status = brandPos < compPos ? "winning" : "losing";
      } else if (brandPos != null) {
        status = "brand_only";
      } else if (compPos != null) {
        status = "competitor_only";
      }
      return {
        keyword: String(r.keyword).slice(0, 200),
        volume: typeof r.volume === "number" ? r.volume : null,
        cpc: null,
        difficulty: typeof r.difficulty === "number" ? r.difficulty : null,
        brandPosition: brandPos,
        brandUrl: null,
        competitorPosition: compPos,
        competitorUrl: null,
        status,
        serpFeatures: [],
      };
    });

  console.info(`[keyword-overlap] AI fallback: produced ${rows.length} rows from ${brandKeywords.length} GSC queries + ${compPages.length} pages`);

  // ── Enrich from real data sources ─────────────────────────────────
  // The LLM often returns brandPosition=null for keywords the brand
  // actually ranks for (it misses fuzzy matches or non-top-30 queries).
  // Cross-reference every row against GSC data and Rank Tracker before
  // returning. Priority: Rank Tracker > GSC > AI estimate.
  const enrichedCount = await enrichWithRealPositions(brandId, rows);

  return {
    rows,
    totalCount: rows.length,
    costUsd: 0,
    // When the majority of rows got a real position from GSC/Rank
    // Tracker, promote the wire-format label from "ai_estimate" to
    // "ai_estimate_enriched" so the UI can drop the "AI ESTIMATE" pill
    // in favor of "REAL DATA" / "ENRICHED".
    source: enrichedCount >= Math.ceil(rows.length / 2) ? "ai_estimate_enriched" : "ai_estimate",
    enrichedCount,
    note: "Competitor positions are estimated by AI from crawled pages. Your positions are verified from GSC and Rank Tracker data.",
  };
}

/**
 * Enrich AI-estimated rows with real position data from GSC and Rank
 * Tracker. For each row where brandPosition is null, looks for:
 *   1. Exact match in KeywordRanking (Rank Tracker — most accurate)
 *   2. Exact match in GscQuery (GSC average position — real data)
 *   3. Contains-match in GscQuery (fuzzy — e.g. "cardiac ablation"
 *      matches "cardiac ablation houston cost")
 * When a match is found, sets brandPosition and recalculates status.
 */
async function enrichWithRealPositions(brandId: string, rows: OverlapRow[]): Promise<number> {
  const keywords = rows.map((r) => r.keyword.toLowerCase());

  // Batch-fetch Rank Tracker positions for all keywords
  const rankTrackerResults = await prisma.trackedKeyword.findMany({
    where: {
      brandId,
      isActive: true,
      keyword: { in: keywords, mode: "insensitive" },
    },
    select: {
      keyword: true,
      rankings: {
        orderBy: { checkedAt: "desc" },
        take: 1,
        select: { position: true, url: true },
      },
    },
  }).catch(() => []);

  const rankMap = new Map<string, { position: number; url: string | null; source: string }>();
  for (const tk of rankTrackerResults) {
    const pos = tk.rankings[0]?.position;
    if (pos != null) {
      rankMap.set(tk.keyword.toLowerCase(), {
        position: pos,
        url: tk.rankings[0]?.url ?? null,
        source: "tracked",
      });
    }
  }

  // Batch-fetch GSC positions for all keywords (exact match)
  const gscResults = await prisma.gscQuery.findMany({
    where: { brandId, query: { in: keywords, mode: "insensitive" } },
    select: { query: true, position: true, clicks: true },
  }).catch(() => []);

  const gscMap = new Map<string, { position: number; source: string }>();
  for (const g of gscResults) {
    gscMap.set(g.query.toLowerCase(), {
      position: Math.round(g.position),
      source: "gsc",
    });
  }

  // For keywords not found in exact GSC match, try partial match
  const unmatchedKeywords = keywords.filter((k) => !rankMap.has(k) && !gscMap.has(k));
  if (unmatchedKeywords.length > 0) {
    for (const kw of unmatchedKeywords) {
      const partial = await prisma.gscQuery.findFirst({
        where: {
          brandId,
          query: { contains: kw, mode: "insensitive" },
        },
        orderBy: { clicks: "desc" },
        select: { query: true, position: true },
      }).catch(() => null);
      if (partial) {
        gscMap.set(kw, { position: Math.round(partial.position), source: "gsc" });
      }
    }
  }

  // Apply enrichment to rows
  let enriched = 0;
  for (const row of rows) {
    const kw = row.keyword.toLowerCase();
    const rank = rankMap.get(kw);
    const gsc = gscMap.get(kw);

    // Priority: Rank Tracker > GSC > existing (AI estimate)
    if (rank && (row.brandPosition == null || row.brandPosition === 0)) {
      row.brandPosition = rank.position;
      row.brandUrl = rank.url;
      enriched++;
    } else if (gsc && (row.brandPosition == null || row.brandPosition === 0)) {
      row.brandPosition = gsc.position;
      enriched++;
    }

    // Recalculate status based on updated positions
    if (row.brandPosition != null && row.competitorPosition != null) {
      row.status = row.brandPosition < row.competitorPosition ? "winning" : "losing";
    } else if (row.brandPosition != null) {
      row.status = "brand_only";
    } else if (row.competitorPosition != null) {
      row.status = "competitor_only";
    } else {
      row.status = "unknown";
    }
  }

  if (enriched > 0) {
    console.info(`[keyword-overlap] enriched ${enriched} rows with real GSC/Rank Tracker positions`);
  }
  return enriched;
}
