/**
 * AI Signals — shared persistence + LLM parsing helpers
 * ──────────────────────────────────────────────────────
 * Two crons (signals-site-tag-ai, signals-competitor-regional) generate
 * signals by sending per-brand data snapshots to Claude and parsing the
 * JSON-array response. Both share three pieces of machinery:
 *
 *   1. Persistence via SiteEvent (no DetectedSignal table in this repo —
 *      eventType="signal_ai_*", eventData carries the full payload).
 *   2. 24h dedup via a contentHash stored in eventData (hash covers
 *      brandId + source + signalType + affectedUrl + competitorName).
 *   3. Strict JSON-array parsing with markdown-fence stripping so the
 *      AI-generated response doesn't crash the cron on a stray ```json.
 *
 * The spec referenced a DetectedSignal model with source / suggestedAction
 * / affectedUrl / contentHash columns. Those map 1:1 to eventData keys
 * here — no schema migration needed; signals.ts surfaces them back up
 * into the Signal shape the Signals Center already renders.
 */

import { createHash } from "crypto";
import { prisma } from "@/lib/prisma";
import { callLlm } from "@/lib/ai-avatar/llm-client";

// ─── Types ───────────────────────────────────────────────────────────────────

export type AiSignalSource = "site-tag-ai" | "competitor-regional-ai";

export interface AiSignal {
  title: string;
  description: string;
  category: "issue" | "opportunity" | "win" | "revenue";
  priority: "critical" | "high" | "medium" | "low";
  /** Short machine-readable label, e.g. "missing_meta", "form_abandonment". */
  signalType: string;
  suggestedAction?: string;
  affectedUrl?: string;
  competitorName?: string;
  /** Extra detail the AI returned — kept verbatim for post-hoc debugging. */
  metadata?: Record<string, unknown>;
}

export interface WriteAiSignalsSummary {
  created: number;
  deduped: number;
}

export interface AiAnalysisResult {
  signals: AiSignal[];
  tokensUsed?: number;
  rawReplyPreview?: string;
}

// ─── Persistence (writes SiteEvent rows) ────────────────────────────────────

/** Maps a source to the SiteEvent.eventType we persist under. */
function eventTypeFor(source: AiSignalSource): string {
  return source === "site-tag-ai" ? "signal_ai_sitetag" : "signal_ai_competitor";
}

/** Short hash identifying a "same signal" for 24h dedup. */
function hashOf(
  brandId: string,
  source: AiSignalSource,
  signalType: string,
  affectedUrl: string | undefined,
  competitorName: string | undefined,
): string {
  const h = createHash("sha1");
  h.update(`${brandId}|${source}|${signalType}|${affectedUrl ?? ""}|${competitorName ?? ""}`);
  return h.digest("hex").slice(0, 16);
}

/**
 * Persist a batch of AI-generated signals. Dedups against SiteEvent rows
 * written for the same brand + source in the last 24h (by contentHash).
 * Returns counts so the cron can log + summarise.
 */
export async function writeAiSignals(
  brandId: string,
  source: AiSignalSource,
  signals: AiSignal[],
): Promise<WriteAiSignalsSummary> {
  if (signals.length === 0) return { created: 0, deduped: 0 };
  const eventType = eventTypeFor(source);
  const dayAgo = new Date(Date.now() - 86_400_000);

  // Load the set of hashes already persisted in the dedup window so we
  // can skip inserting duplicates. Cheaper than per-row pre-check queries.
  const existing = await prisma.siteEvent.findMany({
    where: { brandId, eventType, timestamp: { gte: dayAgo } },
    select: { eventData: true },
  });
  const existingHashes = new Set<string>();
  for (const r of existing) {
    const d = (r.eventData ?? {}) as Record<string, unknown>;
    if (typeof d.contentHash === "string") existingHashes.add(d.contentHash);
  }

  let created = 0;
  let deduped = 0;
  for (const s of signals) {
    const contentHash = hashOf(brandId, source, s.signalType, s.affectedUrl, s.competitorName);
    if (existingHashes.has(contentHash)) { deduped++; continue; }
    try {
      await prisma.siteEvent.create({
        data: {
          brandId,
          // pageUrl is required (non-null in the schema), so fall back to
          // empty string when the AI didn't attribute the signal to a URL.
          pageUrl: s.affectedUrl ?? "",
          eventType,
          eventData: {
            source,
            signalType: s.signalType,
            title: s.title,
            description: s.description,
            severity: normalisePriority(s.priority),
            category: s.category,
            suggestedAction: s.suggestedAction ?? null,
            affectedUrl: s.affectedUrl ?? null,
            competitorName: s.competitorName ?? null,
            metadata: s.metadata ?? {},
            contentHash,
            status: "new",
          } as object,
        },
      });
      created++;
      existingHashes.add(contentHash); // prevent duplicates within the same batch
    } catch (err) {
      console.error(`[ai-signals] persist failed for brand ${brandId}:`, err instanceof Error ? err.message : err);
    }
  }
  return { created, deduped };
}

