import {
  ErrorCode,
  type IngredientView,
  type StepTechStepIngredientView,
  type StepTechStepUtensilView,
  type SubmitTechStepCorrectionResult,
  type TechStepView,
  type UnitView,
  type UtensilView,
} from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../../api/client";
import { errorMessageService } from "../../../services/error-message.service";
import { ingredientLabel } from "../ingredients/ingredient-label";
import { CatalogSearchPicker } from "./CatalogSearchPicker";
import type { TextSelectionRange } from "./use-text-selection";

/** One ingredient the viewer has attached (or is about to submit) — the trimmed-down shape `POST .../corrections`'s `ingredients[]` expects, kept separately from `StepTechStepIngredientView` since a pending one has no resolved `IngredientView`/`UnitView` to carry yet, only ids. */
interface PendingIngredient {
  ingredientId: number;
  quantity: number | null;
  unitId: number | null;
  start: number;
  end: number;
}
/** Same as {@link PendingIngredient}, for a utensil (no quantity/unit — nothing to measure). */
interface PendingUtensil {
  utensilId: number;
  start: number;
  end: number;
}

function toPendingIngredient(view: StepTechStepIngredientView): PendingIngredient {
  return {
    ingredientId: view.ingredient.id,
    quantity: view.quantity,
    unitId: view.unit?.id ?? null,
    start: view.start,
    end: view.end,
  };
}
function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
  return { utensilId: view.utensil.id, start: view.start, end: view.end };
}

/**
 * Small non-modal popover letting a viewer assign a technique to a selected
 * span of a step's description, or clear/relabel an existing match —
 * opened by `StepDescription`'s editable mode. Same `mousedown`-outside-
 * close pattern as `PlanningPage`'s `CalendarPopover`, not `Dialog.tsx`'s
 * native `<dialog>` — this is a small, contextual pick-one-option surface,
 * not a page-blocking modal.
 *
 * Rendered inline right below the step's own description block (see
 * `StepDescription.tsx`), not floating anchored at the selection's exact
 * position — simpler and more robust than tracking a caret-anchored
 * position across scroll/resize, at the cost of a little visual distance
 * from the selected text itself. That placement matters beyond cosmetics
 * here: it's *why* the "attach an ingredient/utensil" flow below can ask
 * the viewer to select a second span of text without closing this popover
 * first — the description stays fully visible and selectable the whole
 * time, nothing overlays it.
 *
 * **One merged editor, not a wizard**: picking a technique
 * (`CatalogSearchPicker`, searchable — the reference catalog is ~74
 * entries, an unfiltered flat list wasn't browsable) and editing its
 * Ingrédients/Ustensiles metadata render together on the same screen,
 * always — there's no separate "pick, then a metadata step reveals
 * itself" sequence to go through, and no dead end where metadata is
 * technically attachable but not visible until some other action happens
 * first. A single "Valider" submits everything at once; disabled until a
 * technique is actually selected (there's nothing to attach metadata to
 * otherwise). **Removing** a match (`submit(null)`) stays its own
 * immediate action next to the picker — nothing to attach when removing.
 *
 * The two metadata sections are pre-seeded from `existingIngredients`/
 * `existingUtensils` (whatever's already attached to this occurrence, auto-
 * or manually-sourced — `[]` for a brand-new technique) and editable via
 * add/remove — see `metadataTouched` below for why what's *displayed* here
 * isn't automatically what gets *submitted*.
 */
