import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ClientEvent, EventType, MatrixClient, Room, RoomEvent, SyncState } from 'matrix-js-sdk';
import type { BotPreset } from './catalog';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSyncState } from '../../hooks/useSyncState';
import {
  getCanonicalAliasOrRoomId,
  getCanonicalAliasRoomId,
  isRoomAlias,
} from '../../utils/matrix';
import {
  getChannelsRoomPath,
  getChannelsSpacePath,
  getDirectRoomPath,
} from '../../pages/pathUtils';
import type { MatrixToRoom } from '../../plugins/matrix-to';
import { Membership } from '../../../types/matrix/room';
import { getRoomToParents, isOneOnOneRoom, isSpace } from '../../utils/room';
import { useBotWidgetEmbed } from './useBotWidgetEmbed';
import { BotAddToChatPicker } from './BotAddToChatPicker';
import * as css from './BotWidgetMount.css';

// Anti-flicker debounce — same rationale as `SyncIndicator`'s
// `PROGRESS_FADE_IN_DELAY_MS`. On a warm/cached widget the iframe can
// boot in <100ms; without the delay the bar would briefly appear and
// fade out, reading as a flash. 100ms is just above the typical instant-
// load envelope yet below the perception threshold for «I clicked and
// nothing happened».
const LOADING_BAR_FADE_IN_DELAY_MS = 100;

// Fallback hide deadline if `animationiteration` never fires — covers
// any compositor freeze that drops the event (e.g. tab backgrounded
// mid-cycle and just foregrounded). 2000ms gives ~400ms slack over the
// 1.6s slide-cycle: a fast 60Hz display fires iteration ~16-32ms after
// cycle end, but a backgrounded compositor can be 100+ms late. The
// reduced-motion path skips this branch entirely.
const LOADING_BAR_HIDE_FALLBACK_MS = 2000;

// Freshly created bridge DM portals reach the client with a sync delay: the
// widget's create_dm call returns the room id from the bridge BEFORE the
// invite/join lands in /sync. Navigating immediately would render a
// room-not-found surface, so we wait for the room to appear (and ideally for
// auto-direct-sync to join it) up to this deadline, then navigate regardless —
// by that point the invite view is a reasonable fallback.
const OPEN_ROOM_SYNC_DEADLINE_MS = 15_000;

const waitForKnownRoom = (mx: MatrixClient, roomId: string, timeoutMs: number): Promise<void> =>
  new Promise((resolve) => {
    const isReady = () => {
      const room = mx.getRoom(roomId);
      return !!room && room.getMyMembership() === Membership.Join;
    };
    if (isReady()) {
      resolve();
      return;
    }
    let settled = false;
    let cleanup = () => {};
    const finish = () => {
      if (settled) return;
      settled = true;
      cleanup();
      resolve();
    };
    const check = () => {
      if (isReady()) finish();
    };
    const onRoom = (room: Room) => {
      if (room.roomId === roomId) check();
    };
    const onMembership = (room: Room) => {
      if (room.roomId === roomId) check();
    };
    const timer = window.setTimeout(finish, timeoutMs);
    mx.on(ClientEvent.Room, onRoom);
    mx.on(RoomEvent.MyMembership, onMembership);
    cleanup = () => {
      window.clearTimeout(timer);
      mx.removeListener(ClientEvent.Room, onRoom);
      mx.removeListener(RoomEvent.MyMembership, onMembership);
    };
    // Close the gap between the sync check above and listener attachment.
    check();
  });

// Bridge portals are organized under the bridge's space (mautrix personal
// filtering space — the «Telegram» space in the Каналы tab), and the bridge
// adds the m.space.child link a beat AFTER the room itself syncs. Give that
// link a short window so a freshly created portal routes into its space on
// the first try instead of falling back to /direct/.
const PARENT_SPACE_WAIT_MS = 5_000;
const PARENT_SPACE_POLL_MS = 400;

// A joined space that lists this room as its child. Plain poll — the
// roomToParents map is cheap to rebuild and this runs once per open-room
// verb, not per render.
const findParentSpace = (mx: MatrixClient, roomId: string): string | undefined => {
  const parents = getRoomToParents(mx).get(roomId);
  if (!parents) return undefined;
  return [...parents].find((spaceId) => {
    const space = mx.getRoom(spaceId);
    return !!space && isSpace(space) && space.getMyMembership() === Membership.Join;
  });
};

const waitForParentSpace = (
  mx: MatrixClient,
  roomId: string,
  isCancelled: () => boolean
): Promise<string | undefined> =>
  new Promise((resolve) => {
    const immediate = findParentSpace(mx, roomId);
    if (immediate) {
      resolve(immediate);
      return;
    }
    const deadline = Date.now() + PARENT_SPACE_WAIT_MS;
    const timer = window.setInterval(() => {
      // Stop polling early when the navigation was superseded or the mount
      // is gone — the caller's .then() would discard the result anyway.
      if (isCancelled()) {
        window.clearInterval(timer);
        resolve(undefined);
        return;
      }
      const spaceId = findParentSpace(mx, roomId);
      if (spaceId || Date.now() >= deadline) {
        window.clearInterval(timer);
        resolve(spaceId);
      }
    }, PARENT_SPACE_POLL_MS);
  });

