import { useEffect, useMemo, useRef, useState } from 'react';
import { validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';

// One input per BIP39 word, with dictionary autocomplete — the same shape Zeus and every other serious
// wallet uses for recovery, and for the same reason: a 24-word phrase typed into one freeform box is
// impossible to proofread, and a single mistyped word fails the checksum with no clue which one is wrong.
// Per-word boxes turn that into an immediately visible red border on word 17.
//
// SECRET IN FLIGHT. The words live in the parent's state and go straight into the request body. Nothing
// here persists, and every field carries the full set of autofill opt-outs (autoComplete="off" plus the
// 1Password/LastPass/Bitwarden ignore attributes) — a password manager silently capturing a seed phrase
// would be the single worst thing this component could do.

const WORDS_PER_ROW_HINT = 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4';

// Generous, and the list scrolls. A small cap silently hides real words — "fa" alone has 22 matches and
// "fan" is the twelfth, so a cap of 6 makes a perfectly valid word look like it is not in the dictionary.
// Truncation here must never be how the owner learns whether their seed word exists.
const MAX_SUGGESTIONS = 64;

/** BIP39 permits these lengths. 12 and 24 dominate, but a restore must not be blocked by the others. */
export const SEED_LENGTHS = [12, 15, 18, 21, 24] as const;
export type SeedLength = (typeof SEED_LENGTHS)[number];

const VALID = new Set<string>(wordlist);

/**
 * Wordlist entries starting with `prefix`, shortest first (alphabetical within a length). Empty prefix
 * yields nothing — suggestions need intent.
 *
 * Shortest-first matters more than it looks. A fully typed word is the shortest thing that can match
 * itself, so it always lands at position 1 — you never hunt for confirmation that your word exists, and
 * the default highlight is always the exact match rather than some longer word that merely shares a
 * prefix. It also front-loads the short words that alphabetical order buries: "fan" is 12th of 22 under
 * "fa" alphabetically, but 2nd by length.
 *
 * Collect-then-sort, never truncate-then-sort: capping during the scan would drop short matches that
 * happen to sort late. Scanning all 2048 entries per keystroke is free.
 */
function suggest(prefix: string): string[] {
  if (!prefix) return [];
  const matches = wordlist.filter((w) => w.startsWith(prefix));
  matches.sort((a, b) => a.length - b.length || a.localeCompare(b));
  return matches.slice(0, MAX_SUGGESTIONS);
}

/**
 * Split arbitrary pasted text into candidate words.
 *
 * Deliberately brutal: BIP39 English words are pure a-z, so everything else is separator. That makes the
 * paste survive the shapes real backups actually arrive in — numbered lists ("1. fan 2. zoo"), commas,
 * hard line breaks, non-breaking spaces, smart quotes from a notes app. Without this, a numbered paste
 * silently becomes twenty-four "words" of digits and the owner is left staring at a wall of red.
 */
export function normalizePhrase(text: string): string[] {
  return text
    .normalize('NFKD')
    .toLowerCase()
    .replace(/[^a-z\s]/g, ' ')
    .split(/\s+/)
    .filter(Boolean);
}

export type PhraseProblem =
  /** Boxes still blank (1-based, for display). */
  | { kind: 'incomplete'; boxes: number[] }
  /** Boxes holding something that is not a BIP39 word at all (1-based). */
  | { kind: 'unknown'; boxes: number[] }
  /** Not a permitted BIP39 length. Only reachable by pasting an odd count. */
  | { kind: 'length'; count: number }
  /** Every word is real and the count is legal, but the checksum says the phrase is not this phrase. */
  | { kind: 'checksum' };

/** CRC32-Castagnoli (poly 0x82F63B78) — the polynomial LND's aezeed uses. Not the IEEE one. */
const CRC32C_TABLE = Uint32Array.from({ length: 256 }, (_, n) => {
  let c = n;
  for (let k = 0; k < 8; k++) c = c & 1 ? 0x82f63b78 ^ (c >>> 1) : c >>> 1;
  return c >>> 0;
});

function crc32c(bytes: Uint8Array): number {
  let c = 0xffffffff;
  for (const b of bytes) c = CRC32C_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8);
  return (c ^ 0xffffffff) >>> 0;
}

