// ============================================================================
// KaratScan — live API layer
// ----------------------------------------------------------------------------
// This is the seam between the UI and a REAL backend. The components in this
// app render an *internal* item shape (camelCase, numbers). The public API
// speaks snake_case and serializes money/ratio/weight as STRINGS. Everything
// here: (1) calls the real endpoints, (2) normalizes responses into the exact
// shape the components already consume.
//
// THE SWITCH:  window.DATA_MODE === "mock" (default) | "live"
//   - mock: app.jsx keeps using the generated SEED_ITEMS + simulated stream.
//   - live: app.jsx fetches public endpoints and refreshes by polling.
//   Flip it in index.html, or append ?data=mock to use generated local data.
//
// Auth is a cookie (HttpOnly) set by the magic-link consume call, so every
// request just sends credentials:"include" — there is no token to manage.
// ============================================================================

(function () {
  // Resolve mode: URL param wins, then any value already set, else mock.
  const urlMode = new URLSearchParams(location.search).get("data");
  window.DATA_MODE = urlMode || window.DATA_MODE || "mock";

  // Base URL. Point this at your API origin if it isn't same-origin.
  const API_BASE = window.KARATSCAN_API_BASE || "/api/v1";
  const API_ORIGIN = new URL(API_BASE, location.href).origin;

  // ---- error envelope -------------------------------------------------------
  class ApiError extends Error {
    constructor({ status, code, message, requestId, retryAfter }) {
      super(message || "Request failed");
      this.name = "ApiError";
      this.status = status;
      this.code = code;
      this.requestId = requestId || null;
      this.retryAfter = retryAfter || null;
    }
  }

  async function apiFetch(path, opts = {}) {
    const { method = "GET", body, query, headers, signal } = opts;
    let url = API_BASE + path;
    if (query) {
      const qs = new URLSearchParams();
      Object.entries(query).forEach(([k, v]) => {
        if (v == null || v === "") return;
        if (Array.isArray(v)) v.forEach((x) => x != null && x !== "" && qs.append(k, x));
        else qs.append(k, v);
      });
      const s = qs.toString();
      if (s) url += "?" + s;
    }
    const res = await fetch(url, {
      method,
      signal,
      credentials: "include", // carry the session cookie
      headers: {
        Accept: "application/json",
        ...(body ? { "Content-Type": "application/json" } : {}),
        ...headers,
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (res.status === 204) return null;
    let data = null;
    try { data = await res.json(); } catch (_) { /* empty/non-json */ }
    if (!res.ok) {
      const env = (data && data.error) || {};
      throw new ApiError({
        status: res.status,
        code: env.code || String(res.status),
        message: env.message || res.statusText || "Request failed",
        requestId: env.request_id,
        retryAfter: Number(res.headers.get("Retry-After")) || null,
      });
    }
    return data;
  }

  // ---- value coercion (strings -> numbers) ---------------------------------
  const num = (x) => (x == null || x === "" ? null : Number(x));
  const int = (x) => { const n = num(x); return n == null ? null : Math.round(n); };
  const confidenceBand = (x, fallback = "medium") => {
    if (x == null || x === "") return fallback;
    const raw = String(x).trim().toLowerCase();
    if (raw === "high" || raw === "medium" || raw === "low") return raw;
    const n = Number(raw);
    if (!Number.isFinite(n)) return fallback;
    if (n >= 0.8) return "high";
    if (n >= 0.5) return "medium";
    return "low";
  };
  const normalizeImageUrl = (url) => {
    if (!url) return null;
    try {
      const u = new URL(url, location.href);
      if (u.pathname.startsWith("/api/v1/images/")) {
        return API_ORIGIN + u.pathname + u.search + u.hash;
      }
    } catch (_) {
      return null;
    }
    // The public UI should not bypass the API image proxy. If a live row has
    // only a raw marketplace image URL, render the local placeholder instead.
    return null;
  };

  // ---- normalizers: API shape -> internal UI shape -------------------------
  // PublicItemSummary / PublicItemDetail -> the object feed.jsx & detail.jsx render.
  function normalizeItem(a) {
    if (!a) return null;
    const p = a.pricing || {};
    const w = a.weight || {};
    const reasonCodes = p.reason_codes || [];
    const ratio = num(p.price_to_melt_ratio);
    const karat = a.karat ?? null;

    const isPlated = reasonCodes.includes("gold_plated_not_priced");
    const isFilled = reasonCodes.includes("gold_filled_not_priced");
    const isVermeil = reasonCodes.includes("vermeil_not_priced");
    const isNonGold =
      (a.primary_metal && a.primary_metal !== "gold") ||
      reasonCodes.includes("non_gold_not_priced");

    // images: prefer proxied_url per the brief, and dedupe primary + detail gallery.
    const imgCandidates = [
      a.primary_image,
      ...(Array.isArray(a.images) ? a.images : []),
    ].filter(Boolean);
    const imageUrls = Array.from(new Set(
      imgCandidates
        .map((im) => im && normalizeImageUrl(im.proxied_url || im.url))
        .filter(Boolean)
    ));
    const primaryUrl = imageUrls[0] || null;

    const listingTime = a.listing_time_utc || a.processed_at;
    const ageMin = listingTime
      ? Math.max(0, (Date.now() - Date.parse(listingTime)) / 60000)
      : 0;

    const seller = a.seller || {};

    return {
      id: String(a.id),
      itemId: a.item_id || "",
      title: a.title || "Untitled listing",
      listingUrl: a.listing_url || "#",
      currency: a.currency || "USD",

      itemType: a.item_type || a.jewelry_type_guess || "other",
      primaryMetal: a.primary_metal || a.metal_family_guess || "unknown",
      karat,
      purity: karat ? (window.KARAT_PURITY[karat] ?? 0) : 0,

      totalWeight: num(w.total_weight_g),
      goldWeight: num(w.gold_weight_g_estimate),
      weightBasis: p.weight_basis || (w.total_weight_g != null ? "text" : "none"),
      weightSource: w.weight_source || null,
      weightConfidence: confidenceBand(w.weight_confidence),
      weightRequiresReview: !!w.weight_requires_review,

      hasStones: reasonCodes.includes("stones_present"),
      isSolidGold: !isPlated && !isFilled && !isVermeil && !isNonGold,
      isPlated, isFilled, isVermeil, isNonGold,

      meltValue: num(p.estimated_gold_melt_value_usd),
      ratio,
      dealScore: int(p.deal_score),
      pricingStatus: p.status || null,
      requiresReview: !!p.requires_review,
      reasonCodes,
      spotPerGram: num(p.gold_spot_price_per_gram_usd) ?? window.SPOT_PER_GRAM,

      // API only exposes all-in price (incl. shipping); no split. Show as all-in.
      price: num(a.all_in_price_usd),
      shipping: 0,
      allIn: num(a.all_in_price_usd),

      overallConfidence: confidenceBand(a.overall_confidence),
      coverage: a.coverage || "parsed",
      tier: window.tierFor(ratio),
      nearMiss: window.isNearMiss(ratio),

      seller: seller.username || null,
      location: seller.location || null,
      sellerFeedbackScore: seller.feedback_score ?? 0,
      sellerFeedbackPercent: seller.feedback_percent ?? null,

      authenticityGuarantee: !!a.ebay_authenticity_guarantee,
      bestOffer: false,
      buyItNow: true,

      images: imageUrls.length || (primaryUrl ? 1 : 0),
      imageUrl: primaryUrl,
      imageUrls,

      isSavedByMe: a.is_saved_by_me ?? null,
      ageMin,
      condition: a.condition_normalized || a.condition_raw || "good",
      note: null,

      // detail-only extras (present after GET /items/{pk})
      categoryPath: a.category_path || [],
      parseWarnings: a.parse_warnings || [],
      weightEvidence: a.weight_evidence
        ? {
            hasImageScaleAnalysis: !!a.weight_evidence.has_image_scale_analysis,
            listingTextExcerpt: a.weight_evidence.listing_text_excerpt || null,
            imageScaleTextExcerpt: a.weight_evidence.image_scale_text_excerpt || null,
            reviewRequiredReason: a.weight_evidence.review_required_reason || null,
          }
        : null,
      authenticityTermsUrl: a.ebay_authenticity_guarantee_terms_url || null,
      relatedItemsUrl: a.related_items_url || null,
      _raw: a,
    };
  }

  // detail is a superset of summary — same normalizer covers it
  const normalizeDetail = normalizeItem;

  function normalizeSpot(a) {
    const perG = num(a.price_per_gram_usd) ?? window.SPOT_PER_GRAM;
    const age =
      a.age_seconds != null
        ? a.age_seconds
        : a.fetched_at
        ? Math.max(0, Math.round((Date.now() - Date.parse(a.fetched_at)) / 1000))
        : 0;
    return {
      pricePerGram: perG,
      pricePerOz: +(perG * 31.1035).toFixed(2),
      source: a.source || "live",
      isAuthoritative: a.is_authoritative ?? true,
      ageSeconds: age,
      change24h: 0, // not in the API; UI treats 0 as flat
    };
  }

  // PublicSearchItem adds highlights + the response adds total_count
  function normalizeSearch(resp) {
    const items = (resp.items || []).map((it) => ({
      ...normalizeItem(it),
      highlights: it.highlights || null,
    }));
    return { items, totalCount: resp.total_count ?? items.length, nextPage: resp.next_page || null };
  }

  // alert rule -> the shape RightRail's AlertCard renders
  function criteriaToString(c = {}) {
    const parts = [];
    if (c.karats && c.karats.length) parts.push(c.karats.map((k) => k + "K").join(","));
    if (c.item_types && c.item_types.length) parts.push(c.item_types.join("/"));
    if (c.require_solid_gold) parts.push("solid");
    if (c.min_gold_weight_g != null) parts.push("≥" + c.min_gold_weight_g + "g");
    if (c.max_price_to_melt_ratio != null)
      parts.push("≤" + Number(c.max_price_to_melt_ratio).toFixed(2) + "×");
    if (c.min_deal_score != null) parts.push("score ≥" + c.min_deal_score);
    if (c.max_all_in_price != null) parts.push("≤$" + c.max_all_in_price);
    return parts.join(" · ") || "any item";
  }
  function normalizeAlertRule(a) {
    const crit = a.criteria || {};
    const pol = a.notification_policy || {};
    const chans = pol.channel_types || pol.channels || [];
    const r = crit.max_price_to_melt_ratio != null ? Number(crit.max_price_to_melt_ratio) : null;
    return {
      id: String(a.id),
      name: a.name || "Untitled rule",
      enabled: a.is_enabled ?? true,
      version: a.version ?? 1,
      criteria: criteriaToString(crit),
      channels: (Array.isArray(chans) ? chans : []).map((c) =>
        typeof c === "string" ? c : c.channel_type || c.type || "discord"
      ),
      lastTriggered: a.last_triggered_at_human || "—",
      matches24h: a.matches_24h ?? 0,
      tier: window.tierFor(r) || "steal",
      _raw: a,
    };
  }

  function rows(resp) {
    return (resp && (resp.items || resp.data)) || (Array.isArray(resp) ? resp : []);
  }

  // ---- the client: one method per endpoint in the brief --------------------
  const KS = {
    base: API_BASE,
    ApiError,

    // -- auth (magic-link only) --
    requestMagicLink: (email) =>
      apiFetch("/auth/magic-link/request", { method: "POST", body: { email } }),
    consumeMagicLink: (token) =>
      apiFetch("/auth/magic-link/consume", { method: "POST", body: { token } }),
    logout: () => apiFetch("/auth/logout", { method: "POST" }),

    // -- public browse / deals --
    // feed: offer | deal | steal | near | needs | all
    async getFeed(feed, opts = {}) {
      const {
        nearTier = "deal",
        includeReview = false,
        filters = {},
        sort = "newest",
        limit = 50,
        page,
      } = opts;
      let resp;
      if (feed === "all") {
        resp = await apiFetch("/items", {
          query: {
            sort,
            limit,
            page,
            karat: filters.karat,
            item_type: filters.type,
            pricing_requires_review: includeReview ? "true" : undefined,
          },
        });
      } else if (feed === "needs") {
        resp = await apiFetch("/deals/needs-weight", { query: { limit, page } });
      } else if (feed === "near") {
        resp = await apiFetch("/deals/near-miss", {
          query: { tier: nearTier, limit, page, include_review_required: includeReview ? "true" : undefined },
        });
      } else {
        // offer | deal | steal
        resp = await apiFetch("/deals/" + feed, {
          query: { limit, page, include_review_required: includeReview ? "true" : undefined },
        });
      }
      const list = rows(resp).map(normalizeItem);
      list._nextPage = (resp && resp.next_page) || null;
      return list;
    },

    getItem: (pk) => apiFetch("/items/" + encodeURIComponent(pk)).then(normalizeDetail),
    getRelated: (pk) =>
      apiFetch("/items/" + encodeURIComponent(pk) + "/related").then((r) => rows(r).map(normalizeItem)),

    search: (q, opts = {}) =>
      apiFetch("/search/items", {
        query: { q, limit: opts.limit || 50, page: opts.page, ...(opts.filters || {}) },
      }).then(normalizeSearch),

    getSpot: () => apiFetch("/gold/spot").then(normalizeSpot),
    getReference: (name) => apiFetch("/reference/" + name),

    // -- profile --
    getProfile: () => apiFetch("/users/me"),
    updateProfile: (display_name) => apiFetch("/users/me", { method: "PATCH", body: { display_name } }),
    deleteAccount: () => apiFetch("/users/me", { method: "DELETE" }),

    // -- saved items --
    getSavedItems: () => apiFetch("/me/saved-items").then((r) => rows(r).map(normalizeItem)),
    saveItem: (item_pk, note) => apiFetch("/me/saved-items", { method: "POST", body: { item_pk, note } }),
    updateSavedNote: (item_pk, note) =>
      apiFetch("/me/saved-items/" + encodeURIComponent(item_pk), { method: "PATCH", body: { note } }),
    unsaveItem: (item_pk) =>
      apiFetch("/me/saved-items/" + encodeURIComponent(item_pk), { method: "DELETE" }),

    // -- saved searches --
    getSavedSearches: () => apiFetch("/me/saved-searches").then(rows),
    createSavedSearch: (name, criteria) =>
      apiFetch("/me/saved-searches", { method: "POST", body: { name, criteria } }),
    deleteSavedSearch: (id) => apiFetch("/me/saved-searches/" + id, { method: "DELETE" }),
    replaySavedSearch: (id, opts = {}) =>
      apiFetch("/me/saved-searches/" + id + "/items", { query: opts }).then((r) => rows(r).map(normalizeItem)),

    // -- alert rules --
    getAlertRules: () => apiFetch("/me/alert-rules").then((r) => rows(r).map(normalizeAlertRule)),
    createAlertRule: (body) => apiFetch("/me/alert-rules", { method: "POST", body }),
    getAlertRule: (id) => apiFetch("/me/alert-rules/" + id).then(normalizeAlertRule),
    updateAlertRule: (id, body) => apiFetch("/me/alert-rules/" + id, { method: "PATCH", body }),
    deleteAlertRule: (id) => apiFetch("/me/alert-rules/" + id, { method: "DELETE" }),
    testAlertRule: (id) => apiFetch("/me/alert-rules/" + id + "/test", { method: "POST" }),
    rescanAlertRule: (id) => apiFetch("/me/alert-rules/" + id + "/rescan", { method: "POST" }),

    // -- notification channels (Discord webhook only, per brief) --
    getChannels: () => apiFetch("/me/notification-channels").then(rows),
    createChannel: (label, secret) =>
      apiFetch("/me/notification-channels", {
        method: "POST",
        body: { channel_type: "discord_webhook", label, secret },
      }),
    updateChannel: (id, body) => apiFetch("/me/notification-channels/" + id, { method: "PATCH", body }),
    deleteChannel: (id) => apiFetch("/me/notification-channels/" + id, { method: "DELETE" }),
    testChannel: (id) => apiFetch("/me/notification-channels/" + id + "/test", { method: "POST" }),

    // -- alert history --
    getAlertHistory: () => apiFetch("/me/alerts").then(rows),
    getAlert: (id) => apiFetch("/me/alerts/" + id),

    // -- feedback --
    getFeedback: () => apiFetch("/me/feedback").then(rows),
    submitFeedback: (body) => apiFetch("/me/feedback", { method: "POST", body }),

    // -- live SSE stream of newly analyzed items --
    // returns an unsubscribe fn. onItem receives a RAW PublicItemSummary;
    // normalize it with window.normalizeItem before adding to state.
    streamItems({ filter, onItem, onError } = {}) {
      if (typeof EventSource === "undefined") return () => {};
      const f = filter ? "?filter=" + encodeURIComponent(typeof filter === "string" ? filter : JSON.stringify(filter)) : "";
      const es = new EventSource(API_BASE + "/events/items" + f, { withCredentials: true });
      const handle = (ev) => {
        if (!ev || !ev.data) return;
        try { onItem && onItem(JSON.parse(ev.data)); } catch (_) {}
      };
      es.onmessage = handle;
      es.addEventListener("item", handle);
      es.onerror = (e) => onError && onError(e);
      return () => es.close();
    },
  };

  Object.assign(window, {
    KaratScanAPI: KS,
    KS,
    ApiError,
    API_BASE,
    normalizeItem,
    normalizeDetail,
    normalizeSpot,
    normalizeSearch,
    normalizeAlertRule,
  });
})();
