/**
 * ⌘K — pages and the book, in one ranking.
 *
 * This used to search page names only, so every account, deal and contract in
 * the book answered "No pages found". That is the opposite of what anyone
 * presses ⌘K for: the reflex is the palette, then a customer's name.
 *
 * Records are read through the SAME react-query keys the list pages use, so a
 * palette opened after a visit to Accounts or a pipeline board costs nothing,
 * and typing costs no requests at all — the book is fetched once per open and
 * filtered in memory. A search that issued a request per keystroke would be
 * slower than opening the page it is trying to save you from.
 */
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import { Building2, FileText, Handshake, type LucideIcon } from 'lucide-react';
import {
  DEMAND_STAGE_LABELS,
  SUPPLY_STAGE_LABELS,
  type AccountSide,
  type ContractStatus,
  type ContractType,
  type DemandStage,
  type SupplyStage,
} from '@pig/core';
import {
  CommandDialog,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
  CommandShortcut,
} from '@/components/ui/command';
import { get, money } from '@/lib/api';
import { useIdentity } from '@/lib/identity';
import { canAny, type PermissionIdentity } from '@/lib/permissions';

export interface CommandDestination {
  to: string;
  label: string;
  icon: LucideIcon;
  shortcut?: string;
  group?: string;
}

/**
 * Where a record lives.
 *
 * One line each, deliberately. `/accounts/:id` is a real detail route; the
 * deal boards and the contract list still select in local state, so those two
 * land on the right page with the id carried in the query string — the honest
 * destination today, and a one-line change to a detail route the moment one
 * exists.
 */
const RECORD_ROUTES = {
  account: (id: string) => `/accounts/${id}`,
  demandDeal: (id: string) => `/demand?deal=${id}`,
  supplyDeal: (id: string) => `/supply?deal=${id}`,
  contract: (id: string) => `/contracts?contract=${id}`,
};

/**
 * Enough rows to recognise the one you meant, few enough that the palette does
 * not become the list page. The heading says when there are more, because a
 * silent cap is indistinguishable from a missing record.
 */
const ROWS_PER_GROUP = 5;

type RecordKind = 'account' | 'deal' | 'contract';

interface RecordHit {
  kind: RecordKind;
  id: string;
  name: string;
  /** Secondary line: what tells two similarly named records apart. */
  meta: string;
  /** Right-hand figure — money or capacity — where the record has one. */
  trailing?: string;
  to: string;
  /** Lowercased match text, including terms the row does not display. */
  haystack: string;
}

const RECORD_GROUPS: readonly { kind: RecordKind; heading: string; icon: LucideIcon }[] = [
  { kind: 'account', heading: 'Accounts', icon: Building2 },
  { kind: 'deal', heading: 'Deals', icon: Handshake },
  { kind: 'contract', heading: 'Contracts', icon: FileText },
];

const SIDE_LABELS: Record<AccountSide, string> = {
  supply: 'Supply',
  demand: 'Demand',
  both: 'Supply & demand',
};

const CONTRACT_TYPE_LABELS: Record<ContractType, string> = {
  msa: 'MSA',
  dpa: 'DPA',
  sla: 'SLA',
  order_form: 'Order form',
  capacity_commitment: 'Capacity commitment',
  nda: 'NDA',
  amendment: 'Amendment',
};

/** Enum values are snake_case everywhere; this is presentation, not a table. */
function humanise(value: string): string {
  const spaced = value.replace(/_/g, ' ');
  return spaced.charAt(0).toLocaleUpperCase() + spaced.slice(1);
}

function joinMeta(parts: (string | null | undefined)[]): string {
  return parts.filter((part): part is string => Boolean(part)).join(' · ');
}

/** The words a row must contain. An empty query asks nothing and matches all. */
function queryWords(query: string): string[] {
  const needle = query.trim().toLocaleLowerCase();
  return needle ? needle.split(/\s+/) : [];
}

/**
 * Every word, in any order — so "labs tess" and "tess labs" both find
 * Tessellate Labs, which a plain substring test would not.
 *
 * This gate is also what keeps the ranking sane. cmdk scores with
 * command-score, which is a subsequence matcher: it rates "Import Records"
 * against "tess" at 0.003 rather than zero, and it leaves groups in the order
 * they were written. Left to itself the palette therefore put four irrelevant
 * pages above the account someone had just typed the name of, with the first
 * of them selected — so Enter opened Import. Filtering both pages and records
 * on whole words first means everything cmdk still sees is a genuine match.
 */
function matchesWords(haystack: string, words: readonly string[]): boolean {
  return words.every((word) => haystack.includes(word));
}

