/**
 * Conversion Resolver — write-time dedup + tier-upgrade logic
 * ────────────────────────────────────────────────────────────
 * The same real-world action fires multiple events through the Site
 * Tag pipeline:
 *
 *   User clicks "Schedule Appointment"   → appointment_intent       (T2)
 *   Amelia widget opens                  → booking_widget_opened    (T2)
 *   User focuses a field                 → form_start               (T2)
 *   gtag fires book_appointment2025      → booking_submit           (T2)
 *   Amelia AJAX intercept fires          → booking_submit           (T2)
 *   Amelia confirmation panel renders    → booking_confirmed        (T1)
 *
 * Without dedup that's 6 SiteConversion rows for ONE conversion. The
 * resolver collapses them to a single row at write time:
 *
 *   1. WITHIN A 30-SECOND WINDOW per (brand, session, page) keep only
 *      the HIGHEST TIER (T1 wins over T2).
 *   2. ACROSS SOURCES for the same canonical type, take the most
 *      authoritative (gtag intercept > native plugin > native form).
 *      The losing source is recorded in mergedSources for attribution.
 *
 * The resolver exposes one function — resolveConversion() — that the
 * collect-side writer calls before a SiteConversion.create. It either
 * returns an action plan ({ create | upgrade | skip }) or executes the
 * upgrade itself when an existing record needs its conversionType
 * promoted to a higher tier.
 *
 * Source-of-truth dedup window
 * ────────────────────────────
 * 30 seconds matches the spec; longer than the existing 5-second
 * t.js-side dedup so it catches the slower booking flows (Amelia's
 * AJAX submit can land 8-15 seconds after the gtag event).
 */

import { prisma } from "@/lib/prisma";
import type { Prisma } from "@prisma/client";
import {
  canonicalConversionType,
  getConversionTier,
  type CanonicalConversionType,
} from "@/lib/conversion-taxonomy";
import { normalizePageUrl } from "@/lib/url-utils";

// ─── Source authority ranking ───────────────────────────────────
//
// When two events resolve to the SAME canonical type within the
// dedup window we take the more authoritative source. Higher number
// wins. Anything not in this map gets weight 0 (least authoritative).

const SOURCE_AUTHORITY: Record<string, number> = {
  // gtag intercept is the most authoritative — it represents the
  // brand's OWN configured conversion event. If they're firing it,
  // they meant to count it.
  "gtag_intercept": 100,
  // Plugin-specific detection (postMessage from Calendly, AJAX from
  // Amelia, gform_confirmation_loaded from Gravity Forms, etc.). The
  // plugin knows what it's doing — high authority.
  "amelia": 80,
  "calendly": 80,
  "nexhealth": 80,
  "acuity": 80,
  "jotform": 80,
  "typeform": 80,
  "wpforms": 80,
  "gravityforms": 80,
  "contactform7": 80,
  "hubspot": 80,
  // Native t.js detection — generic listener that may pick up
  // unrelated actions. Lowest plausible authority.
  "native": 30,
  "tel_link": 30,
  "phone_pattern": 20,
  "fetch_intercept": 10,
  "xhr_intercept": 10,
};

function sourceAuthority(source: string | null | undefined): number {
  if (!source) return 0;
  return SOURCE_AUTHORITY[source.toLowerCase()] ?? 0;
}

// ─── Resolver input + output ────────────────────────────────────

