import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown } from 'lucide-react';
import { Input } from './input';
import { cn } from '@/lib/utils';

// Searchable combobox: type to filter, click/Enter to pick. On blur it commits
// only an exact (case-insensitive) match — otherwise it reverts, so the field
// can't hold a typo'd value that isn't in the list.
export function Combobox({
  value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
  showToggle = false, onWheelStep,
}: {
  value: string;
  onChange: (v: string) => void;
  options: string[];
  placeholder?: string;
  className?: string;
  allowFreeText?: boolean;
  // Commit each keystroke to the parent immediately (not just on blur). Use for
  // fields read live by other actions — e.g. RST, so a CW macro sent without
  // leaving the field uses the value just typed.
  commitOnType?: boolean;
  // Wheel over the field steps the value. The control cannot know what a step
  // means — a decibel here, an S-unit there — so it reports the direction and
  // the owner decides.
  onWheelStep?: (dir: 1 | -1) => void;
  // Draw a chevron that opens the full list on click.
  //
  // Without it this control is indistinguishable from a plain text box: it opens
  // only on a keystroke or ArrowDown, both of which have to be known about
  // first. That is fine for a field whose list is a convenience (RST), and wrong
  // for one whose list is the ANSWER — the COM ports actually present on this
  // machine, which nobody can be expected to recall.
  showToggle?: boolean;
}) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState('');
  // Opened by the chevron rather than by typing: the whole list is on offer, not
  // the part matching what is already in the field — a box holding COM7 would
  // otherwise "open" onto COM7 alone.
  const [browse, setBrowse] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  // Where the portalled menu goes. Measured from the field itself, and
  // re-measured while it is open, so scrolling the panel underneath does not
  // leave the list floating over the wrong row.
  const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 });

  // A NATIVE, non-passive listener: React attaches onWheel passively, where
  // preventDefault does nothing at all and the panel scrolls away under the
  // field being adjusted.
  useEffect(() => {
    const el = ref.current;
    if (!el || !onWheelStep) return;
    const onWheel = (e: WheelEvent) => {
      if (e.deltaY === 0) return;
      e.preventDefault();
      onWheelStep(e.deltaY < 0 ? 1 : -1); // up is a better report
    };
    el.addEventListener('wheel', onWheel, { passive: false });
    return () => el.removeEventListener('wheel', onWheel);
  }, [onWheelStep]);

  useEffect(() => {
    function onDoc(e: MouseEvent) {
      if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
    }
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, []);

  useLayoutEffect(() => {
    if (!open) return;
    const place = () => {
      const el = ref.current;
      if (!el) return;
      const r = el.getBoundingClientRect();
      // Opens UPWARD when there is not enough room below — which is exactly
      // where these fields tend to sit, at the bottom of a panel.
      const height = 240;
      const below = window.innerHeight - r.bottom;
      const top = below < height + 8 ? Math.max(4, r.top - height - 4) : r.bottom + 4;
      setMenuPos({ top, left: r.left, width: r.width });
    };
    place();
    window.addEventListener('scroll', place, true);
    window.addEventListener('resize', place);
    return () => {
      window.removeEventListener('scroll', place, true);
      window.removeEventListener('resize', place);
    };
  }, [open]);

  const filtered = !open ? []
    : browse ? options.slice(0, 60)
      : options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60);

  function commit(v: string) {
    onChange(v);
    setQuery(v);
    setOpen(false);
    setBrowse(false);
  }

  function onBlur() {
    // Defer so a click on an option registers first.
    setTimeout(() => {
      setOpen(false);
      setBrowse(false);
      const trimmed = query.trim();
      const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase());
      // Only fire onChange when the value actually changed — committing an
      // unchanged value on a plain tab-through would wrongly flag the field as
      // "user-edited" (e.g. RST, which then blocks the CW/SSB 599↔59 default).
      if (exact) { if (exact !== value) onChange(exact); setQuery(exact); }
      else if (allowFreeText) { if (trimmed !== value) onChange(trimmed); }
      else { setQuery(value); } // revert typo
    }, 120);
  }

  return (
    <div ref={ref} className={cn('relative', className)}>
      <Input
        value={open ? query : value}
        placeholder={placeholder}
        // Focus selects the text so a keystroke replaces it — but does NOT
        // open the list (so tabbing in doesn't pop the dropdown).
        onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
        onChange={(e) => {
          const v = e.target.value;
          setQuery(v);
          setOpen(true);
          setBrowse(false); // typing filters again
          // Commit-on-type pushes the value live to the parent (so a CW macro sent
          // without leaving the field uses what was just typed). With free text that's
          // any input; a restricted field (allowFreeText=false) commits ONLY a value
          // that's actually in the list, so a half-typed or invalid report never
          // becomes the committed value — blur then reverts the leftover text.
          if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v);
        }}
        onBlur={onBlur}
        className={showToggle ? 'pr-7' : undefined}
        onKeyDown={(e) => {
          if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); setBrowse(true); }
          else if (e.key === 'Enter' && open && filtered.length > 0) { e.preventDefault(); commit(filtered[0]); }
          else if (e.key === 'Escape') { setQuery(value); setOpen(false); }
          // Tab: just let it move on; onBlur commits/closes. Options are
          // tabIndex=-1 so a single Tab leaves the field.
        }}
      />
      {showToggle && (
        <button
          type="button"
          tabIndex={-1}
          aria-label="Show list"
          className="absolute inset-y-0 right-0 flex w-7 items-center justify-center text-muted-foreground hover:text-foreground"
          // mousedown, not click: the input's blur fires first otherwise and
          // closes the list the same instant this opens it.
          onMouseDown={(e) => {
            e.preventDefault();
            if (open) { setOpen(false); setBrowse(false); return; }
            setQuery(value);
            setBrowse(true);
            setOpen(true);
          }}
        >
          <ChevronDown className="size-3.5" />
        </button>
      )}
      {/* THE MENU IS PORTALLED to the body, and positioned from the field's own
          rectangle. As an absolutely-positioned child it was clipped by whichever
          scrolling or overflow-hidden box it happened to sit in: in the details
          panel it was cut off after the first row, and a list showing one entry
          of eight is worse than no list at all. */}
      {open && filtered.length > 0 && createPortal(
        <div
          style={{ position: 'fixed', top: menuPos.top, left: menuPos.left, width: menuPos.width }}
          className="z-[100] max-h-60 overflow-auto rounded-md border border-border bg-card shadow-lg text-xs"
        >
          {filtered.map((o) => (
            <button
              key={o}
              type="button"
              tabIndex={-1}
              className="block w-full text-left px-2 py-1 hover:bg-accent/40"
              onMouseDown={(e) => { e.preventDefault(); commit(o); }}
            >
              {o}
            </button>
          ))}
        </div>,
        document.body,
      )}
    </div>
  );
}
