// Contacts surface: the user's Telegram address book (GET /v3/contacts) with
// a single search field that doubles as «start a chat with anyone»:
//
//   * typing letters filters the local list;
//   * typing a +phone or @username shape surfaces an explicit «check on
//     Telegram» action (GET /v3/resolve_identifier — never fired per
//     keystroke: ContactsResolveUsername has no flood-wait retry in the
//     connector, so probing is click-gated);
//   * every row's action either opens the existing DM portal
//     (`dm_room_mxid` from the API) or creates one (POST /v3/create_dm)
//     and asks the host to navigate there.

import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import {
  ProvisioningClient,
  contactHandles,
  detectIdentifier,
  type Contact,
  type ProbeIdentifier,
} from './provisioning';
import { describeApiError } from './errors';
import { useMxcAvatar } from './avatars';
import type { WidgetApi } from './widget-api';
import { Avatar, RefreshIcon, SearchIcon, Spinner } from './ui';
import type { T } from './i18n';

type ListState =
  | { kind: 'loading' }
  | { kind: 'error'; message: string }
  | { kind: 'ready'; contacts: Contact[] };

type ProbeState =
  | { status: 'checking'; identifier: ProbeIdentifier }
  | { status: 'found'; identifier: ProbeIdentifier; contact: Contact }
  | { status: 'not-found'; identifier: ProbeIdentifier };

type ContactsProps = {
  client: ProvisioningClient;
  /** Widget transport — used for MSC4039 avatar downloads. */
  api: WidgetApi;
  t: T;
  /** Telegram user id of the linked account (whoami login id). Your own
   * address-book entry is hidden from the list, and probing your own
   * number/username answers «это вы» instead of offering a chat. */
  selfId?: string;
  /** Reports the avatar mxc of YOUR OWN address-book entry once the list
   * loads — the most reliable own-avatar source (whoami's profile.avatar is
   * empty until the bridge meets your ghost). */
  onSelfAvatar?: (mxc: string) => void;
  /** Bump to re-fetch the list — the refresh control lives in the parent's
   * header row, next to the status pill. */
  reloadToken?: number;
  /** List-fetch in-flight signal for the parent's refresh button. */
  onLoadingChange?: (loading: boolean) => void;
  /** Ask the host to navigate to a room (matrix.to side-channel verb). */
  onOpenRoom: (roomId: string) => void;
  /** Surface a terminal action error in the global notice strip. */
  onError: (message: string) => void;
};

// --- Contact row -------------------------------------------------------------

type ContactRowProps = {
  contact: Contact;
  api: WidgetApi;
  t: T;
  busy: boolean;
  /** Another row's action is in flight — this row's button is disabled. */
  anotherBusy: boolean;
  onAction: () => void;
};

const ContactRow = ({ contact, api, t, busy, anotherBusy, onAction }: ContactRowProps) => {
  const { username, phone } = contactHandles(contact);
  const usernameText = username ? `@${username}` : null;
  const phoneText = phone ? `+${phone.replace(/^\+/, '')}` : null;
  const linked = Boolean(contact.dm_room_mxid);
  const avatarSrc = useMxcAvatar(api, contact.avatar_url);
  return (
    <div class="contact-row">
      <Avatar name={contact.name} colorKey={contact.id} src={avatarSrc} />
      <div class="contact-main">
        <div class="contact-name">
          <span class="contact-name-text">
            {contact.name || usernameText || phoneText || contact.id}
          </span>
        </div>
        {usernameText || phoneText ? (
          // Separate spans (not a ' · '-joined string) so narrow viewports
          // wrap the phone onto its own line instead of ellipsizing both.
          <div class="contact-handle">
            {usernameText ? <span class="contact-handle-part">{usernameText}</span> : null}
            {phoneText ? <span class="contact-handle-part">{phoneText}</span> : null}
          </div>
        ) : null}
      </div>
      <button
        type="button"
        class={`contact-action${linked ? ' linked' : ''}`}
        disabled={anotherBusy || busy}
        onClick={onAction}
      >
        {busy ? (
          <>
            <Spinner />
            {linked ? t('contacts.opening') : t('contacts.creating')}
          </>
        ) : (
          <>{linked ? t('contacts.open-chat') : t('contacts.start-chat')}</>
        )}
      </button>
    </div>
  );
};

const contactSearchHaystack = (contact: Contact): string => {
  const { username, phone } = contactHandles(contact);
  return [contact.name ?? '', username ?? '', phone ?? ''].join(' ').toLowerCase();
};