export interface ResolveInput {
  brandId: string;
  sessionId: string | null;
  /** The visitor's localStorage["_meseo_vid"] value. Shared across both write
   *  paths (t.source.js and form-phone.js) unlike sessionId which uses
   *  different sessionStorage keys per script. Used for form dedup fallback. */
  visitorId?: string | null;
  pageUrl: string;
  /** The canonical OR legacy / raw conversion type. canonicalConversionType
   *  is applied internally so the caller can pass any of:
   *    - "appointment_booked" (canonical)
   *    - "form_submission" / "booking_attempt" (legacy storage)
   *    - "click_to_call" / "form_submit" (raw t.js eventType)
   *    - "gtag:contact_us" / "amelia:booking_confirmed" (raw source)
   */
  rawType: string;
  /** When the caller already knows the source (e.g. metadata.source =
   *  "gtag_intercept" / "amelia" / "native"), pass it so the
   *  authority comparison works. Null is safe — defaults to weight 0. */
  source: string | null;
  /** Original event name (for source attribution + reconciliation). */
  originalEvent?: string | null;
  /** Event timestamp — drives the dedup window. */
  timestamp: Date;
  /** Client-generated idempotency token from getFormSubmissionId(). When
   *  present, used as the primary dedup key instead of sessionId+pageUrl+window. */
  submissionId?: string | null;
  /** True when a framework-specific success hook corroborated this submit
   *  (CF7 wpcf7mailsent, gform_confirmation_loaded, WPForms confirmation
   *  container, Elementor submit_success, etc.). When true the resolver
   *  sets successConfirmed=true on the written or upgraded row. */
  successConfirmed?: boolean;
}

export type ResolveAction =
  | { kind: "create"; conversionType: CanonicalConversionType; tier: 1 | 2; invalidatedReason?: string; duplicateOf?: string; successConfirmed?: boolean }
  | { kind: "upgrade"; existingId: string; conversionType: CanonicalConversionType; tier: 1 | 2; from: string; fromTier: 1 | 2; successConfirmed?: boolean }
  | { kind: "skip"; reason: "duplicate" | "lower_tier" | "lower_authority" | "unknown_type" };

function utcDayBounds(ts: Date): { dayStart: Date; dayEnd: Date } {
  const dayStart = new Date(Date.UTC(ts.getUTCFullYear(), ts.getUTCMonth(), ts.getUTCDate()));
  return { dayStart, dayEnd: new Date(dayStart.getTime() + 86_400_000) };
}

const DEDUP_WINDOW_MS = 30_000;
const PHONE_DEDUP_WINDOW_MS = 60_000;
const BOOKING_DEDUP_WINDOW_MS = 90_000; // 90s — absorbs cross-path latency without collapsing back-to-back bookings
// 5 minutes — absorbs the full cross-path latency for native form submits
// written by two independent listeners with different sessionStorage keys.
const FORM_DEDUP_WINDOW_MS = 300_000;

/**
 * Check whether a phone_call row already exists for this visitor + digits
 * within a 60s window. Returns the existing row's id when found (the caller
 * should insert the new row with isDuplicateOf set to that id), or null when
 * the new row is the first (safe to insert with isDuplicateOf = null).
 *
 * Key: (brandId, visitorId, normalizedPhoneDigits, +-60s). visitorId is used
 * because it is the only field reliably shared across both write paths
 * (t.source.js->event and form-phone.js->conversion use different sessionStorage
 * keys but the same localStorage key for visitorId).
 *
 * Guards: if visitorId is "anon" or phoneDigits is shorter than 7 digits the
 * check is skipped and null is returned (safe to insert; the hourly dedup cron
 * will catch any residual duplicates).
 */
export async function findPhoneDuplicateId(
  brandId: string,
  visitorId: string,
  phoneDigits: string,
  timestamp: Date,
  tx?: TxClient,
): Promise<string | null> {
  if (!phoneDigits || phoneDigits.length < 7) return null;
  if (!visitorId || visitorId === "anon") return null;
  const db = tx ?? prisma;
  const windowStart = new Date(timestamp.getTime() - PHONE_DEDUP_WINDOW_MS);
  const windowEnd   = new Date(timestamp.getTime() + PHONE_DEDUP_WINDOW_MS);
  try {
    const existing = await db.siteConversion.findFirst({
      where: {
        brandId,
        visitorId,
        phoneNumber: phoneDigits,
        isDuplicateOf: null,
        timestamp: { gte: windowStart, lte: windowEnd },
      },
      select: { id: true },
      orderBy: { timestamp: "asc" },
    });
    return existing?.id ?? null;
  } catch (err) {
    console.warn("[conversion-resolver] phone dedup lookup failed -- falling through", {
      err: err instanceof Error ? err.message : "unknown",
    });
    return null;
  }
}

