"use client";

import Fuse from "fuse.js";
import { ChevronLeftIcon, ChevronRightIcon, SearchIcon, XIcon } from "lucide-react";
import { type Locale, useLocale, useTranslations } from "next-intl";
import Image from "next/image";
import { useSearchParams } from "next/navigation";
import {
  type KeyboardEvent,
  type ReactNode,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
} from "react";
import {
  BLOG_CATEGORIES,
  type CategoryId,
  categoryIdFromSlug,
  categorySlug,
} from "@/config/blog/categories";
import { Link, useRouter as useIntlRouter } from "@/i18n/navigation";
import { cn } from "@/lib/utils";
import Rule from "@/components/ui/Rule";

type Suggestion = { title: string; category: CategoryId; slug: string; cover: string };
type CategoryMatch = { id: CategoryId; label: string };
type Option =
  | { type: "category"; match: CategoryMatch }
  | { type: "article"; item: Suggestion }
  | { type: "seeAll" };

const MIN_CHARS = 2;
const MAX_SUGGESTIONS = 6;

/** Lowercase + strip diacritics for case/accent-insensitive matching. */
function normalize(value: string) {
  return value
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")
    .toLowerCase();
}

/**
 * Wraps every occurrence of `query` inside `text` in a <mark>, matching
 * case- and accent-insensitively while preserving the original characters.
 * Uses indexOf on a normalized copy (with an index map back to the source), so
 * no regex — the query needs no escaping.
 */
function highlight(text: string, query: string): ReactNode {
  const trimmed = query.trim();
  if (!trimmed) {
    return text;
  }
  const needle = normalize(trimmed);
  if (!needle) {
    return text;
  }
  const normalizedChars: string[] = [];
  const sourceIndex: number[] = [];
  for (let i = 0; i < text.length; i++) {
    for (const ch of normalize(text[i])) {
      normalizedChars.push(ch);
      sourceIndex.push(i);
    }
  }
  const haystack = normalizedChars.join("");
  const parts: ReactNode[] = [];
  let cursor = 0;
  let from = 0;
  let key = 0;
  let found = haystack.indexOf(needle, from);
  while (found !== -1) {
    const start = sourceIndex[found];
    const end = sourceIndex[found + needle.length - 1] + 1;
    if (start > cursor) {
      parts.push(<span key={`t${key++}`}>{text.slice(cursor, start)}</span>);
    }
    parts.push(
      <mark key={`m${key++}`} className="bg-gold-bright/50 text-foreground">
        {text.slice(start, end)}
      </mark>,
    );
    cursor = end;
    from = found + needle.length;
    found = haystack.indexOf(needle, from);
  }
  if (cursor < text.length) {
    parts.push(<span key={`t${key++}`}>{text.slice(cursor)}</span>);
  }
  return parts.length > 0 ? parts : text;
}

function tabClass(active: boolean) {
  return cn(
    // inline-flex, not the default inline: vertical padding on an inline box is
    // painted but does not grow the line box, so the <ul> stayed 24px tall while
    // its links needed 47px. overflow-x-auto forces overflow-y to auto, and that
    // scroll container was clipping the glyph tops and the active gold rule.
    "inline-flex items-center whitespace-nowrap border-b py-4 text-[11px] font-semibold uppercase tracking-[0.14em] transition-colors",
    active
      ? "border-burnished-gold text-gold-text"
      : "border-transparent text-on-surface-variant hover:text-gold-text",
  );
}

/** Chevron sitting over the faded edge of the category row. Mouse-only, so it
 *  stays out of the way on touch (`hidden sm:flex`). */
function arrowClass(side: "left-0" | "right-0") {
  return cn(
    "absolute inset-y-0 z-10 hidden w-7 items-center justify-center text-brushed-steel transition-colors hover:text-gold-text sm:flex",
    side,
    side === "left-0" ? "justify-start" : "justify-end",
  );
}

function ChevronIcon({ direction }: { direction: "left" | "right" }) {
  const Icon = direction === "left" ? ChevronLeftIcon : ChevronRightIcon;
  return <Icon aria-hidden="true" strokeWidth={1.6} className="size-3.5" />;
}