// Fallback classification when the room has no parent space: m.direct tag or
// the strict two-member gate (same `isOneOnOneRoom` rule the Direct list
// uses) → /direct/, everything else → channels.
const isDirectRoomId = (mx: MatrixClient, roomId: string): boolean => {
  const content = mx.getAccountData(EventType.Direct)?.getContent() ?? {};
  const tagged = Object.values(content).some(
    (rooms) => Array.isArray(rooms) && (rooms as string[]).includes(roomId)
  );
  if (tagged) return true;
  const room = mx.getRoom(roomId);
  return !!room && isOneOnOneRoom(room);
};

// Renders the iframe-mount target and wires up `useBotWidgetEmbed` to
// drive the iframe lifecycle. Renamed from BotWidgetHost in the BotShell
// refactor — «Host» was overloaded with BotExperienceHost (page-level
// dispatcher); «Mount» reads as the literal DOM mount point this is.
type BotWidgetMountProps = {
  preset: BotPreset;
  room: Room;
  onError: () => void;
};

export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const navigate = useNavigate();
  const mx = useMatrixClient();

  // Deferred open-room navigations can resolve up to OPEN_ROOM_SYNC_DEADLINE_MS
  // after the verb — by then the user may have navigated elsewhere (unmounting
  // this mount) or fired a newer open-room. Track liveness + a latest-wins
  // sequence so a stale wait never yanks the router.
  const aliveRef = useRef(true);
  const openRoomSeq = useRef(0);
  useEffect(
    () => () => {
      aliveRef.current = false;
    },
    []
  );

  // Generic «navigate cinny to a matrix.to room/alias». Bot-agnostic: any
  // widget that posts `{action: 'open-matrix-to', data: {url}}` on the
  // `io.vojo.bot-widget` side-channel reaches this. The embed has already
  // validated the URL via `parseMatrixToRoom` so `target` is well-formed.
  // For an alias we resolve to the canonical room id first — the channels
  // path expects an id-or-alias either way, but joined-room lookup needs
  // the id form for the via-server hint to be effective. `viaServers` are
  // currently dropped (the channels view doesn't propagate them); add a
  // dedicated «join-via» path if a future widget needs to surface a room
  // the user hasn't joined yet.
  const handleOpenMatrixToRoom = useCallback(
    (target: MatrixToRoom) => {
      const { roomIdOrAlias } = target;
      const idOrAlias = isRoomAlias(roomIdOrAlias)
        ? getCanonicalAliasRoomId(mx, roomIdOrAlias) ?? roomIdOrAlias
        : roomIdOrAlias;
      if (isRoomAlias(idOrAlias)) {
        // Unresolvable alias — no room id to wait on or classify; the
        // channels path handles alias resolution itself.
        navigate(getChannelsSpacePath(idOrAlias));
        return;
      }
      // Freshly created DM portals (widget create_dm) land in /sync after the
      // bridge replies, so give the room a window to appear and get auto-joined
      // before deciding the destination. Destination priority mirrors where
      // the room actually LIVES in the UI:
      //   1. child of a joined space (bridge portals under the Telegram
      //      space) → that space's room path in the Каналы tab;
      //   2. m.direct / plain 1:1 → /direct/;
      //   3. everything else → channels.
      openRoomSeq.current += 1;
      const seq = openRoomSeq.current;
      waitForKnownRoom(mx, idOrAlias, OPEN_ROOM_SYNC_DEADLINE_MS)
        .then(() => {
          if (!aliveRef.current || seq !== openRoomSeq.current) return undefined;
          return waitForParentSpace(
            mx,
            idOrAlias,
            () => !aliveRef.current || seq !== openRoomSeq.current
          );
        })
        .then((spaceId) => {
          if (!aliveRef.current || seq !== openRoomSeq.current) return;
          if (spaceId) {
            navigate(getChannelsRoomPath(spaceId, idOrAlias));
          } else if (isDirectRoomId(mx, idOrAlias)) {
            navigate(getDirectRoomPath(idOrAlias));
          } else {
            navigate(getChannelsSpacePath(getCanonicalAliasOrRoomId(mx, idOrAlias)));
          }
        });
    },
    [mx, navigate]
  );

  // Host-owned room picker for the elevated `add-to-chat` verb. The widget
  // only posts the verb (no room, no mxid) — opening the picker and choosing a
  // room is entirely host-side, so this modal IS the authorization boundary.
  // `pickerOpen` is the singleton open-state: re-firing the verb while it's
  // already true is a no-op, so a hostile bundle can't stack modals.
  const [pickerOpen, setPickerOpen] = useState(false);
  const handleAddToChat = useCallback(() => setPickerOpen(true), []);

  const { ready } = useBotWidgetEmbed({
    containerRef,
    preset,
    room,
    onError,
    onOpenMatrixToRoom: handleOpenMatrixToRoom,
    onAddToChat: handleAddToChat,
  });

  // Track Matrix sync state so the bot loading bar yields to the global
  // SyncIndicator when the connection is unhealthy. Without this, on a
  // dropped network the user would see TWO sweeping bars at once — the
  // bot bar at top stuck in «still loading» plus the SyncIndicator at
  // bottom in transient/error state. The bottom bar is the canonical
  // connection-state surface; the top one defers. Reuses `mx` from the
  // navigate-callback block above — single hook call per render.
  const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState());
  useSyncState(
    mx,
    useCallback((state: SyncState) => {
      setSyncState(state);
    }, [])
  );
  useEffect(() => {
    // Close the render-to-effect gap (same pattern as SyncIndicator) so
    // a Sync event fired between useState init and listener attach
    // doesn't get dropped.
    setSyncState(mx.getSyncState());
  }, [mx]);

  // Healthy = SyncIndicator's «hidden» branch (`Syncing` or `Stopped`).
  // Anything else (`Reconnecting`, `Catchup`, `Error`, initial null)
  // means the bottom bar will be visible, so we should not also show
  // ours. Mirrors `stateToVisual` in SyncIndicator.tsx.
  const syncHealthy = syncState === SyncState.Syncing || syncState === SyncState.Stopped;

  // Three logical phases for the loading bar:
  //   - hidden  (visible=false, pendingHide=false) → initial / post-hide
  //   - visible (visible=true,  pendingHide=false) → animating actively
  //   - closing (visible=true,  pendingHide=true)  → ready fired mid-
  //                                                  cycle, waiting for
  //                                                  animationiteration
  //                                                  to land at the
  //                                                  cycle boundary
  //                                                  before snapping
  //                                                  invisible
  // The closing → hidden transition is driven by the iteration event so
  // the user always sees a complete left-to-right sweep and never a
  // mid-stroke cut. A fallback timer covers the case where the event
  // can't fire (compositor freeze on a backgrounded tab). Reduced-motion
  // and sync-unhealthy paths skip cycle-complete entirely — for the
  // first there's no animation cycle to honour, for the second the
  // bottom SyncIndicator should take over without UI overlap.
  const [visible, setVisible] = useState(false);
  const [pendingHide, setPendingHide] = useState(false);

  // Reasons the bar should NOT be shown:
  //   - 'ready' — widget loaded normally, hide gracefully (cycle-complete)
  //   - 'sync'  — connection unhealthy, snap-hide so the SyncIndicator's
  //               bar is the only visible chrome (no double-bar UX)
  //   - null    — bar should be visible
  // Order matters: `ready` takes precedence so a successfully-loaded
  // widget that then drops its connection still gets the polished cycle
  // hide on the original ready event rather than being re-classified as
  // a sync-issue snap.
  let hideReason: 'ready' | 'sync' | null = null;
  if (ready) hideReason = 'ready';
  else if (!syncHealthy) hideReason = 'sync';

  useEffect(() => {
    if (hideReason !== null) {
      // Bar wasn't shown yet — cached fast load (or a handshake that
      // landed during the debounce window) won the race. Nothing to do;
      // bar stays hidden, no flash.
      if (!visible) return undefined;
      // Sync-issue snap: jump straight to invisible so the bottom
      // SyncIndicator can take over without two bars overlapping.
      // Reduced-motion: animation is off (no iterations ever land), so
      // parking a static stripe for ~2s isn't graceful, just stuck.
      if (hideReason === 'sync' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        setVisible(false);
        setPendingHide(false);
        return undefined;
      }
      // hideReason === 'ready' with motion enabled — bar is mid-sweep.
      // Defer the actual hide to the next cycle boundary. Fallback
      // force-hides if the event never lands.
      setPendingHide(true);
      const fallback = setTimeout(() => {
        setVisible(false);
        setPendingHide(false);
      }, LOADING_BAR_HIDE_FALLBACK_MS);
      return () => clearTimeout(fallback);
    }
    // Bar should show — clear any leftover hide request from a previous
    // ready-then-not-ready blip and (if not yet visible) schedule the
    // debounced fade-in.
    setPendingHide(false);
    if (visible) return undefined;
    const fadeIn = setTimeout(() => setVisible(true), LOADING_BAR_FADE_IN_DELAY_MS);
    return () => clearTimeout(fadeIn);
  }, [hideReason, visible]);

  const handleIteration = () => {
    if (pendingHide) {
      setVisible(false);
      setPendingHide(false);
    }
  };

  // Loading bar stays mounted; opacity carries the visible/hidden
  // crossfade. animationPlayState is paused while invisible so the
  // compositor doesn't keep sweeping a 0-alpha element forever.
  // pointer-events: none on the root so it never intercepts iframe
  // clicks even mid-fade.
  return (
    <>
      <div ref={containerRef} className={css.FrameMount} />
      <div className={css.LoadingBarRoot} aria-hidden>
        <div
          className={css.LoadingBar}
          style={{
            opacity: visible ? 1 : 0,
            animationPlayState: visible ? 'running' : 'paused',
          }}
          onAnimationIteration={handleIteration}
        />
      </div>
      {pickerOpen && (
        <BotAddToChatPicker preset={preset} requestClose={() => setPickerOpen(false)} />
      )}
    </>
  );
}