/**
 * Check whether an appointment_booked row already exists for this visitor +
 * provider within a 5-minute window. Returns the existing row's id when found
 * (the caller should insert the new row with isDuplicateOf set to that id),
 * or null when the new row is the first.
 *
 * Key: (brandId, visitorId, "appointment_booked", formProvider, +-5min).
 * visitorId is used because it is the only identifier reliably shared across
 * both write paths (t.source.js->event and form-phone.js->conversion use
 * different sessionStorage keys but the same localStorage key for visitorId).
 * formProvider (e.g. "calendly") is included when available to prevent false
 * dedup if a visitor books via two different providers in the same window.
 *
 * Guard: if visitorId is "anon" the check is skipped and null is returned
 * (safe to insert; the hourly dedup cron will catch any residual duplicates).
 */
export async function findBookingDuplicateId(
  brandId: string,
  visitorId: string,
  formProvider: string | null,
  timestamp: Date,
  tx?: TxClient,
): Promise<string | null> {
  if (!visitorId || visitorId === "anon") return null;
  const db = tx ?? prisma;
  const windowStart = new Date(timestamp.getTime() - BOOKING_DEDUP_WINDOW_MS);
  const windowEnd   = new Date(timestamp.getTime() + BOOKING_DEDUP_WINDOW_MS);
  try {
    const existing = await db.siteConversion.findFirst({
      where: {
        brandId,
        visitorId,
        conversionType: "appointment_booked",
        ...(formProvider ? { formProvider } : {}),
        isDuplicateOf: null,
        timestamp: { gte: windowStart, lte: windowEnd },
      },
      select: { id: true },
      orderBy: { timestamp: "asc" },
    });
    return existing?.id ?? null;
  } catch (err) {
    console.warn("[conversion-resolver] booking dedup lookup failed -- falling through", {
      err: err instanceof Error ? err.message : "unknown",
    });
    return null;
  }
}

/**
 * Check whether a form_submitted row already exists for this visitor +
 * normalised page path on the same UTC calendar day. Returns the existing
 * row's id when found, or null when the new row is the first.
 *
 * Key: (brandId, visitorId, normalizePageUrl(pageUrl), same UTC calendar day).
 * Matches CRM per-lead dedup: one lead per person per contact-page per day.
 * Different paths remain separate; anonymous visitors are excluded (tight
 * session-level dedup already covers them).
 *
 * Guard: if visitorId is "anon" the check is skipped and null is returned.
 */
export async function findFormSubmitDuplicateId(
  brandId: string,
  visitorId: string,
  pageUrl: string,
  timestamp: Date,
  tx?: TxClient,
): Promise<string | null> {
  if (!visitorId || visitorId === "anon") return null;
  const db = tx ?? prisma;
  const { dayStart, dayEnd } = utcDayBounds(timestamp);
  const normalizedPath = normalizePageUrl(pageUrl);
  try {
    const candidates = await db.siteConversion.findMany({
      where: {
        brandId,
        visitorId,
        conversionType: "form_submitted",
        isDuplicateOf: null,
        timestamp: { gte: dayStart, lt: dayEnd },
      },
      select: { id: true, pageUrl: true },
      orderBy: { timestamp: "asc" },
    });
    const match = candidates.find((r) => normalizePageUrl(r.pageUrl) === normalizedPath);
    return match?.id ?? null;
  } catch (err) {
    console.warn("[conversion-resolver] form dedup lookup failed -- falling through", {
      err: err instanceof Error ? err.message : "unknown",
    });
    return null;
  }
}