function normalisePriority(p: string): "critical" | "high" | "medium" | "low" {
  if (p === "critical" || p === "high" || p === "medium" || p === "low") return p;
  return "medium";
}

// ─── LLM call + JSON parsing ────────────────────────────────────────────────

/**
 * Call Claude with a system prompt + a JSON data payload, parse the reply
 * as a JSON array of AiSignal-shaped objects. Guards against three common
 * failure modes:
 *   1. Markdown code fences around the JSON (```json … ```)
 *   2. Preamble text before the array ("Here are the signals:")
 *   3. Invalid or partial JSON — caught, logged, empty array returned
 *
 * Each entry is schema-validated (title + description required, enum
 * members for category / priority). Invalid entries are dropped, not
 * coerced, so a hallucinated field never ends up in the DB.
 */
export async function callAiForSignals(params: {
  systemPrompt: string;
  userPayload: object;
  brandIdForLog: string;
  maxTokens?: number;
}): Promise<AiAnalysisResult> {
  const { systemPrompt, userPayload, brandIdForLog, maxTokens = 2000 } = params;

  const result = await callLlm(
    [
      { role: "system", content: systemPrompt },
      { role: "user", content: `Data: ${JSON.stringify(userPayload)}` },
    ],
    () => "[]", // mock fallback — empty array means "no signals" in this pipeline
    { maxTokens },
  );

  const tokensUsed = result.tokensUsed;
  const rawReplyPreview = result.reply.slice(0, 300);

  // Strip a Markdown code fence if the model wrapped the response, then
  // try to recover a JSON array even if there's preamble before [ ... ].
  let text = result.reply.trim();
  text = text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
  const firstBracket = text.indexOf("[");
  const lastBracket = text.lastIndexOf("]");
  if (firstBracket >= 0 && lastBracket > firstBracket) {
    text = text.slice(firstBracket, lastBracket + 1);
  }

  let parsed: unknown;
  try {
    parsed = JSON.parse(text);
  } catch (err) {
    console.error(
      `[ai-signals] invalid JSON for brand ${brandIdForLog}:`,
      err instanceof Error ? err.message : err,
      "reply head:", rawReplyPreview,
    );
    return { signals: [], tokensUsed, rawReplyPreview };
  }

  if (!Array.isArray(parsed)) {
    console.error(`[ai-signals] AI returned non-array for brand ${brandIdForLog}, reply head:`, rawReplyPreview);
    return { signals: [], tokensUsed, rawReplyPreview };
  }

  const ALLOWED_CATEGORY = new Set(["issue", "opportunity", "win", "revenue"]);
  const ALLOWED_PRIORITY = new Set(["critical", "high", "medium", "low"]);
  const signals: AiSignal[] = [];
  for (const item of parsed) {
    if (!item || typeof item !== "object") continue;
    const o = item as Record<string, unknown>;
    if (typeof o.title !== "string" || typeof o.description !== "string") continue;
    const category = typeof o.category === "string" && ALLOWED_CATEGORY.has(o.category)
      ? (o.category as AiSignal["category"])
      : "issue";
    const priority = typeof o.priority === "string" && ALLOWED_PRIORITY.has(o.priority)
      ? (o.priority as AiSignal["priority"])
      : "medium";
    signals.push({
      title: o.title.slice(0, 200),
      description: o.description.slice(0, 600),
      category,
      priority,
      signalType: typeof o.signalType === "string" ? o.signalType : "ai_insight",
      suggestedAction: typeof o.suggestedAction === "string" ? o.suggestedAction.slice(0, 600) : undefined,
      affectedUrl: typeof o.affectedUrl === "string" ? o.affectedUrl.slice(0, 500) : undefined,
      competitorName: typeof o.competitorName === "string" ? o.competitorName.slice(0, 100) : undefined,
      metadata: o,
    });
  }

  return { signals, tokensUsed, rawReplyPreview };
}
