import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import db from "../db.server";
import { adminLog } from "../lib/admin-logger.server";

// CDC LQ-08 — non-blocking SEO decision tracking via navigator.sendBeacon
// Public endpoint (no Shopify auth wrapper) — called cross-origin from storefront.
// CORS: simple request (Content-Type text/plain set implicitly by sendBeacon's
// untyped Blob), no preflight required for the POST itself, but we still expose
// an OPTIONS handler defensively.

const SHOP_DOMAIN_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i;

const MAX_URL_LEN = 2000;
const MAX_PATH_LEN = 2000;
const MAX_FIELD_LEN = 200;

const RATE_LIMIT_MAX = 60; // requests
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute sliding window

// Per-instance in-memory sliding window. Note: Railway can run multiple
// instances, so this is best-effort anti-abuse, not a strict global limit.
const rateBuckets = new Map<string, number[]>();

function corsHeaders(extra: Record<string, string> = {}): HeadersInit {
  return {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Access-Control-Allow-Headers": "content-type",
    "Access-Control-Max-Age": "86400",
    ...extra,
  };
}

function noContent(): Response {
  return new Response(null, { status: 204, headers: corsHeaders() });
}

function methodNotAllowed(): Response {
  return new Response(null, {
    status: 405,
    headers: corsHeaders({ Allow: "POST, OPTIONS" }),
  });
}

function badRequest(): Response {
  return new Response(null, { status: 400, headers: corsHeaders() });
}

function checkRateLimit(shop: string): boolean {
  const now = Date.now();
  const cutoff = now - RATE_LIMIT_WINDOW_MS;
  const bucket = rateBuckets.get(shop) ?? [];
  // Drop expired entries
  let i = 0;
  while (i < bucket.length && bucket[i] < cutoff) i++;
  const fresh = i > 0 ? bucket.slice(i) : bucket;
  if (fresh.length >= RATE_LIMIT_MAX) {
    rateBuckets.set(shop, fresh);
    return false;
  }
  fresh.push(now);
  rateBuckets.set(shop, fresh);
  return true;
}

// Handle preflight + GET (returns 405)
export const loader = async ({ request }: LoaderFunctionArgs) => {
  if (request.method === "OPTIONS") {
    return new Response(null, { status: 204, headers: corsHeaders() });
  }
  return methodNotAllowed();
};

export const action = async ({ request }: ActionFunctionArgs) => {
  if (request.method === "OPTIONS") {
    return new Response(null, { status: 204, headers: corsHeaders() });
  }
  if (request.method !== "POST") {
    return methodNotAllowed();
  }

  // Read body as text — sendBeacon defaults Content-Type to text/plain;
  // do not rely on Remix JSON parsing.
  let body: unknown;
  try {
    const raw = await request.text();
    if (!raw) return badRequest();
    body = JSON.parse(raw);
  } catch {
    return badRequest();
  }

  if (!body || typeof body !== "object") return badRequest();
  const data = body as Record<string, unknown>;

  const shop = typeof data.shop === "string" ? data.shop : "";
  if (!shop || !SHOP_DOMAIN_REGEX.test(shop)) {
    return badRequest();
  }

  const url = typeof data.url === "string" ? data.url : "";
  const path = typeof data.path === "string" ? data.path : "";
  if (!url || !path) return badRequest();

  if (url.length > MAX_URL_LEN || path.length > MAX_PATH_LEN) {
    return badRequest();
  }

  const optStr = (k: string): string | null => {
    const v = data[k];
    if (v === undefined || v === null || v === "") return null;
    if (typeof v !== "string") return null;
    if (v.length > MAX_FIELD_LEN) return null; // sentinel: caller checks for `null` after collection
    return v;
  };

  // Pre-validate optional string lengths: if any string field exceeds
  // MAX_FIELD_LEN, reject 400 (per CDC). We can't distinguish "absent" from
  // "too-long" through optStr alone, so re-check raw values here.
  for (const k of [
    "template",
    "ruleKey",
    "ruleCategory",
    "robotsValue",
    "canonicalUrl",
    "reason",
  ]) {
    const v = data[k];
    if (typeof v === "string" && v.length > MAX_FIELD_LEN) {
      return badRequest();
    }
  }

  const template = optStr("template");
  const ruleKey = optStr("ruleKey");
  const ruleCategory = optStr("ruleCategory");
  const robotsValue = optStr("robotsValue");
  const canonicalUrl = optStr("canonicalUrl");
  const reason = optStr("reason");
  const conflictDetected =
    typeof data.conflictDetected === "boolean" ? data.conflictDetected : false;

  // Validate shop exists in our DB. If not, silently drop (no info leak).
  try {
    const known = await db.shop.findUnique({
      where: { id: shop },
      select: { id: true },
    });
    if (!known) return noContent();
  } catch (err) {
    await adminLog("error", "track-seo: shop lookup failed", {
      shopId: shop,
      error: err instanceof Error ? err.message : String(err),
    });
    return noContent();
  }

  // Per-shop sliding-window rate limit (per-instance, best-effort).
  if (!checkRateLimit(shop)) {
    return noContent();
  }

  try {
    await db.sEODecisionLog.create({
      data: {
        shopId: shop,
        url,
        path,
        template,
        ruleKey,
        ruleCategory,
        robotsValue,
        canonicalUrl,
        reason,
        conflictDetected,
      },
    });
  } catch (err) {
    await adminLog("error", "track-seo: insert failed", {
      shopId: shop,
      error: err instanceof Error ? err.message : String(err),
    });
  }

  return noContent();
};