/**
 * Zeus (and anything else running an embedded LND) does NOT hand out BIP39 mnemonics. It hands out
 * *aezeed* cipher seeds: 24 words drawn from the very same BIP39 English wordlist, so they are visually
 * indistinguishable, but the trailing bits are a CRC32-Castagnoli checksum instead of a SHA-256 one.
 * They can never validate as BIP39, no matter how carefully the owner proofreads.
 *
 * Officer imports these — the sidecar deciphers the AEZ ciphertext and uses the 16 bytes inside as the
 * BIP32 master seed directly. But BIP39 validation still fails on them, so without this check the owner
 * would be told "invalid checksum" for a phrase that is perfectly correct.
 *
 * This mirrors `isAezeedPhrase` in the sidecar (`wallet/aezeed/cipher-seed.ts`). It is duplicated rather
 * than shared because it must run in the browser, where nothing under `src/servers` is reachable — and
 * it is pure structure, no secret and no key material, so it is safe to ship client-side. The sidecar
 * still decides independently; this only makes the form honest before the round trip.
 *
 * Layout: version(1) + ciphertext(23) + salt(5) + crc32c(4) = 33 bytes = 24 x 11 bits.
 * Verified against LND's own published test vector.
 */
export function isAezeed(words: string[]): boolean {
  if (words.length !== 24) return false;

  let bits = '';
  for (const word of words) {
    const index = wordlist.indexOf(word);
    if (index < 0) return false;
    bits += index.toString(2).padStart(11, '0');
  }

  const bytes = Uint8Array.from(bits.match(/.{8}/g)!.map((b) => parseInt(b, 2)));
  if (bytes[0] !== 0) return false; // aezeed CipherSeedVersion

  const view = new DataView(bytes.buffer);
  return crc32c(bytes.subarray(0, 29)) === view.getUint32(29, false);
}

/**
 * What is wrong with the phrase, checked locally so the owner never spends a network round trip — or
 * risks the form state — to learn they mistyped word 17. This mirrors what the sidecar does (BIP39
 * checksum, else aezeed structure), just run earlier; the sidecar remains the authority and still
 * rejects independently. Purely local: nothing is transmitted to validate.
 *
 * An aezeed phrase is NOT a problem — it is a supported second format, so it returns null here and the
 * caller uses `isAezeed` separately to explain what it is looking at.
 */
export function phraseProblem(words: string[]): PhraseProblem | null {
  const trimmed = words.map((w) => w.trim().toLowerCase());

  const blank = trimmed.map((w, i) => (w ? -1 : i + 1)).filter((i) => i > 0);
  if (blank.length) return { kind: 'incomplete', boxes: blank };

  const unknown = trimmed.map((w, i) => (VALID.has(w) ? -1 : i + 1)).filter((i) => i > 0);
  if (unknown.length) return { kind: 'unknown', boxes: unknown };

  if (!(SEED_LENGTHS as readonly number[]).includes(trimmed.length)) {
    return { kind: 'length', count: trimmed.length };
  }

  if (validateMnemonic(trimmed.join(' '), wordlist)) return null;

  // Checked before the generic failure: an aezeed seed is a different supported format, not a typo.
  if (isAezeed(trimmed)) return null;
  return { kind: 'checksum' };
}

/**
 * The words are all real and the checksum still fails — so look for the one mistake that is invisible
 * when you proofread: right words, wrong order.
 *
 * Wallets print backups in a grid, and plenty of them run the phrase DOWN the columns (1-12 left, 13-24
 * right). Read ACROSS instead and you get every word present, every word spelled correctly, every box
 * green, and a phrase that is simply not yours. Nothing in a word-by-word review can catch it, because
 * every word individually is fine.
 *
 * That misreading is exactly a matrix transpose, so it is cheap to test: for each rows x cols factoring
 * of the length, un-transpose and re-check. A handful of validations, and it either finds the owner's
 * real phrase or rules the theory out. Returns null when the order is not the problem.
 *
 * NOT a brute-force search. Trying every single-word substitution is useless here — the checksum is only
 * 8 bits, so ~8 different words validate at EVERY position and it can localise nothing.
 */