/**
 * Decide what to do with a candidate conversion event.
 *
 *   create        — no overlapping row in the dedup window; safe to insert.
 *   upgrade       — overlapping row exists at a LOWER tier; the resolver
 *                   has already mutated the existing row's conversionType
 *                   to the higher canonical type. Caller must NOT insert.
 *   skip          — overlapping row at the same or higher tier wins.
 *
 * The resolver is fire-and-forget safe — every prisma call is wrapped
 * so a transient DB error returns "create" rather than blocking a
 * real conversion.
 */
type TxClient = Omit<typeof prisma, "$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends">;

export async function resolveConversion(input: ResolveInput, tx?: TxClient): Promise<ResolveAction> {
  const db = tx ?? prisma;
  const canonical = canonicalConversionType(input.rawType);
  if (!canonical) return { kind: "skip", reason: "unknown_type" };
  const tier = getConversionTier(canonical);
  if (!tier) return { kind: "skip", reason: "unknown_type" };

  const sessionKey = input.sessionId ?? "anon";
  const windowStart = new Date(input.timestamp.getTime() - DEDUP_WINDOW_MS);
  const windowEnd = new Date(input.timestamp.getTime() + DEDUP_WINDOW_MS);

  let existing: { id: string; conversionType: string; metadata: unknown } | null = null;
  try {
    if (input.submissionId) {
      // Primary dedup: submissionId is the client idempotency token -- no time window needed.
      existing = await db.siteConversion.findFirst({
        where: { brandId: input.brandId, submissionId: input.submissionId },
        select: { id: true, conversionType: true, formFields: true },
        orderBy: { timestamp: "desc" },
      }).then((r) => r ? { id: r.id, conversionType: r.conversionType, metadata: r.formFields } : null);
    }

    // Form-specific fallback: same-calendar-day dedup for known visitors.
    // Key: (brandId, visitorId, normalizePageUrl, same UTC day). Matches
    // CRM per-lead semantics -- one counted lead per person per page per day.
    // Different paths stay separate; success signals (successConfirmed=true)
    // are excluded here so the path-agnostic upgrade check below still fires.
    // Both write paths share localStorage["_meseo_vid"]; session-keyed dedup
    // below handles same-session cross-detection within the 30s window.
    if (!existing && canonical === "form_submitted" && !input.successConfirmed) {
      const visitorKey = input.visitorId ?? null;
      if (visitorKey && visitorKey !== "anon") {
        const normalizedPath = normalizePageUrl(input.pageUrl);
        const { dayStart, dayEnd } = utcDayBounds(input.timestamp);
        const candidates = await db.siteConversion.findMany({
          where: {
            brandId: input.brandId,
            visitorId: visitorKey,
            conversionType: "form_submitted",
            isDuplicateOf: null,
            timestamp: { gte: dayStart, lt: dayEnd },
          },
          select: { id: true, pageUrl: true },
          orderBy: { timestamp: "asc" },
        });
        const match = candidates.find((r) => normalizePageUrl(r.pageUrl) === normalizedPath);
        if (match) {
          // Write the row as a marked duplicate so the submission is recoverable,
          // but isDuplicateOf excludes it from every user-facing count.
          return {
            kind: "create",
            conversionType: canonical,
            tier,
            duplicateOf: match.id,
            invalidatedReason: "same_visitor_same_path_day",
            successConfirmed: false,
          };
        }
      }
    }

    // Path-agnostic success signal: a thankyou_nav / CF7 / etc. fires on the
    // thank-you URL, a DIFFERENT path than the original submit. The path-keyed
    // check above won't find it. When successConfirmed=true and we still have
    // no match, look for any recent form_submitted by this visitor across ALL
    // paths within the 5-minute window and upgrade it rather than create a
    // second row.
    if (!existing && input.successConfirmed && canonical === "form_submitted") {
      const visitorKey = input.visitorId ?? null;
      if (visitorKey && visitorKey !== "anon") {
        const formWindowStart = new Date(input.timestamp.getTime() - FORM_DEDUP_WINDOW_MS);
        const formWindowEnd   = new Date(input.timestamp.getTime() + FORM_DEDUP_WINDOW_MS);
        try {
          const anySubmit = await db.siteConversion.findFirst({
            where: {
              brandId: input.brandId,
              visitorId: visitorKey,
              conversionType: "form_submitted",
              isDuplicateOf: null,
              invalidatedReason: null,
              timestamp: { gte: formWindowStart, lte: formWindowEnd },
            },
            select: { id: true, conversionType: true, formFields: true },
            orderBy: { timestamp: "desc" },
          });
          if (anySubmit) {
            existing = { id: anySubmit.id, conversionType: anySubmit.conversionType, metadata: anySubmit.formFields };
          }
        } catch { /* fall through — next block will create */ }
      }
    }

    if (!existing) {
      // Tertiary fallback: session+pageUrl+30s window. Covers same-session
      // duplicate events (e.g. two t.source.js firings) and providers that
      // don't carry submissionId but share a session. Cannot collapse the
      // t.source.js / form-phone.js cross-path case (different sessionIds);
      // that is handled by the form-specific visitorId check above.
      existing = await db.siteConversion.findFirst({
        where: {
          brandId: input.brandId,
          sessionId: sessionKey,
          pageUrl: input.pageUrl,
          timestamp: { gte: windowStart, lte: windowEnd },
        },
        select: { id: true, conversionType: true, formFields: true },
        orderBy: { timestamp: "desc" },
      }).then((r) => r ? { id: r.id, conversionType: r.conversionType, metadata: r.formFields } : null);
    }
  } catch (err) {
    console.warn("[conversion-resolver] dedup lookup failed — falling through to create", {
      err: err instanceof Error ? err.message : "unknown",
    });
    return { kind: "create", conversionType: canonical, tier, successConfirmed: input.successConfirmed ?? false };
  }

  if (!existing) {
    // gtag_intercept form_submitted with no corroborating form interaction:
    // store the row but mark it so counted paths exclude it. A corroborating
    // row is any form_submitted (non-gtag) or form_filled from the same
    // visitor + page within the 5-minute window.
    if (input.source === "gtag_intercept" && canonical === "form_submitted") {
      const visitorKey = input.visitorId ?? null;
      let corroborated = false;
      if (visitorKey && visitorKey !== "anon") {
        const normalizedPath = normalizePageUrl(input.pageUrl);
        const corrWindowStart = new Date(input.timestamp.getTime() - FORM_DEDUP_WINDOW_MS);
        const corrWindowEnd   = new Date(input.timestamp.getTime() + FORM_DEDUP_WINDOW_MS);
        try {
          const corrCandidates = await db.siteConversion.findMany({
            where: {
              brandId: input.brandId,
              visitorId: visitorKey,
              conversionType: { in: ["form_submitted", "form_filled"] },
              formProvider: { not: "gtag_intercept" },
              isDuplicateOf: null,
              timestamp: { gte: corrWindowStart, lte: corrWindowEnd },
            },
            select: { pageUrl: true },
          });
          corroborated = corrCandidates.some(
            (r) => normalizePageUrl(r.pageUrl) === normalizedPath,
          );
        } catch {
          // Lookup failure — treat as corroborated so we don't mark a real
          // lead as uncorroborated due to a transient DB error.
          corroborated = true;
        }
      }
      if (!corroborated) {
        return { kind: "create", conversionType: canonical, tier, invalidatedReason: "uncorroborated_gtag", successConfirmed: input.successConfirmed ?? false };
      }
    }
    return { kind: "create", conversionType: canonical, tier, successConfirmed: input.successConfirmed ?? false };
  }

  const existingCanonical = canonicalConversionType(existing.conversionType);
  const existingTier = existingCanonical ? getConversionTier(existingCanonical) : null;

  // If the existing row isn't a recognised type, treat the new event
  // as authoritative and create — defensive against legacy garbage.
  if (!existingCanonical || !existingTier) {
    return { kind: "create", conversionType: canonical, tier, successConfirmed: input.successConfirmed ?? false };
  }

  // If new is HIGHER tier, upgrade the existing row in-place.
  if (tier < existingTier) {
    try {
      // Stamp the upgrade history into the formFields JSON so the
      // attribution + reconciliation surfaces can see it. formFields
      // is an array historically but the schema is loose Json — we
      // upgrade it to a richer object form when the resolver touches it.
      const oldMeta = (existing.metadata ?? {}) as Record<string, unknown>;
      const newMeta = {
        ...oldMeta,
        _upgradedFrom: existing.conversionType,
        _upgradedFromTier: existingTier,
        _upgradedTo: canonical,
        _upgradedToTier: tier,
        _upgradedAt: input.timestamp.toISOString(),
        _upgradedBySource: input.source ?? null,
        _upgradedByOriginal: input.originalEvent ?? null,
      };
      await db.siteConversion.update({
        where: { id: existing.id },
        data: {
          conversionType: canonical,
          formFields: JSON.parse(JSON.stringify(newMeta)),
          ...(input.successConfirmed ? { successConfirmed: true } : {}),
        },
      });
      return {
        kind: "upgrade",
        existingId: existing.id,
        conversionType: canonical,
        tier,
        from: existing.conversionType,
        fromTier: existingTier,
        successConfirmed: input.successConfirmed ?? false,
      };
    } catch (err) {
      console.warn("[conversion-resolver] upgrade failed — falling through to create", {
        err: err instanceof Error ? err.message : "unknown",
      });
      return { kind: "create", conversionType: canonical, tier };
    }
  }

  // Same tier — take the more authoritative source. If new wins,
  // upgrade the row's source attribution but leave the type alone.
  if (tier === existingTier) {
    const oldMeta = (existing.metadata ?? {}) as Record<string, unknown>;
    const oldSource = typeof oldMeta._source === "string" ? oldMeta._source : null;
    const newAuth = sourceAuthority(input.source);
    const oldAuth = sourceAuthority(oldSource);
    if (newAuth > oldAuth) {
      try {
        const newMeta = {
          ...oldMeta,
          _source: input.source ?? null,
          _originalEvent: input.originalEvent ?? null,
          _mergedSources: [...((oldMeta._mergedSources as string[]) ?? []), oldSource].filter(Boolean),
        };
        await db.siteConversion.update({
          where: { id: existing.id },
          data: {
            formFields: JSON.parse(JSON.stringify(newMeta)),
            ...(input.successConfirmed ? { successConfirmed: true } : {}),
          },
        });
      } catch (err) {
        // Source-attribution upgrade is informational; ignore failures.
        console.warn("[conversion-resolver] source attribution update failed", {
          err: err instanceof Error ? err.message : "unknown",
        });
      }
    } else {
      // Existing wins on authority — record this source as a merged
      // candidate so the reconciliation report can see it was deduped.
      try {
        const merged = [...((oldMeta._mergedSources as string[]) ?? []), input.source].filter(Boolean);
        await db.siteConversion.update({
          where: { id: existing.id },
          data: {
            formFields: JSON.parse(JSON.stringify({ ...oldMeta, _mergedSources: merged })),
            ...(input.successConfirmed ? { successConfirmed: true } : {}),
          },
        });
      } catch { /* best-effort */ }
    }
    return { kind: "skip", reason: existingTier === tier ? "lower_authority" : "duplicate" };
  }

  // New tier > existing tier (i.e. tier-2 incoming when a tier-1 is
  // already there). Skip — never downgrade.
  return { kind: "skip", reason: "lower_tier" };
}

// ─── Helper used by KPI rollups + analytics ─────────────────────
//
// Given a stored SiteConversion.conversionType, return whether it
// counts as a tier-1 conversion. Wraps canonicalConversionType +
// getConversionTier into a single boolean.

export function isHardConversion(stored: string): boolean {
  return getConversionTier(stored) === 1;
}

export function isSoftConversion(stored: string): boolean {
  return getConversionTier(stored) === 2;
}