export function TechStepCorrectionPopover({
  recipeId,
  stepId,
  selectedText,
  range,
  previousTechStepId,
  existingIngredients,
  existingUtensils,
  resolvedMetadataSpan,
  onRequestSpan,
  onClose,
  onSubmitted,
}: {
  recipeId: number;
  stepId: number;
  /** The selected span's own text — shown so the user confirms what they're tagging before picking a technique. */
  selectedText: string;
  range: TextSelectionRange;
  /** Set when correcting an already-detected match (opened from clicking its highlight) rather than a fresh selection — passed through as-is on submit, and offers a "remove" option `null` doesn't. */
  previousTechStepId: number | null;
  /** Whatever ingredients/utensils already sit on this occurrence (both `"auto"` and `"manual"` sourced) — `[]` for a brand-new technique, nothing to pre-seed. */
  existingIngredients: StepTechStepIngredientView[];
  existingUtensils: StepTechStepUtensilView[];
  /**
   * A text span `StepDescription` just resolved on this popover's behalf,
   * after a call to `onRequestSpan` below — `null` until then. Identified
   * by `nonce` (not by value) so this popover's own `useEffect` reliably
   * fires once per fresh selection, even if the exact same span is
   * selected twice in a row.
   */
  resolvedMetadataSpan: {
    nonce: number;
    kind: "ingredient" | "utensil";
    range: TextSelectionRange;
    text: string;
  } | null;
  /** Tells `StepDescription` "the next text selection in the description is for an ingredient/utensil mention, not a new technique correction" — see this component's own doc comment. */
  onRequestSpan: (kind: "ingredient" | "utensil") => void;
  onClose: () => void;
  onSubmitted: (result: SubmitTechStepCorrectionResult) => void;
}) {
  const { t } = useTranslation();
  const popoverRef = useRef<HTMLDivElement>(null);
  const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
  // Opening this popover on an *already-detected* match (`previousTechStepId
  // !== null`, i.e. the user clicked an existing highlight rather than
  // selecting fresh text) starts pre-selected on that same technique, its
  // name shown next to the picker right away — since the picker and the
  // metadata sections render together regardless (see this component's own
  // doc comment), this just saves re-picking the technique that's already
  // correct before its metadata becomes editable.
  const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
  const [catalogs, setCatalogs] = useState<{
    ingredients: IngredientView[];
    units: UnitView[];
    utensils: UtensilView[];
  } | null>(null);

  const [pendingIngredients, setPendingIngredients] = useState<PendingIngredient[]>(() =>
    existingIngredients.map(toPendingIngredient),
  );
  const [pendingUtensils, setPendingUtensils] = useState<PendingUtensil[]>(() =>
    existingUtensils.map(toPendingUtensil),
  );
  // Flips true the moment the viewer adds/removes a pending entry — never
  // from the initial seeding above. `submit()` below only includes
  // `ingredients`/`utensils` in the request when this is true, so
  // relabeling/confirming a technique without ever opening either section
  // leaves existing metadata completely alone server-side (see
  // `submitTechStepCorrectionSchema`'s own doc comment, `packages/shared`,
  // for why an *omitted* field — not an empty array — is what "don't
  // touch it" means over the wire).
  const [metadataTouched, setMetadataTouched] = useState(false);

  const [awaitingSpanFor, setAwaitingSpanFor] = useState<"ingredient" | "utensil" | null>(null);
  const [activeSpan, setActiveSpan] = useState<{
    kind: "ingredient" | "utensil";
    range: TextSelectionRange;
    text: string;
  } | null>(null);
  // Only meaningful while `activeSpan?.kind === "ingredient"` — the
  // ingredient sub-flow is itself two steps (pick the ingredient, then its
  // quantity/unit), this is where the first step's choice waits until the
  // second is confirmed.
  const [pickedIngredientId, setPickedIngredientId] = useState<number | null>(null);
  const [spanQuantity, setSpanQuantity] = useState("");
  const [spanUnitId, setSpanUnitId] = useState<number | null>(null);

  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;
    apiClient
      .getTechSteps()
      .then((list) => {
        if (!cancelled) setTechSteps(list);
      })
      .catch(() => {
        if (!cancelled) setTechSteps([]);
      });
    return () => {
      cancelled = true;
    };
  }, []);

  // Fetched unconditionally on mount — the Ingrédients/Ustensiles sections
  // render alongside the technique picker from the start (see this
  // component's own doc comment on the merged editor), so there's no later
  // point to defer this to anymore.
  useEffect(() => {
    if (catalogs !== null) return;
    let cancelled = false;
    Promise.all([apiClient.getIngredients(), apiClient.getUnits(), apiClient.getUtensils()])
      .then(([ingredients, units, utensils]) => {
        if (!cancelled) setCatalogs({ ingredients, units, utensils });
      })
      .catch(() => {
        if (!cancelled) setCatalogs({ ingredients: [], units: [], utensils: [] });
      });
    return () => {
      cancelled = true;
    };
  }, [catalogs]);

  // Consumes a span `StepDescription` just resolved on this popover's
  // behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the
  // matching sub-picker and clears the "awaiting a selection" hint.
  useEffect(() => {
    if (resolvedMetadataSpan === null) return;
    setActiveSpan({
      kind: resolvedMetadataSpan.kind,
      range: resolvedMetadataSpan.range,
      text: resolvedMetadataSpan.text,
    });
    setAwaitingSpanFor(null);
    setPickedIngredientId(null);
    setSpanQuantity("");
    setSpanUnitId(null);
    // Depends on the whole object, not just `.nonce` — `StepDescription`
    // only ever calls its setter with a brand-new object (never mutates
    // one in place), so reference equality alone already gives this the
    // "fires once per fresh selection" behavior `nonce` documents, with no
    // need to silence the exhaustive-deps lint to get there.
  }, [resolvedMetadataSpan]);

  useEffect(() => {
    function handleClickOutside(e: MouseEvent) {
      if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
        onClose();
      }
    }
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, [onClose]);

  async function removeMatch() {
    setIsSubmitting(true);
    setError(null);
    try {
      const result = await apiClient.submitTechStepCorrection(recipeId, stepId, {
        start: range.start,
        end: range.end,
        previousTechStepId,
        correctedTechStepId: null,
      });
      onSubmitted(result);
      onClose();
    } catch (err) {
      const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
      setError(errorMessageService.getLabel(code));
      setIsSubmitting(false);
    }
  }

  async function confirm() {
    if (selectedTechStepId === null) return;
    setIsSubmitting(true);
    setError(null);
    try {
      const result = await apiClient.submitTechStepCorrection(recipeId, stepId, {
        start: range.start,
        end: range.end,
        previousTechStepId,
        correctedTechStepId: selectedTechStepId,
        ...(metadataTouched ? { ingredients: pendingIngredients, utensils: pendingUtensils } : {}),
      });
      onSubmitted(result);
      onClose();
    } catch (err) {
      const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
      setError(errorMessageService.getLabel(code));
      setIsSubmitting(false);
    }
  }

  function requestSpan(kind: "ingredient" | "utensil") {
    setAwaitingSpanFor(kind);
    onRequestSpan(kind);
  }

  function cancelSpanSelection() {
    setAwaitingSpanFor(null);
    setActiveSpan(null);
    setPickedIngredientId(null);
  }

  function confirmIngredientSpan() {
    if (activeSpan === null || pickedIngredientId === null) return;
    const trimmed = spanQuantity.trim();
    const parsedQuantity = trimmed.length > 0 ? Number(trimmed) : null;
    setPendingIngredients((prev) => [
      ...prev,
      {
        ingredientId: pickedIngredientId,
        quantity:
          parsedQuantity !== null && Number.isFinite(parsedQuantity) ? parsedQuantity : null,
        unitId: spanUnitId,
        start: activeSpan.range.start,
        end: activeSpan.range.end,
      },
    ]);
    setMetadataTouched(true);
    setActiveSpan(null);
    setPickedIngredientId(null);
  }

  function confirmUtensilSpan(utensilId: number) {
    if (activeSpan === null) return;
    setPendingUtensils((prev) => [
      ...prev,
      { utensilId, start: activeSpan.range.start, end: activeSpan.range.end },
    ]);
    setMetadataTouched(true);
    setActiveSpan(null);
  }

  function removeIngredient(index: number) {
    setPendingIngredients((prev) => prev.filter((_, i) => i !== index));
    setMetadataTouched(true);
  }
  function removeUtensil(index: number) {
    setPendingUtensils((prev) => prev.filter((_, i) => i !== index));
    setMetadataTouched(true);
  }

  const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
  const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
  const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));

  return (
    <div className="tech-step-correction-popover" ref={popoverRef}>
      <p className="tech-step-correction-popover__selection">
        {t("recipes.techStepCorrection.selectionLabel", { text: selectedText })}
      </p>

      {awaitingSpanFor !== null ? (
        <p className="tech-step-correction-popover__hint">
          {t("recipes.techStepCorrection.selectSpanHint")}
        </p>
      ) : activeSpan !== null ? (
        <div className="tech-step-correction-popover__span-picker">
          <p className="tech-step-correction-popover__selection">
            {t("recipes.techStepCorrection.selectionLabel", { text: activeSpan.text })}
          </p>
          {activeSpan.kind === "ingredient" ? (
            pickedIngredientId === null ? (
              <CatalogSearchPicker
                items={(catalogs?.ingredients ?? []).map((ingredient) => ({
                  id: ingredient.id,
                  label: ingredientLabel(ingredient, t),
                }))}
                onSelect={setPickedIngredientId}
                placeholder={t("recipes.form.searchIngredientPlaceholder")}
                emptyLabel={t("recipes.form.noIngredientFound")}
              />
            ) : (
              <div className="tech-step-correction-popover__quantity-line">
                <input
                  type="number"
                  min="0"
                  step="any"
                  value={spanQuantity}
                  onChange={(e) => setSpanQuantity(e.target.value)}
                  aria-label={t("recipes.form.quantityLabel")}
                />
                <select
                  value={spanUnitId ?? ""}
                  onChange={(e) => setSpanUnitId(e.target.value ? Number(e.target.value) : null)}
                  aria-label={t("recipes.form.unitLabel")}
                >
                  <option value="">{t("recipes.form.unitPlaceholder")}</option>
                  {(catalogs?.units ?? []).map((unit) => (
                    <option key={unit.id} value={unit.id}>
                      {t(`catalog.units.${unit.key}`)}
                    </option>
                  ))}
                </select>
                <button type="button" onClick={confirmIngredientSpan}>
                  {t("recipes.techStepCorrection.addToList")}
                </button>
              </div>
            )
          ) : (
            <CatalogSearchPicker
              items={(catalogs?.utensils ?? []).map((utensil) => ({
                id: utensil.id,
                label: t(`catalog.utensils.${utensil.key}`),
              }))}
              onSelect={confirmUtensilSpan}
              placeholder={t("recipes.techStepCorrection.searchUtensilPlaceholder")}
              emptyLabel={t("recipes.techStepCorrection.noUtensilFound")}
            />
          )}
          <button
            type="button"
            className="tech-step-correction-popover__cancel"
            onClick={cancelSpanSelection}
          >
            {t("recipes.techStepCorrection.cancelSpanSelection")}
          </button>
        </div>
      ) : techSteps === null ? (
        <p>{t("recipes.loading")}</p>
      ) : (
        <div className="tech-step-correction-popover__confirm">
          <section className="tech-step-correction-popover__technique-section">
            <div className="tech-step-correction-popover__technique-header">
              <h4>{t("recipes.techStepCorrection.techniqueSection")}</h4>
              {previousTechStepId !== null && (
                <button
                  type="button"
                  disabled={isSubmitting}
                  onClick={removeMatch}
                  className="tech-step-correction-popover__remove"
                >
                  {t("recipes.techStepCorrection.removeMatch")}
                </button>
              )}
            </div>
            <p className="tech-step-correction-popover__chosen-technique">
              {selectedTechStepId !== null
                ? t("recipes.techStepCorrection.currentTechnique", {
                    technique: t(
                      `catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
                    ),
                  })
                : t("recipes.techStepCorrection.noTechniqueSelected")}
            </p>
            <CatalogSearchPicker
              items={techSteps.map((techStep) => ({
                id: techStep.id,
                label: t(`catalog.techSteps.${techStep.key}`),
              }))}
              selectedId={selectedTechStepId ?? undefined}
              onSelect={setSelectedTechStepId}
              placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
              emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
            />
          </section>

          <section className="tech-step-correction-popover__metadata-section">
            <h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4>
            <ul className="tech-step-correction-popover__chips">
              {pendingIngredients.map((ingredient, index) => {
                const view = ingredientById.get(ingredient.ingredientId);
                const unit =
                  ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
                const label = view ? ingredientLabel(view, t) : "…";
                return (
                  // biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
                  <li key={index} className="tech-step-correction-popover__chip">
                    {ingredient.quantity !== null ? `${ingredient.quantity} ` : ""}
                    {unit ? `${t(`catalog.units.${unit.key}`)} ` : ""}
                    {label}
                    <button
                      type="button"
                      onClick={() => removeIngredient(index)}
                      title={t("recipes.techStepCorrection.removeIngredient")}
                      disabled={isSubmitting}
                    >
                      ✕
                    </button>
                  </li>
                );
              })}
            </ul>
            <button type="button" onClick={() => requestSpan("ingredient")} disabled={isSubmitting}>
              {t("recipes.techStepCorrection.addIngredient")}
            </button>
          </section>

          <section className="tech-step-correction-popover__metadata-section">
            <h4>{t("recipes.techStepCorrection.utensilsSection")}</h4>
            <ul className="tech-step-correction-popover__chips">
              {pendingUtensils.map((utensil, index) => {
                const view = utensilById.get(utensil.utensilId);
                return (
                  // biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient chip list above.
                  <li key={index} className="tech-step-correction-popover__chip">
                    {view ? t(`catalog.utensils.${view.key}`) : "…"}
                    <button
                      type="button"
                      onClick={() => removeUtensil(index)}
                      title={t("recipes.techStepCorrection.removeUtensil")}
                      disabled={isSubmitting}
                    >
                      ✕
                    </button>
                  </li>
                );
              })}
            </ul>
            <button type="button" onClick={() => requestSpan("utensil")} disabled={isSubmitting}>
              {t("recipes.techStepCorrection.addUtensil")}
            </button>
          </section>

          <button
            type="button"
            className="tech-step-correction-popover__confirm-button"
            onClick={confirm}
            disabled={isSubmitting || selectedTechStepId === null}
          >
            {t("recipes.techStepCorrection.confirm")}
          </button>
        </div>
      )}

      {error && <p className="field-error">{error}</p>}
      <button
        type="button"
        className="tech-step-correction-popover__cancel"
        onClick={onClose}
        disabled={isSubmitting}
      >
        {t("recipes.techStepCorrection.cancel")}
      </button>
    </div>
  );
}