interface AccountRow {
  id: string;
  name: string;
  domain: string | null;
  side: AccountSide;
  country: string | null;
  customerSegment: string | null;
  supplierType: string | null;
}

interface DealBoard<T> {
  deals: { deal: T; accountName: string | null }[];
}

interface DemandDealRow {
  id: string;
  name: string;
  stage: DemandStage;
  productLine: string;
  acvCents: number | null;
  currency: string;
}

interface SupplyDealRow {
  id: string;
  name: string;
  stage: SupplyStage;
  gpuType: string | null;
  gpuCount: number | null;
}

interface ContractRow {
  contract: {
    id: string;
    title: string;
    type: ContractType;
    status: ContractStatus;
    valueCents: number | null;
    currency: string;
  };
  accountName: string | null;
}

function hit(fields: Omit<RecordHit, 'haystack'> & { hidden?: string }): RecordHit {
  const { hidden, ...record } = fields;
  return {
    ...record,
    haystack: `${record.name} ${record.meta} ${record.trailing ?? ''} ${hidden ?? ''}`.toLocaleLowerCase(),
  };
}

function accountHits(rows: AccountRow[] | undefined): RecordHit[] {
  return (rows ?? []).map((account) =>
    hit({
      kind: 'account',
      id: account.id,
      name: account.name,
      meta: joinMeta([SIDE_LABELS[account.side], account.domain, account.country]),
      to: RECORD_ROUTES.account(account.id),
      hidden: joinMeta([account.customerSegment, account.supplierType]),
    }),
  );
}

function demandDealHits(board: DealBoard<DemandDealRow> | undefined): RecordHit[] {
  return (board?.deals ?? []).map(({ deal, accountName }) =>
    hit({
      kind: 'deal',
      id: deal.id,
      name: deal.name,
      meta: joinMeta(['Demand', accountName, DEMAND_STAGE_LABELS[deal.stage] ?? humanise(deal.stage)]),
      trailing: deal.acvCents == null ? undefined : money(deal.acvCents, deal.currency),
      to: RECORD_ROUTES.demandDeal(deal.id),
      hidden: humanise(deal.productLine),
    }),
  );
}

function supplyDealHits(board: DealBoard<SupplyDealRow> | undefined): RecordHit[] {
  return (board?.deals ?? []).map(({ deal, accountName }) =>
    hit({
      kind: 'deal',
      id: deal.id,
      name: deal.name,
      meta: joinMeta(['Supply', accountName, SUPPLY_STAGE_LABELS[deal.stage] ?? humanise(deal.stage)]),
      trailing:
        deal.gpuCount != null && deal.gpuType ? `${deal.gpuCount}× ${deal.gpuType}` : undefined,
      to: RECORD_ROUTES.supplyDeal(deal.id),
      hidden: deal.gpuType ?? '',
    }),
  );
}

function contractHits(rows: ContractRow[] | undefined): RecordHit[] {
  return (rows ?? []).map(({ contract, accountName }) =>
    hit({
      kind: 'contract',
      id: contract.id,
      name: contract.title,
      meta: joinMeta([
        accountName,
        CONTRACT_TYPE_LABELS[contract.type],
        humanise(contract.status),
      ]),
      trailing:
        contract.valueCents == null
          ? undefined
          : money(contract.valueCents, contract.currency),
      to: RECORD_ROUTES.contract(contract.id),
    }),
  );
}

/**
 * A hit on the record's own name beats one that only matched its second line,
 * so typing an account's name puts the account above the several deals that
 * merely mention it. cmdk re-scores whatever survives; this decides which rows
 * survive the cap, which is the decision cmdk cannot make for us.
 */
function rankOf(record: RecordHit, needle: string): number {
  const name = record.name.toLocaleLowerCase();
  if (name.startsWith(needle)) return 0;
  if (name.includes(needle)) return 1;
  return 2;
}

interface BookSearch {
  hits: RecordHit[];
  isLoading: boolean;
  /** Every source failed — the palette can only offer pages. */
  isUnavailable: boolean;
  /** At least one source failed, so the results are known to be incomplete. */
  isIncomplete: boolean;
}

/**
 * The book, filtered.
 *
 * Every key here is copied from the page that owns it — `['accounts', 'all']`
 * from Accounts, the endpoint-keyed boards from Pipeline, `['contracts']` from
 * Contracts — so this shares their cache rather than shadowing it with a
 * fourth copy of the same rows.
 */