/**
 * Combobox-style search field with an autocomplete dropdown (article results
 * with cover thumbnails + matching category shortcuts). Rendered once for the
 * desktop inline field and once for the expanded mobile row; each instance owns
 * its own state and stable option ids (via `useId`) so the two never collide.
 */
function SearchCombobox({
  fuse,
  categories,
  placeholder,
  seeAllLabel,
  categoryRowLabel,
  clearLabel,
  categoryLabel,
  onRunSearch,
  onNavigate,
  onSelectCategory,
  onEscape,
  focusOnMount,
  className,
}: {
  fuse: Fuse<Suggestion> | null;
  categories: CategoryMatch[];
  placeholder: string;
  seeAllLabel: string;
  categoryRowLabel: string;
  clearLabel: string;
  categoryLabel: (category: CategoryId) => string;
  onRunSearch: (query: string) => void;
  onNavigate: (suggestion: Suggestion) => void;
  onSelectCategory: (id: CategoryId) => void;
  onEscape?: () => void;
  focusOnMount?: boolean;
  className?: string;
}) {
  const [value, setValue] = useState("");
  const [open, setOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const rootRef = useRef<HTMLFormElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const listboxId = useId();
  const optionId = (index: number) => `${listboxId}-opt-${index}`;

  const trimmed = value.trim();

  const options = useMemo<Option[]>(() => {
    if (trimmed.length < MIN_CHARS) {
      return [];
    }
    const needle = normalize(trimmed);
    const categoryOptions: Option[] = categories
      .filter((c) => normalize(c.label).includes(needle))
      .map((match) => ({ type: "category", match }));
    const articleOptions: Option[] = fuse
      ? fuse
          .search(trimmed)
          .slice(0, MAX_SUGGESTIONS)
          .map((result) => ({ type: "article", item: result.item }))
      : [];
    return [...categoryOptions, ...articleOptions, { type: "seeAll" }];
  }, [trimmed, categories, fuse]);

  const showDropdown = open && trimmed.length >= MIN_CHARS && options.length > 0;

  useEffect(() => {
    if (focusOnMount) {
      inputRef.current?.focus();
    }
  }, [focusOnMount]);

  useEffect(() => {
    const onPointerDown = (event: PointerEvent) => {
      if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
        setOpen(false);
        setActiveIndex(-1);
      }
    };
    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, []);

  const close = () => {
    setOpen(false);
    setActiveIndex(-1);
  };

  const selectOption = (option: Option) => {
    if (option.type === "category") {
      onSelectCategory(option.match.id);
    } else if (option.type === "article") {
      onNavigate(option.item);
    } else {
      onRunSearch(value);
    }
    close();
  };

  const submit = () => {
    const option = activeIndex >= 0 ? options[activeIndex] : undefined;
    if (option) {
      selectOption(option);
    } else {
      onRunSearch(value);
      close();
    }
  };

  const clear = () => {
    setValue("");
    close();
    inputRef.current?.focus();
  };

  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "ArrowDown") {
      if (!showDropdown) {
        return;
      }
      event.preventDefault();
      setActiveIndex((index) => Math.min(index + 1, options.length - 1));
    } else if (event.key === "ArrowUp") {
      if (!showDropdown) {
        return;
      }
      event.preventDefault();
      setActiveIndex((index) => Math.max(index - 1, 0));
    } else if (event.key === "Escape") {
      if (showDropdown) {
        close();
      } else {
        onEscape?.();
      }
    }
  };

  // The rich autocomplete uses the WAI-ARIA combobox/listbox pattern (styled
  // rows with thumbnails and badges — no native <option> can render this).
  // oxlint's jsx-a11y heuristics flag it as false positives; keyboard nav,
  // roving aria-activedescendant, Escape and outside-click are implemented above.
  /* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role, jsx-a11y/prefer-tag-over-role, jsx-a11y/click-events-have-key-events, jsx-a11y/no-redundant-roles */
  return (
    <form
      ref={rootRef}
      onSubmit={(event) => {
        event.preventDefault();
        submit();
      }}
      className={cn("relative", className)}
    >
      <input
        ref={inputRef}
        type="text"
        role="combobox"
        aria-expanded={showDropdown}
        aria-controls={listboxId}
        aria-autocomplete="list"
        aria-activedescendant={activeIndex >= 0 ? optionId(activeIndex) : undefined}
        value={value}
        onChange={(event) => {
          setValue(event.target.value);
          setOpen(true);
          setActiveIndex(-1);
        }}
        onFocus={() => setOpen(true)}
        onKeyDown={onKeyDown}
        placeholder={placeholder}
        aria-label={placeholder}
        className="w-full rounded-none border-0 border-b border-brushed-steel bg-transparent py-2 pr-7 text-sm text-foreground placeholder:text-brushed-steel focus:border-foreground focus:outline-none"
      />

      {value && (
        <button
          type="button"
          onClick={clear}
          aria-label={clearLabel}
          className="absolute right-0 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center text-brushed-steel transition-colors hover:text-foreground"
        >
          <XIcon aria-hidden="true" strokeWidth={1.6} className="size-3.5" />
        </button>
      )}

      {showDropdown && (
        <ul
          id={listboxId}
          role="listbox"
          className="absolute inset-x-0 top-full z-50 mt-1 max-h-[min(420px,60dvh)] overflow-y-auto overscroll-contain border border-hairline bg-background shadow-[0_18px_40px_-22px_rgba(26,28,27,0.35)] sm:left-auto sm:right-0 sm:w-[420px]"
        >
          {options.map((option, index) => {
            const active = activeIndex === index;
            const rowClass = cn(
              "flex cursor-pointer items-center gap-3 border-b border-hairline-soft px-4 py-2.5 last:border-b-0",
              active ? "bg-surface-container-low" : "hover:bg-surface-container-low",
            );

            if (option.type === "category") {
              return (
                <li
                  key={`cat-${option.match.id}`}
                  id={optionId(index)}
                  role="option"
                  aria-selected={active}
                  aria-label={`${categoryRowLabel}: ${option.match.label}`}
                  onClick={() => selectOption(option)}
                  onMouseEnter={() => setActiveIndex(index)}
                  className={rowClass}
                >
                  <span className="flex size-[34px] shrink-0 items-center justify-center bg-surface-container">
                    <Rule size="sm" />
                  </span>
                  <span className="min-w-0">
                    <span className="block text-[9.5px] font-semibold uppercase tracking-[0.16em] text-brushed-steel">
                      {categoryRowLabel}
                    </span>
                    <span className="block truncate text-[13.5px] text-foreground">
                      {highlight(option.match.label, value)}
                    </span>
                  </span>
                </li>
              );
            }

            if (option.type === "article") {
              return (
                <li
                  key={`art-${option.item.category}-${option.item.slug}`}
                  id={optionId(index)}
                  role="option"
                  aria-selected={active}
                  aria-label={option.item.title}
                  onClick={() => selectOption(option)}
                  onMouseEnter={() => setActiveIndex(index)}
                  className={rowClass}
                >
                  <Image
                    src={option.item.cover}
                    alt=""
                    width={64}
                    height={44}
                    className="h-11 w-16 shrink-0 bg-surface-container object-cover"
                  />
                  <span className="min-w-0">
                    <span className="line-clamp-2 block text-[13.5px] leading-snug text-foreground">
                      {highlight(option.item.title, value)}
                    </span>
                    <span className="mt-0.5 block text-[9.5px] font-semibold uppercase tracking-[0.14em] text-gold-text">
                      {categoryLabel(option.item.category)}
                    </span>
                  </span>
                </li>
              );
            }

            return (
              <li
                key="see-all"
                id={optionId(index)}
                role="option"
                aria-selected={active}
                aria-label={seeAllLabel}
                onClick={() => selectOption(option)}
                onMouseEnter={() => setActiveIndex(index)}
                className={cn(
                  "cursor-pointer px-4 py-2.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-gold-text",
                  active ? "bg-surface-container-low" : "hover:bg-surface-container-low",
                )}
              >
                {seeAllLabel}
              </li>
            );
          })}
        </ul>
      )}
    </form>
  );
}

