import type { BrowsableSourceItemView, RecipeView } from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiClient } from "../../../api/client";
import { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel";
import { SourceItemTable } from "./SourceItemTable";
import "../recipes.scss";

/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
const SEARCH_DEBOUNCE_MS = 300;

/** How many pulsing skeleton rows `handleLoadMore` shows while its fetch is in flight — see `loadMoreStatus`. Not tied to any source's real page size (that varies per source, and isn't known client-side); just enough to visibly fill the gap below the list without over-promising. */
const LOAD_MORE_PLACEHOLDER_COUNT = 4;

/** One page of `sourceKey`'s browsable catalog, as returned by `apiClient.browseSource`. */
interface BrowsePage {
  items: BrowsableSourceItemView[];
  nextCursor: string | null;
}

/** One item's identity within a source's browsable catalog — `sourceKey` + `externalId` together, since `externalId` alone is only unique per source. */
export interface SourceItemSelection {
  sourceKey: string;
  externalId: string;
}

type BrowseState =
  | { status: "loading" }
  | { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null }
  | { status: "error" };

/**
 * One household-enabled source's own tab content in the recipe catalog
 * (`RecipesPage`/`RecipePickerDialog`) — a master-detail pair of its own
 * (browsable list on the left, a preview on the right), independent of
 * `RecipeTable`'s own `RecipeTab`-based fetching: it browses this one
 * source's *live* catalog (`GET /sources/:sourceKey/browse`), not the
 * saved `Recipe` table.
 *
 * Scoped to exactly one source — every enabled source gets its own tab
 * now (`RecipeTabs`), rather than a single generic "Sources" tab
 * switching between them internally, so `sourceKey` is a fixed prop, not
 * something this component ever changes itself. Callers remount this
 * (via a React `key={sourceKey}` on it) when switching which source's tab
 * is active, the same "mounted only while relevant" convention as
 * `RecipePickerDialog`/`CalendarPopover` elsewhere — simpler than this
 * component reacting to its own `sourceKey` prop changing mid-lifetime.
 *
 * The right-hand preview reuses `RecipeDetailPanel` itself — for a
 * not-yet-imported item, its `"loaded-draft"` state; for an already-
 * imported one, this panel fetches the real thing (`GET /recipes/:id`)
 * and shows it through the exact same `"loaded"` state `RecipesPage` uses,
 * `showActions={false}` since editing/deleting isn't a click that belongs
 * on a browsing/preview screen. Either way, selecting a row only ever
 * previews here — nothing about clicking one imports, saves, or navigates
 * by itself; what a selection *means* is entirely up to the caller
 * (`onSelectImportedRecipe`/`onDraftSelected` below just report which one
 * is currently previewed).
 *
 * `initialSelection`/`onItemSelected` are how `RecipesPage` keeps a
 * not-yet-imported item's preview addressable by URL
 * (`/recettes/sources/:sourceKey/:externalId`) without this panel needing
 * to know anything about routing itself — it reports selection changes
 * upward, and re-previews on mount/prop-change if handed one back.
 * `RecipePickerDialog` leaves both unset: previewing inside that dialog
 * has no URL of its own to keep in sync.
 */
export function RecipeSourcesPanel({
  sourceKey,
  onSelectImportedRecipe,
  onDraftSelected,
  initialSelection,
  onItemSelected,
}: {
  sourceKey: string;
  /** Fired once an already-imported row's own fetch resolves — the full recipe, already what this panel is itself previewing, handed up so the caller (`RecipePickerDialog`) knows a real recipe is now the pending selection without fetching it again itself. */
  onSelectImportedRecipe: (recipe: RecipeView) => void;
  /** Fired the moment a not-yet-imported row is clicked (before its own preview fetch even resolves) — same "which one is pending" role as `onSelectImportedRecipe`, just for a draft instead of a real recipe. Only `RecipePickerDialog` sets this; `RecipesPage` has nothing to do with "pending" since browsing there is never building up to a confirm step. */
  onDraftSelected?: (selection: SourceItemSelection) => void;
  initialSelection?: SourceItemSelection;
  onItemSelected?: (item: SourceItemSelection | null) => void;
}) {
  const { t } = useTranslation();

  const [search, setSearch] = useState("");
  const [debouncedSearch, setDebouncedSearch] = useState("");
  const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
  const [loadMoreStatus, setLoadMoreStatus] = useState<"idle" | "loading" | "error">("idle");
  // The *next* page, fetched ahead of time as soon as the current one is on
  // screen (see the effect below) — a ref, not state, since it's an
  // implementation detail `handleLoadMore` consumes, never itself rendered.
  // Keyed on exactly what makes a prefetch valid to reuse (source/query/
  // cursor all matching) rather than just "is something in flight", so a
  // stale prefetch from before a search/source change is never mistaken for
  // the page that's actually needed next.
  const nextPagePrefetchRef = useRef<{
    sourceKey: string;
    query: string;
    cursor: string;
    promise: Promise<BrowsePage>;
  } | null>(null);
  // Re-entrancy guard for `handleLoadMore` — infinite scroll (unlike a
  // button `onClick`) can call it again before the previous call has
  // settled (e.g. the sentinel row is still intersecting when the observer
  // re-evaluates after a layout shift). A ref, not `loadMoreStatus`: that
  // state only exists to drive what's rendered and is read from React's
  // closure at call time, which would still read the *previous* render's
  // (stale) value inside a handler fired synchronously off a fresh
  // browser event — this needs to be checked/set immediately and
  // synchronously, which only a ref does correctly here.
  const isLoadingMoreRef = useRef(false);
  const [selectedExternalId, setSelectedExternalId] = useState<string | null>(
    initialSelection?.externalId ?? null,
  );
  const [previewState, setPreviewState] = useState<RecipeDetailState>({ status: "empty" });
  // Which item `previewState` actually reflects (or is in flight for) —
  // lets the `initialSelection` effect below tell "the URL just changed to
  // match a selection this component already made itself" (a row click
  // already fetched/is fetching this exact item; `onItemSelected` only
  // round-trips that same pair back in as a new `initialSelection` prop)
  // apart from "the URL changed to point somewhere new" (a deep link, or
  // the browser's back/forward button) — only the latter needs a fetch.
  // Always starts at `null`, even when `initialSelection` is already set on
  // mount — nothing has been fetched yet at that point, that's exactly the
  // "needs a fetch" case the effect below must still run for.
  const [previewedItem, setPreviewedItem] = useState<SourceItemSelection | null>(null);

  // Re-previews whenever `initialSelection` itself changes (a fresh deep
  // link, or the browser's back/forward button landing on a different
  // item within this same source) — not just once on mount. Keyed on the
  // primitive field below, not `initialSelection` itself — a fresh object
  // literal from the caller on every render (see `RecipesPage`) would
  // otherwise re-run this on every render too.
  // biome-ignore lint/correctness/useExhaustiveDependencies: see above.
  useEffect(() => {
    if (!initialSelection) return;
    if (previewedItem?.externalId === initialSelection.externalId) return;
    let cancelled = false;
    setPreviewedItem(initialSelection);
    setSelectedExternalId(initialSelection.externalId);
    setPreviewState({ status: "loading" });
    apiClient
      .previewSourceItem(sourceKey, initialSelection.externalId)
      .then((draft) => {
        if (!cancelled) setPreviewState({ status: "loaded-draft", draft });
      })
      .catch(() => {
        if (!cancelled) setPreviewState({ status: "error" });
      });
    return () => {
      cancelled = true;
    };
  }, [initialSelection?.externalId]);

  useEffect(() => {
    const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
    return () => window.clearTimeout(timeout);
  }, [search]);

  useEffect(() => {
    let cancelled = false;
    setBrowseState({ status: "loading" });
    // A fresh search/source is a fresh list — any in-flight "load more" or
    // stale prefetch from the *previous* one no longer applies to anything.
    setLoadMoreStatus("idle");
    nextPagePrefetchRef.current = null;
    isLoadingMoreRef.current = false;

    apiClient
      .browseSource(sourceKey, { query: debouncedSearch.trim() || undefined })
      .then(({ items, nextCursor }) => {
        if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor });
      })
      .catch(() => {
        if (!cancelled) setBrowseState({ status: "error" });
      });

    return () => {
      cancelled = true;
    };
  }, [sourceKey, debouncedSearch]);

  // Prefetches the page after the one currently on screen, so scrolling
  // near the bottom (SourceItemTable's sentinel row, which calls
  // handleLoadMore) usually just swaps in data that's already arrived
  // instead of starting a fresh round-trip right when someone's waiting on
  // it — `handleLoadMore` below reuses this when it matches. Re-runs on every
  // `browseState` change, so a load-more that appends a new page and a new
  // `nextCursor` immediately kicks off prefetching the page *after* that
  // one too, keeping the panel permanently one page ahead of what's shown.
  useEffect(() => {
    if (browseState.status !== "loaded" || !browseState.nextCursor) return;
    const cursor = browseState.nextCursor;
    const query = debouncedSearch.trim();
    const already = nextPagePrefetchRef.current;
    if (
      already &&
      already.sourceKey === sourceKey &&
      already.query === query &&
      already.cursor === cursor
    ) {
      return; // already prefetching/prefetched exactly this page
    }
    const promise = apiClient.browseSource(sourceKey, { query: query || undefined, cursor });
    nextPagePrefetchRef.current = { sourceKey, query, cursor, promise };
    // A failed prefetch is swallowed here on purpose — nobody's actually
    // waiting on it yet. If `handleLoadMore` later reuses this same promise
    // it awaits/catches the rejection itself at that point; if it's never
    // reused (the prefetch just goes stale), this `.catch()` only exists to
    // keep the rejection from surfacing as an unhandled one.
    promise.catch(() => {});
  }, [browseState, sourceKey, debouncedSearch]);

  function handleLoadMore() {
    if (browseState.status !== "loaded" || !browseState.nextCursor) {
      return;
    }
    if (isLoadingMoreRef.current) {
      return; // already fetching this exact next page — see the ref's own doc comment
    }
    isLoadingMoreRef.current = true;
    const cursor = browseState.nextCursor;
    const query = debouncedSearch.trim();
    setLoadMoreStatus("loading");

    const prefetch = nextPagePrefetchRef.current;
    const request =
      prefetch &&
      prefetch.sourceKey === sourceKey &&
      prefetch.query === query &&
      prefetch.cursor === cursor
        ? prefetch.promise
        : apiClient.browseSource(sourceKey, { query: query || undefined, cursor });

    request
      .then(({ items, nextCursor }) => {
        nextPagePrefetchRef.current = null;
        isLoadingMoreRef.current = false;
        setBrowseState((prev) =>
          prev.status === "loaded"
            ? { status: "loaded", items: [...prev.items, ...items], nextCursor }
            : prev,
        );
        setLoadMoreStatus("idle");
      })
      .catch(() => {
        // Also clears a failed *prefetch*, not just a failed manual retry —
        // otherwise a rejected promise would sit in the ref forever, and
        // "Réessayer" would just keep reusing (and re-rejecting on) that
        // same dead promise instead of ever making a fresh request.
        nextPagePrefetchRef.current = null;
        isLoadingMoreRef.current = false;
        setLoadMoreStatus("error");
      });
  }

  function handleSelectItem(item: BrowsableSourceItemView) {
    if (item.alreadyImported && item.recipeId !== null) {
      setSelectedExternalId(item.externalId);
      setPreviewState({ status: "loading" });
      apiClient
        .getRecipe(item.recipeId)
        .then((recipe) => {
          setPreviewState({ status: "loaded", recipe });
          onSelectImportedRecipe(recipe);
        })
        .catch(() => setPreviewState({ status: "error" }));
      return;
    }
    const selection = { sourceKey, externalId: item.externalId };
    setSelectedExternalId(item.externalId);
    setPreviewState({ status: "loading" });
    // Set before `onItemSelected` so the `initialSelection` effect above
    // recognizes the URL change it triggers as reflecting this same fetch,
    // not a fresh one to make (see that effect's own doc comment).
    setPreviewedItem(selection);
    onItemSelected?.(selection);
    onDraftSelected?.(selection);
    apiClient
      .previewSourceItem(sourceKey, item.externalId)
      .then((draft) => setPreviewState({ status: "loaded-draft", draft }))
      .catch(() => setPreviewState({ status: "error" }));
  }

  return (
    <>
      <div className="recipes-page__header recipes-page__header--sources">
        <input
          type="search"
          className="recipes-page__search"
          placeholder={t("recipes.sources.searchPlaceholder")}
          value={search}
          onChange={(e) => setSearch(e.target.value)}
        />
      </div>

      <div className="recipes-page__catalog">
        {browseState.status === "loading" && (
          <p className="recipes-page__status">{t("recipes.sources.loading")}</p>
        )}
        {browseState.status === "error" && (
          <p className="recipes-page__status recipes-page__status--error">
            {t("recipes.sources.loadError")}
          </p>
        )}
        {browseState.status === "loaded" && browseState.items.length === 0 && (
          <p className="recipes-page__status">{t("recipes.sources.empty")}</p>
        )}
        {browseState.status === "loaded" && browseState.items.length > 0 && (
          <div className="source-items-column">
            <SourceItemTable
              items={browseState.items}
              selectedExternalId={selectedExternalId}
              onSelect={handleSelectItem}
              placeholderCount={loadMoreStatus === "loading" ? LOAD_MORE_PLACEHOLDER_COUNT : 0}
              hasMore={browseState.nextCursor !== null}
              onLoadMore={handleLoadMore}
            />
            {loadMoreStatus === "error" && (
              <p className="recipes-page__status recipes-page__status--error">
                {t("recipes.sources.loadMoreError")}{" "}
                <button type="button" className="source-items-retry" onClick={handleLoadMore}>
                  {t("recipes.sources.retry")}
                </button>
              </p>
            )}
          </div>
        )}

        <RecipeDetailPanel state={previewState} showActions={false} />
      </div>
    </>
  );
}