function useBookSearch(query: string, enabled: boolean): BookSearch {
  const accounts = useQuery({
    queryKey: ['accounts', 'all'],
    queryFn: () => get<AccountRow[]>('/api/accounts'),
    enabled,
  });
  const demand = useQuery({
    queryKey: ['/api/deals/demand'],
    queryFn: () => get<DealBoard<DemandDealRow>>('/api/deals/demand'),
    enabled,
  });
  const supply = useQuery({
    queryKey: ['/api/deals/supply'],
    queryFn: () => get<DealBoard<SupplyDealRow>>('/api/deals/supply'),
    enabled,
  });
  const contracts = useQuery({
    queryKey: ['contracts'],
    queryFn: () => get<ContractRow[]>('/api/contracts'),
    enabled,
  });

  const all = useMemo(
    () => [
      ...accountHits(accounts.data),
      ...demandDealHits(demand.data),
      ...supplyDealHits(supply.data),
      ...contractHits(contracts.data),
    ],
    [accounts.data, demand.data, supply.data, contracts.data],
  );

  const hits = useMemo(() => {
    const needle = query.trim().toLocaleLowerCase();
    const words = queryWords(query);
    if (words.length === 0) return [];
    return all
      .filter((record) => matchesWords(record.haystack, words))
      .sort((left, right) => rankOf(left, needle) - rankOf(right, needle));
  }, [all, query]);

  const queries: UseQueryResult<unknown>[] = [accounts, demand, supply, contracts];
  const failed = queries.filter((result) => result.isError).length;
  return {
    hits,
    isLoading: queries.some((result) => result.isLoading),
    isUnavailable: failed === queries.length,
    isIncomplete: failed > 0,
  };
}

/**
 * What the palette can actually search for this person, said honestly.
 *
 * Exported because the header's search button makes the same promise, and a
 * button offering to find accounts to somebody whose grants stop the palette
 * from loading any is a promise the dialog then breaks. No trailing ellipsis:
 * these are accessible names as well as placeholders, and a screen reader
 * announces the dots.
 */
export function searchPlaceholder(identity: PermissionIdentity | undefined): string {
  return canAny(identity, 'book:read')
    ? 'Search accounts, deals, contracts and pages'
    : 'Search pages and workflows';
}

/** The same promise, short enough to survive the header button at 224px. */
export function searchLabel(identity: PermissionIdentity | undefined): string {
  return canAny(identity, 'book:read') ? 'Search records and pages' : 'Search pages';
}