export default function BlogSubNav({
  activeCategory,
  searchIndex,
}: {
  activeCategory?: CategoryId;
  searchIndex?: Suggestion[];
}) {
  const t = useTranslations("BlogPage");
  const tCommon = useTranslations("Common");
  const locale = useLocale() as Locale;
  const params = useSearchParams();
  const intlRouter = useIntlRouter();
  const [searchOpen, setSearchOpen] = useState(false);

  const fuse = useMemo(
    () =>
      searchIndex && searchIndex.length > 0
        ? new Fuse(searchIndex, { keys: ["title"], threshold: 0.35, ignoreLocation: true })
        : null,
    [searchIndex],
  );

  const categoryLabel = (category: CategoryId) => t(`categories.${category}`);
  const categories = useMemo<CategoryMatch[]>(
    () => BLOG_CATEGORIES.map((c) => ({ id: c.id, label: t(`categories.${c.id}`) })),
    [t],
  );

  // Active tab: explicit prop (article pages) wins; else derive from ?category (/blog).
  const fromParam = params.get("category");
  const active: CategoryId | "all" =
    activeCategory ?? (fromParam ? (categoryIdFromSlug(fromParam, locale) ?? "all") : "all");

  const runSearch = (query: string) => {
    const trimmedQuery = query.trim();
    const nextQuery: Record<string, string> = {};
    if (trimmedQuery) {
      nextQuery.q = trimmedQuery;
    }
    if (fromParam) {
      nextQuery.category = fromParam; // preserve the active category filter
    }
    intlRouter.push({ pathname: "/blog", query: nextQuery });
    setSearchOpen(false);
  };

  const goToArticle = (suggestion: Suggestion) => {
    intlRouter.push({
      pathname: "/[category]/[slug]",
      params: { category: categorySlug(suggestion.category, locale), slug: suggestion.slug },
    });
    setSearchOpen(false);
  };

  const goToCategory = (id: CategoryId) => {
    intlRouter.push({ pathname: "/blog", query: { category: categorySlug(id, locale) } });
    setSearchOpen(false);
  };

  const placeholder = t("topics.searchPlaceholder");
  const seeAllLabel = t("topics.seeAllResults");
  const categoryRowLabel = t("topics.category");
  const clearLabel = tCommon("close");

  // Center the active pill in the scrollable row. It can sit off-screen well
  // beyond phones: in English the seven labels stop fitting below ~1220px, so
  // laptops clip the tail too. Scrolls the <ul> only, never the page.
  const pillsRef = useRef<HTMLUListElement>(null);
  useEffect(() => {
    const list = pillsRef.current;
    const activeLink = list?.querySelector<HTMLElement>("[data-active]");
    if (!list || !activeLink) return;
    list.scrollLeft = Math.max(
      0,
      activeLink.offsetLeft - (list.clientWidth - activeLink.offsetWidth) / 2,
    );
  }, [active]);

  // Track which edge is overflowing. The scrollbar is hidden by design, so
  // without this the row was silently clipped: nothing on screen said the last
  // categories existed, and a mouse (unlike a trackpad) emits no horizontal
  // delta to reach them.
  const [overflow, setOverflow] = useState({ start: false, end: false });
  useEffect(() => {
    const list = pillsRef.current;
    if (!list) return;
    let alive = true;

    const sync = () => {
      if (!alive) return;
      const max = list.scrollWidth - list.clientWidth;
      setOverflow({ start: list.scrollLeft > 1, end: list.scrollLeft < max - 1 });
    };

    sync();
    list.addEventListener("scroll", sync, { passive: true });
    // The <ul> is flex-1, so it resizes with the viewport; this covers both the
    // window resize and the mobile search row expanding.
    const observer = new ResizeObserver(sync);
    observer.observe(list);
    // Label widths shift once the webfont swaps in, which changes scrollWidth
    // without resizing the container.
    void document.fonts.ready.then(sync);

    return () => {
      alive = false;
      list.removeEventListener("scroll", sync);
      observer.disconnect();
    };
  }, []);

  const scrollPills = (direction: -1 | 1) => {
    const list = pillsRef.current;
    if (!list) return;
    list.scrollBy({ left: direction * list.clientWidth * 0.8, behavior: "smooth" });
  };

  const comboboxProps = {
    fuse,
    categories,
    placeholder,
    seeAllLabel,
    categoryRowLabel,
    clearLabel,
    categoryLabel,
    onRunSearch: runSearch,
    onNavigate: goToArticle,
    onSelectCategory: goToCategory,
  };

  return (
    <nav
      aria-label={t("indexTitle")}
      className="sticky top-[var(--header-h,75px)] z-40 border-b border-outline-soft bg-background/95 backdrop-blur transition-[top] duration-300 lg:top-[var(--header-h,90px)]"
    >
      <div className="mx-auto flex w-full max-w-[1440px] items-center gap-6 px-6 lg:px-16">
        {/* min-w-0: iOS Safari does not shrink a flex child with overflow-x-auto
            without it, pushing the search button off-screen. */}
        <div className="relative flex min-w-0 flex-1 items-center">
          {/* scroll-fade-x (shadcn) fades whichever edge still has content, via
              a scroll-driven animation with a static fallback. */}
          <ul
            ref={pillsRef}
            className="scroll-fade-x flex w-full items-center gap-4 overflow-x-auto no-scrollbar [--scroll-fade-size:2.5rem] lg:gap-6"
          >
            <li>
              <Link
                href="/blog"
                data-active={active === "all" || undefined}
                className={tabClass(active === "all")}
              >
                {t("topics.all")}
              </Link>
            </li>
            {BLOG_CATEGORIES.map((c) => (
              <li key={c.id}>
                <Link
                  href={{ pathname: "/blog", query: { category: c.slug[locale as "en" | "fr"] } }}
                  data-active={active === c.id || undefined}
                  className={tabClass(active === c.id)}
                >
                  {t(`categories.${c.id}`)}
                </Link>
              </li>
            ))}
          </ul>

          {/* Pointer affordance for the hidden scrollbar. Touch users swipe and
              keyboard users tab (which scrolls the link into view natively), so
              these only need to exist for the mouse. */}
          {overflow.start ? (
            <button
              type="button"
              onClick={() => scrollPills(-1)}
              aria-label={t("topics.scrollPrev")}
              className={arrowClass("left-0")}
            >
              <ChevronIcon direction="left" />
            </button>
          ) : null}
          {overflow.end ? (
            <button
              type="button"
              onClick={() => scrollPills(1)}
              aria-label={t("topics.scrollNext")}
              className={arrowClass("right-0")}
            >
              <ChevronIcon direction="right" />
            </button>
          ) : null}
        </div>

        {/* Desktop: inline search field with autocomplete. */}
        {/* Narrower until xl so it steals less room from the category row,
            which is what overflows first. */}
        <SearchCombobox className="hidden w-44 shrink-0 sm:block xl:w-56" {...comboboxProps} />

        {/* Mobile: icon toggle that expands the field below the bar. */}
        <button
          type="button"
          onClick={() => setSearchOpen((o) => !o)}
          aria-label={placeholder}
          aria-expanded={searchOpen}
          className="flex size-9 shrink-0 items-center justify-center border border-hairline text-foreground transition-colors hover:border-foreground sm:hidden"
        >
          <SearchIcon aria-hidden="true" strokeWidth={1.6} className="size-4" />
        </button>
      </div>

      {/* Mobile: expanded search row. */}
      {searchOpen ? (
        <div className="border-t border-hairline-soft px-6 py-3 sm:hidden">
          <SearchCombobox
            className="w-full"
            focusOnMount
            onEscape={() => setSearchOpen(false)}
            {...comboboxProps}
          />
        </div>
      ) : null}
    </nav>
  );
}