const sortContacts = (contacts: Contact[]): Contact[] =>
  [...contacts].sort((a, b) =>
    (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' })
  );

export const Contacts = ({
  client,
  api,
  t,
  selfId,
  onSelfAvatar,
  reloadToken,
  onLoadingChange,
  onOpenRoom,
  onError,
}: ContactsProps) => {
  const [list, setList] = useState<ListState>({ kind: 'loading' });
  const [query, setQuery] = useState('');
  const [probe, setProbe] = useState<ProbeState | null>(null);
  // One in-flight row action at a time; the value is the contact id (or the
  // probe identifier) whose button is busy.
  const [busyId, setBusyId] = useState<string | null>(null);
  const aliveRef = useRef(true);

  useEffect(
    () => () => {
      aliveRef.current = false;
    },
    []
  );

  // Ref-shims so the parent's per-render callback identities don't churn
  // `load` (which would re-fetch the contact list on every parent render).
  const onSelfAvatarRef = useRef(onSelfAvatar);
  onSelfAvatarRef.current = onSelfAvatar;
  const onLoadingChangeRef = useRef(onLoadingChange);
  onLoadingChangeRef.current = onLoadingChange;

  const load = useCallback(() => {
    setList({ kind: 'loading' });
    onLoadingChangeRef.current?.(true);
    client
      .listContacts()
      .then((contacts) => {
        onLoadingChangeRef.current?.(false);
        if (!aliveRef.current) return;
        // Your own address-book entry is hidden from the rows below, but its
        // avatar is the best own-avatar source — hand it up before filtering.
        const selfEntry = selfId ? contacts.find((c) => c.id === selfId) : undefined;
        if (selfEntry?.avatar_url) onSelfAvatarRef.current?.(selfEntry.avatar_url);
        setList({ kind: 'ready', contacts: sortContacts(contacts) });
      })
      .catch((err) => {
        onLoadingChangeRef.current?.(false);
        if (!aliveRef.current) return;
        setList({ kind: 'error', message: describeApiError(err, t) });
      });
  }, [client, t, selfId]);

  useEffect(() => {
    load();
    // reloadToken is the parent header's refresh button — same load, new tick.
  }, [load, reloadToken]);

  // Telegram's address book includes your own entry — hide it; «начать чат
  // с собой» reads as a glitch here (Saved Messages is not this surface).
  const visibleContacts = useMemo(() => {
    if (list.kind !== 'ready') return [];
    return selfId ? list.contacts.filter((c) => c.id !== selfId) : list.contacts;
  }, [list, selfId]);

  const filtered = useMemo(() => {
    const needle = query.trim().toLowerCase();
    if (!needle) return visibleContacts;
    // `@nick` / `+7 999…` queries should also match the local list.
    const bare = needle.replace(/^@/, '').replace(/[\s\-()]/g, '');
    return visibleContacts.filter((c) => {
      const haystack = contactSearchHaystack(c);
      return haystack.includes(needle) || (bare.length > 0 && haystack.includes(bare));
    });
  }, [visibleContacts, query]);

  const probeCandidate = useMemo(() => detectIdentifier(query), [query]);

  // When the typed identifier exactly matches someone already in the visible
  // list, the list row IS the answer — offering a parallel «проверить в
  // Telegram» path would just duplicate the same person with two buttons.
  // Self is intentionally NOT considered a match here, so probing your own
  // number still reaches the «это вы» reply instead of dead-ending.
  const probeMatchesLocal = useMemo(() => {
    if (!probeCandidate) return false;
    if (probeCandidate.kind === 'phone') {
      const digits = probeCandidate.value.replace(/\D/g, '');
      return visibleContacts.some(
        (c) => (contactHandles(c).phone ?? '').replace(/\D/g, '') === digits
      );
    }
    const uname = probeCandidate.value.toLowerCase();
    return visibleContacts.some((c) => (contactHandles(c).username ?? '').toLowerCase() === uname);
  }, [probeCandidate, visibleContacts]);

  // The probe row hides once its result is stale (query changed).
  const activeProbe =
    probe && probeCandidate && probe.identifier.value === probeCandidate.value ? probe : null;

  // Latest-wins guard: probes aren't serialized by the UI (editing the query
  // re-enables the button while an older probe is still in flight), so a
  // slow stale response must not stomp a fresher result or fire a
  // misattributed error notice.
  const probeSeq = useRef(0);
  const runProbe = useCallback(() => {
    if (!probeCandidate) return;
    probeSeq.current += 1;
    const seq = probeSeq.current;
    setProbe({ status: 'checking', identifier: probeCandidate });
    client
      .resolveIdentifier(probeCandidate.value)
      .then((contact) => {
        if (!aliveRef.current || seq !== probeSeq.current) return;
        setProbe(
          contact
            ? { status: 'found', identifier: probeCandidate, contact }
            : { status: 'not-found', identifier: probeCandidate }
        );
      })
      .catch((err) => {
        if (!aliveRef.current || seq !== probeSeq.current) return;
        setProbe(null);
        onError(describeApiError(err, t));
      });
  }, [client, probeCandidate, onError, t]);

  // After we hand a room to the host, the widget normally unmounts when the
  // host navigates (≤15 s: BotWidgetMount waits for the fresh portal to
  // sync+join first). If navigation never happens — host-side parse failure,
  // room never syncing — re-enable the button instead of spinning forever.
  const busyResetTimer = useRef<number | null>(null);
  useEffect(
    () => () => {
      if (busyResetTimer.current !== null) window.clearTimeout(busyResetTimer.current);
    },
    []
  );
  const handOffToHost = useCallback(
    (roomId: string) => {
      onOpenRoom(roomId);
      if (busyResetTimer.current !== null) window.clearTimeout(busyResetTimer.current);
      busyResetTimer.current = window.setTimeout(() => {
        if (aliveRef.current) setBusyId(null);
      }, 20_000);
    },
    [onOpenRoom]
  );

  const startChat = useCallback(
    (contact: Contact, busyKey: string) => {
      if (busyId !== null) return;
      setBusyId(busyKey);
      if (contact.dm_room_mxid) {
        // Portal already exists — pure navigation, the host takes it from
        // here (the widget unmounts on route change).
        handOffToHost(contact.dm_room_mxid);
        return;
      }
      client
        .createDm(contact.id)
        .then((resolved) => {
          if (!aliveRef.current) return;
          if (resolved.dm_room_mxid) {
            handOffToHost(resolved.dm_room_mxid);
          } else {
            setBusyId(null);
            onError(t('error.generic', { reason: 'no room id' }));
          }
        })
        .catch((err) => {
          if (!aliveRef.current) return;
          setBusyId(null);
          onError(describeApiError(err, t));
        });
    },
    [busyId, client, handOffToHost, onError, t]
  );

  const renderRow = (contact: Contact, busyKey: string) => (
    <ContactRow
      key={busyKey}
      contact={contact}
      api={api}
      t={t}
      busy={busyId === busyKey}
      anotherBusy={busyId !== null && busyId !== busyKey}
      onAction={() => startChat(contact, busyKey)}
    />
  );

  const renderProbeArea = () => {
    if (!probeCandidate || probeMatchesLocal) return null;
    if (activeProbe?.status === 'found') {
      // Resolving your own number/username is technically valid (Telegram's
      // «Saved Messages»), but reads as a glitch in a contact picker —
      // surface it as «это вы» instead of an actionable row.
      if (selfId && activeProbe.contact.id === selfId) {
        return (
          <div class="probe-result">
            <div class="probe-result-label missing">{t('contacts.probe-self')}</div>
          </div>
        );
      }
      return (
        <div class="probe-result">
          <div class="probe-result-label">{t('contacts.probe-found')}</div>
          {renderRow(activeProbe.contact, `probe:${activeProbe.contact.id}`)}
        </div>
      );
    }
    if (activeProbe?.status === 'not-found') {
      return (
        <div class="probe-result">
          <div class="probe-result-label missing">
            {t('contacts.probe-not-found', { handle: activeProbe.identifier.display })}
          </div>
        </div>
      );
    }
    const checking = activeProbe?.status === 'checking';
    return (
      <button type="button" class="probe-check" onClick={runProbe} disabled={checking}>
        {checking ? <Spinner /> : <SearchIcon />}
        {checking
          ? t('contacts.probe-checking', { handle: probeCandidate.display })
          : t('contacts.probe-check', { handle: probeCandidate.display })}
      </button>
    );
  };

  return (
    <div class="contacts">
      <form
        class="search-shell"
        onSubmit={(e) => {
          e.preventDefault();
          // Enter in the search field fires the probe when the input looks
          // like an identifier — the keyboard path to «написать по номеру».
          if (probeCandidate && !probeMatchesLocal && activeProbe?.status !== 'checking') {
            runProbe();
          }
        }}
      >
        <span class="search-shell-icon" aria-hidden="true">
          <SearchIcon />
        </span>
        <input
          class="search-input"
          type="text"
          placeholder={t('contacts.search-placeholder')}
          value={query}
          onInput={(e) => setQuery((e.currentTarget as HTMLInputElement).value)}
        />
      </form>

      {!query && list.kind === 'ready' ? <div class="hint">{t('contacts.hint')}</div> : null}

      {renderProbeArea()}

      {list.kind === 'loading' ? (
        <div class="contacts-placeholder" role="status">
          <Spinner />
          {t('contacts.loading')}
        </div>
      ) : null}

      {list.kind === 'error' ? (
        <div class="contacts-placeholder">
          <span class="contacts-error-text">{list.message || t('contacts.error')}</span>
          <button type="button" class="recovery-action" onClick={load}>
            <RefreshIcon />
            {t('contacts.retry')}
          </button>
        </div>
      ) : null}

      {list.kind === 'ready' ? (
        <>
          {filtered.length > 0 ? (
            <div class="contact-list">{filtered.map((c) => renderRow(c, c.id))}</div>
          ) : (
            <div class="contacts-placeholder">
              {query.trim() ? t('contacts.empty-filtered') : t('contacts.empty')}
            </div>
          )}
        </>
      ) : null}
    </div>
  );
};