export function CommandPalette({
  destinations,
  open,
  onOpenChange,
}: {
  destinations: readonly CommandDestination[];
  open: boolean;
  onOpenChange: (open: boolean) => void;
}) {
  const navigate = useNavigate();
  const [query, setQuery] = useState('');
  const identity = useIdentity();
  const placeholder = searchPlaceholder(identity);
  const canSearchRecords = canAny(identity, 'book:read');

  /*
   * Filtering is deliberately synchronous with the keystroke, and there is no
   * timer anywhere in this file.
   *
   * The debounce that matters happened already: the book is fetched once per
   * open and matched in memory, so typing costs no requests. Deferring the
   * *render* on top of that — `useDeferredValue`, a timeout, either — is not
   * free but actively broken: cmdk picks the item Enter will open in the same
   * pass that handles the keystroke, so rows arriving a frame later are rows
   * it has already decided are not there. Measured, with the rows deferred:
   * typing "demo msa" listed four contracts with none selected, and Enter did
   * nothing at all. Eighty rows of string matching is far cheaper than that.
   */
  const records = useBookSearch(query, open && canSearchRecords);
  const words = useMemo(() => queryWords(query), [query]);
  const pages = useMemo(
    () =>
      destinations.filter((destination) =>
        matchesWords(
          `${destination.label} ${destination.group ?? 'Navigate'}`.toLocaleLowerCase(),
          words,
        ),
      ),
    [destinations, words],
  );
  const groups = Array.from(new Set(pages.map((destination) => destination.group ?? 'Navigate')));

  // Clear on close rather than on open: reopening must not present yesterday's
  // query over a list it is already silently filtering. Keyed off `open` and
  // not the close handler because ⌘K and a selected item both close the dialog
  // by setting the prop directly.
  useEffect(() => {
    if (!open) setQuery('');
  }, [open]);

  // Where focus came from, so it can go back there. This used to be
  // `onCloseAutoFocus: preventDefault` — necessary while the header's search
  // control was an input that opened the palette on focus, because restoring
  // focus reopened the dialog. That control is a button now, and suppressing
  // restoration left focus on `<body>`: Tab then restarted at the top of the
  // document, which is a keyboard trap of its own. Radix's own restoration
  // does not survive this dialog either (measured: focus lands on `<body>`),
  // so the opener is captured and refocused explicitly.
  const opener = useRef<HTMLElement | null>(null);
  useEffect(() => {
    if (open) opener.current = document.activeElement as HTMLElement | null;
  }, [open]);

  const typing = query.trim().length > 0;

  return (
    <CommandDialog
      open={open}
      onOpenChange={onOpenChange}
      contentProps={{
        onCloseAutoFocus: (event) => {
          const target = opener.current;
          // `isConnected` because selecting an item navigates, and the opener
          // may be a control the new route has already unmounted; falling back
          // to Radix's default is better than focusing a detached node.
          if (!target || !target.isConnected) return;
          event.preventDefault();
          target.focus();
        },
      }}
    >
      <CommandInput
        value={query}
        onValueChange={setQuery}
        placeholder={`${placeholder}…`}
        aria-label={placeholder}
      />
      <CommandList className="max-h-[min(70dvh,32rem)] p-1">
        {/*
          Three states, three sentences. "Nothing matches" while the book is
          still arriving is a lie that sends someone off to check whether the
          record exists at all.
        */}
        <CommandEmpty>
          {records.isLoading
            ? 'Searching accounts, deals and contracts…'
            : records.isUnavailable
              ? `No pages match “${query.trim()}”.`
              : `Nothing matches “${query.trim()}”.`}
        </CommandEmpty>
        {groups.map((group, index) => (
          <Fragment key={group}>
            {index > 0 ? <CommandSeparator /> : null}
            <CommandGroup heading={group}>
              {pages
                .filter((destination) => (destination.group ?? 'Navigate') === group)
                .map((destination) => (
                  <CommandItem
                    key={destination.to}
                    value={`${destination.label} ${group}`}
                    className="min-h-11 rounded-lg"
                    onSelect={() => {
                      navigate(destination.to);
                      onOpenChange(false);
                    }}
                  >
                    <destination.icon aria-hidden />
                    <span>{destination.label}</span>
                    {destination.shortcut ? (
                      <CommandShortcut>{destination.shortcut}</CommandShortcut>
                    ) : null}
                  </CommandItem>
                ))}
            </CommandGroup>
          </Fragment>
        ))}
        {/*
          Records only once there is something to match on. With an empty query
          they would bury the navigation under sixty rows of book, which is the
          palette failing at the job it already did well.
        */}
        {typing
          ? RECORD_GROUPS.map(({ kind, heading, icon: Icon }) => {
              const matches = records.hits.filter((record) => record.kind === kind);
              const shown = matches.slice(0, ROWS_PER_GROUP);
              if (shown.length === 0) return null;
              return (
                // No separator: cmdk hides those while a search is running, and
                // records only ever render while one is. The headings carry the
                // division on their own.
                <CommandGroup
                  key={kind}
                  heading={
                    matches.length > shown.length
                      ? `${heading} · closest ${shown.length} of ${matches.length}`
                      : heading
                  }
                >
                  {shown.map((record) => (
                    <CommandItem
                      key={`${kind}-${record.id}`}
                      // The id keeps the value unique where two records share a
                      // name; it is never rendered, and cannot widen the match
                      // because these rows are pre-filtered above.
                      value={`${record.haystack} ${record.id}`}
                      className="min-h-11 items-start rounded-lg"
                      onSelect={() => {
                        navigate(record.to);
                        onOpenChange(false);
                      }}
                    >
                      <Icon aria-hidden className="mt-0.5 text-muted" />
                      <span className="flex min-w-0 flex-1 flex-col">
                        <span className="truncate">{record.name}</span>
                        <span className="truncate text-xs text-muted">{record.meta}</span>
                      </span>
                      {record.trailing ? (
                        <span className="nums mt-0.5 shrink-0 text-xs text-muted">
                          {record.trailing}
                        </span>
                      ) : null}
                    </CommandItem>
                  ))}
                </CommandGroup>
              );
            })
          : null}
      </CommandList>
      {/*
        Outside the list, so a failed fetch reads as a status rather than as a
        result. Silently returning pages only would leave someone convinced
        their customer is not in the book.
      */}
      {typing && records.isIncomplete ? (
        <p className="border-t border-border px-3 py-2 text-xs text-muted">
          {records.isUnavailable
            ? 'Record search is unavailable just now — pages only.'
            : 'Some records could not be searched, so this list may be incomplete.'}
        </p>
      ) : null}
    </CommandDialog>
  );
}
