import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAdapter } from '../provider';
import { isOwnMessage } from '../types';
import type { Attachment, Message, SendOpts } from '../types';

const TYPING_TTL_MS = 3500;

export interface UiMessage extends Message {
  mine: boolean;
}

export interface MessagesState {
  messages: UiMessage[];
  loading: boolean;
  error: string | null;
  send: (content: string, opts?: SendOpts) => Promise<void>;
  react: (messageId: string, emoji: string) => Promise<void>;
  upload: (file: File) => Promise<Attachment>;
  typingUserIds: string[];
  seenIds: Set<string>;
  sendTyping: () => void;
  canReact: boolean;
  canUpload: boolean;
}

let optimisticSeq = 0;

export function useMessages(threadId: string | null): MessagesState {
  const adapter = useAdapter();
  const [raw, setRaw] = useState<Message[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [typing, setTyping] = useState<Record<string, number>>({});
  const [seenIds, setSeenIds] = useState<Set<string>>(new Set());

  const currentActorId = adapter.currentActorId();
  const actorRef = useRef(currentActorId);
  actorRef.current = currentActorId;

  // Load history, then subscribe. Reconciliation is by message id, so an echoed
  // send never duplicates the optimistic row.
  useEffect(() => {
    if (!threadId) {
      setRaw([]);
      setLoading(false);
      return;
    }
    let alive = true;
    setLoading(true);
    setRaw([]);
    setError(null);
    setSeenIds(new Set());
    setTyping({});

    adapter
      .history(threadId)
      .then((h) => {
        if (!alive) return;
        // Merge, don't clobber: a live message can arrive via subscribe while this
        // history fetch is still in flight. Blindly setting raw = h would drop it.
        setRaw((live) => {
          const histIds = new Set(h.map((m) => m.id));
          const extras = live.filter((m) => !histIds.has(m.id));
          return extras.length ? [...h, ...extras] : h;
        });
        setError(null);
      })
      .catch((e: unknown) => {
        if (alive) setError(e instanceof Error ? e.message : String(e));
      })
      .finally(() => {
        if (alive) setLoading(false);
      });

    const off = adapter.subscribe(threadId, (e) => {
      if (!alive) return;
      switch (e.kind) {
        case 'message':
          setRaw((l) => (l.some((m) => m.id === e.message.id) ? l : [...l, e.message]));
          break;
        case 'typing':
          if (e.userId !== actorRef.current) {
            setTyping((t) => ({ ...t, [e.userId]: Date.now() + TYPING_TTL_MS }));
          }
          break;
        case 'receipt':
          // Only the OTHER side reading my message counts as "seen".
          if (e.actorId !== actorRef.current) {
            setSeenIds((s) => (s.has(e.messageId) ? s : new Set(s).add(e.messageId)));
          }
          break;
        case 'reaction':
          setRaw((l) => l.map((m) => (m.id === e.messageId ? { ...m, reactions: e.reactions } : m)));
          break;
      }
    });

    return () => {
      alive = false;
      off();
    };
  }, [adapter, threadId]);

  const messages: UiMessage[] = useMemo(
    () => raw.map((m) => ({ ...m, mine: isOwnMessage(m, currentActorId) })),
    [raw, currentActorId],
  );

  const send = useCallback(
    async (content: string, opts?: SendOpts) => {
      if (!threadId) return;
      const tempId = `optimistic_${optimisticSeq++}`;
      const optimistic: Message = {
        id: tempId,
        actorId: actorRef.current,
        text: content,
        at: new Date().toISOString(),
        pending: true,
        reactions: [],
        ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
        ...(opts?.attachment ? { attachment: opts.attachment } : {}),
      };
      setRaw((l) => [...l, optimistic]);

      try {
        const saved = await adapter.send(threadId, content, opts);
        setError(null);
        // Replace the optimistic row with the server's. If the subscribe echo already
        // added the real message, just drop the optimistic one.
        setRaw((l) => {
          const withoutTemp = l.filter((m) => m.id !== tempId);
          return withoutTemp.some((m) => m.id === saved.id) ? withoutTemp : [...withoutTemp, saved];
        });
      } catch (e: unknown) {
        setRaw((l) => l.filter((m) => m.id !== tempId));
        setError(e instanceof Error ? e.message : String(e));
        throw e;
      }
    },
    [adapter, threadId],
  );

  const react = useCallback(
    async (messageId: string, emoji: string) => {
      if (!threadId || !adapter.react) return;
      await adapter.react(threadId, messageId, emoji);
    },
    [adapter, threadId],
  );

  const upload = useCallback(
    async (file: File): Promise<Attachment> => {
      if (!adapter.upload) throw new Error('uploads are not supported by this adapter');
      return adapter.upload(file);
    },
    [adapter],
  );

  const sendTyping = useCallback(() => {
    if (threadId) adapter.sendTyping(threadId);
  }, [adapter, threadId]);

  // The newest acknowledged (non-pending) message id — what we report as read.
  const lastReadableId = useMemo(() => {
    for (let i = raw.length - 1; i >= 0; i--) {
      if (!raw[i]!.pending) return raw[i]!.id;
    }
    return null;
  }, [raw]);

  // Report my read of the newest message (drives the other side's "seen" tick).
  // Keyed on the id, not the whole array, so reaction/optimistic churn doesn't re-fire it.
  useEffect(() => {
    if (!threadId || !lastReadableId) return;
    void adapter.markRead(threadId, lastReadableId).catch(() => {});
  }, [adapter, threadId, lastReadableId]);

  const typingUserIds = useMemo(() => {
    const now = Date.now();
    return Object.entries(typing)
      .filter(([, exp]) => exp > now)
      .map(([u]) => u);
  }, [typing]);

  // Expire stale typing entries. Bumping `typing` to a new reference forces the
  // memo above to recompute with a fresh `now`, dropping entries past their TTL.
  // (A bump of unrelated state can't do this — the memo is keyed on `typing`, so it
  // would return its cached array and the indicator would stick forever.)
  useEffect(() => {
    if (typingUserIds.length === 0) return;
    const t = setTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS);
    return () => clearTimeout(t);
  }, [typingUserIds.length, typing]);

  // Only my messages that the other side has read.
  const seenMine = useMemo(() => {
    const out = new Set<string>();
    for (const id of seenIds) if (messages.some((m) => m.id === id && m.mine)) out.add(id);
    return out;
  }, [seenIds, messages]);

  return {
    messages,
    loading,
    error,
    send,
    react,
    upload,
    typingUserIds,
    seenIds: seenMine,
    sendTyping,
    canReact: typeof adapter.react === 'function',
    canUpload: typeof adapter.upload === 'function',
  };
}