export function findOrderFix(words: string[]): { rows: number; cols: number; words: string[] } | null {
  const n = words.length;
  for (let rows = 2; rows < n; rows++) {
    if (n % rows) continue;
    const cols = n / rows;
    const fixed = new Array<string>(n);
    for (let i = 0; i < rows; i++) for (let j = 0; j < cols; j++) fixed[j * rows + i] = words[i * cols + j]!;
    if (validateMnemonic(fixed.join(' '), wordlist)) return { rows, cols, words: fixed };
  }
  return null;
}

type SeedPhraseInputProps = {
  words: string[];
  onChange: (words: string[]) => void;
  /**
   * Fired when a pasted phrase is itself a valid BIP39 length that differs from the current box count,
   * so the parent can resize instead of making the owner notice the mismatch and fix the toggle by hand.
   */
  onLengthDetected?: (count: number) => void;
  disabled?: boolean;
};

export const SeedPhraseInput = ({ words, onChange, onLengthDetected, disabled }: SeedPhraseInputProps) => {
  const refs = useRef<(HTMLInputElement | null)[]>([]);
  const [focused, setFocused] = useState<number | null>(null);
  const [highlight, setHighlight] = useState(0);
  /** Whether the owner moved the highlight by hand — the difference between "chose that word" and
   *  "happened to have it highlighted". Reset on every keystroke and on leaving the box. */
  const [arrowed, setArrowed] = useState(false);

  const suggestions = useMemo(() => (focused == null ? [] : suggest(words[focused] ?? '')), [focused, words]);

  // The list shrinks as you type, so a stale index can point past the end. Clamp at read time rather than
  // trying to keep `highlight` in sync with every keystroke.
  const safeHighlight = suggestions.length ? Math.min(highlight, suggestions.length - 1) : 0;

  const highlightRef = useRef<HTMLButtonElement | null>(null);
  useEffect(() => {
    highlightRef.current?.scrollIntoView({ block: 'nearest' });
  }, [safeHighlight, focused]);

  const setWord = (index: number, value: string) => {
    const next = [...words];
    next[index] = value;
    onChange(next);
  };

  const focus = (index: number) => {
    const el = refs.current[index];
    if (!el) return;
    el.focus();
    el.select();
  };

  /** Accept a suggestion (or whatever is typed) and move on. The last box stays put. */
  const commit = (index: number, value: string) => {
    setWord(index, value);
    setHighlight(0);
    setArrowed(false);
    if (index < words.length - 1) requestAnimationFrame(() => focus(index + 1));
  };

  // Pasting the whole phrase into any box is how people actually restore. Spread it across the boxes
  // from here on rather than dumping 24 words into one field.
  const handlePaste = (index: number, ev: React.ClipboardEvent<HTMLInputElement>) => {
    const parts = normalizePhrase(ev.clipboardData.getData('text'));
    if (parts.length < 2) return;
    ev.preventDefault();

    // A whole phrase pasted into box 1 of the wrong-size grid: resize rather than truncate. Only from the
    // first box — pasting a fragment mid-phrase should never resize the form.
    if (index === 0 && parts.length !== words.length && (SEED_LENGTHS as readonly number[]).includes(parts.length)) {
      onLengthDetected?.(parts.length);
      onChange(parts);
      requestAnimationFrame(() => focus(parts.length - 1));
      return;
    }

    const next = [...words];
    for (let i = 0; i < parts.length && index + i < next.length; i++) next[index + i] = parts[i]!;
    onChange(next);

    const landed = Math.min(index + parts.length, next.length - 1);
    requestAnimationFrame(() => focus(landed));
  };

  const handleKeyDown = (index: number, ev: React.KeyboardEvent<HTMLInputElement>) => {
    const current = words[index] ?? '';

    if (ev.key === 'ArrowDown' && suggestions.length) {
      ev.preventDefault();
      setArrowed(true);
      setHighlight((safeHighlight + 1) % suggestions.length);
      return;
    }
    if (ev.key === 'ArrowUp' && suggestions.length) {
      ev.preventDefault();
      setArrowed(true);
      setHighlight((safeHighlight - 1 + suggestions.length) % suggestions.length);
      return;
    }

    // Space and Enter advance. Tab does NOT accept — tab is navigation, and hijacking it to mean
    // "accept" makes it impossible to skip a box you meant to come back to.
    //
    // A suggestion is only substituted for what you typed when the choice is UNAMBIGUOUS: you arrowed to
    // it deliberately, or it is the single remaining match, or you already typed the whole word. Typing
    // "fa" and hitting space must NOT silently store "fabric" — for a seed phrase, a wrong-but-valid word
    // is the worst possible outcome, because it passes every check this component can do and only fails
    // later as an opaque checksum error with no clue which box is at fault. Ambiguous input is left
    // exactly as typed, where the missing checkmark makes it obvious.
    if (ev.key === ' ' || ev.key === 'Enter') {
      if (!current) return;
      ev.preventDefault();
      const unambiguous = arrowed || suggestions.length === 1 || VALID.has(current);
      commit(index, unambiguous ? (suggestions[safeHighlight] ?? current) : current);
      return;
    }

    // Backspace out of an empty box steps back, so correcting a run of words never needs the mouse.
    if (ev.key === 'Backspace' && !current && index > 0) {
      ev.preventDefault();
      focus(index - 1);
    }
  };

  return (
    <div className={cn('grid gap-2', WORDS_PER_ROW_HINT)}>
      {words.map((word, index) => {
        const valid = VALID.has(word);
        const invalid = word.length > 0 && !valid;
        // Shown whenever the box has focus and anything matches — NOT gated on the word being invalid.
        // "fan" is both a complete word and a prefix of "fancy"/"fantasy"; hiding the list the moment the
        // text happens to be valid strands the owner mid-word with no way to see the longer options.
        const showSuggestions = focused === index && suggestions.length > 0;

        return (
          <div key={index} className="relative">
            <div
              className={cn(
                'flex h-10 items-center rounded-md border bg-background pl-2 focus-within:ring-2 focus-within:ring-ring',
                invalid ? 'border-destructive' : 'border-input',
              )}
            >
              <span className="w-5 shrink-0 select-none text-right font-mono text-[11px] text-muted-foreground">
                {index + 1}
              </span>
              <input
                ref={(el) => {
                  refs.current[index] = el;
                }}
                value={word}
                disabled={disabled}
                onChange={(ev) => {
                  setWord(index, ev.target.value.trim().toLowerCase());
                  // Typing invalidates a deliberate pick — the list underneath just changed.
                  setHighlight(0);
                  setArrowed(false);
                }}
                onFocus={() => {
                  setFocused(index);
                  setHighlight(0);
                  setArrowed(false);
                }}
                onBlur={() => setFocused((f) => (f === index ? null : f))}
                onPaste={(ev) => handlePaste(index, ev)}
                onKeyDown={(ev) => handleKeyDown(index, ev)}
                autoComplete="off"
                autoCapitalize="none"
                autoCorrect="off"
                spellCheck={false}
                data-1p-ignore
                data-lpignore="true"
                data-bwignore
                className="h-full w-full bg-transparent px-2 font-mono text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
              />
              {/* Positive confirmation, not just the absence of red: a typo that happens to be another
                  real word is caught by reading the phrase back, but a typo that is NOT a word should be
                  obvious without having to compare against the dropdown. */}
              {valid && <Check className="mr-2 h-3.5 w-3.5 shrink-0 text-emerald-500" />}
            </div>

            {showSuggestions && (
              // onMouseDown, not onClick: blur fires first on click and would unmount the list mid-press.
              <ul className="absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-md border border-input bg-popover shadow-md">
                {suggestions.map((s, i) => (
                  <li key={s}>
                    <button
                      type="button"
                      tabIndex={-1}
                      // Keeps the highlighted row visible while arrowing through a long list.
                      ref={i === safeHighlight ? highlightRef : undefined}
                      onMouseDown={(ev) => {
                        ev.preventDefault();
                        commit(index, s);
                      }}
                      onMouseEnter={() => setHighlight(i)}
                      className={cn(
                        'flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left font-mono text-sm',
                        i === safeHighlight ? 'bg-accent text-accent-foreground' : 'text-foreground',
                      )}
                    >
                      <span>{s}</span>
                      {/* The exact match, called out — this is the "yes, that word really exists" signal
                          when the typed word is also a prefix of longer ones (fan / fancy / fantasy). */}
                      {s === word && <Check className="h-3.5 w-3.5 shrink-0 text-emerald-500" />}
                    </button>
                  </li>
                ))}
              </ul>
            )}
          </div>
        );
      })}
    </div>
  );
};
