import { useState, useCallback, useEffect, useRef, useMemo, type ComponentRef } from 'react';
import { flushSync } from 'react-dom';
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
import { useIsMobile } from 'hooks/useIsMobile';
import { useSessionState } from 'hooks/useSessionState';
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents, PanelConfig } from './types';
import type { DropPosition } from './layout-utils';
import {
  splitPanel,
  removePanel,
  setApp,
  updateSizes,
  swapPanels,
  movePanel,
  countPanels,
  setZoom,
  setPanelConfig,
  collectPanelConfigs,
  findPanelApp,
  normalizeLayout,
} from './layout-utils';
import type { WorkspaceContextValue } from './WorkspaceContext';
import { WorkspaceProvider } from './WorkspaceContext';
import { parseWorkspaceKey } from './workspace-identity';
import { WorkspaceRenderer } from './WorkspaceRenderer';
import { usePublishPanelFullscreen } from './panel-fullscreen';
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';

type WorkspaceViewProps = {
  workspace: DashboardState;
  locked?: boolean;
  /**
   * Pin every panel to one of these app types, replacing anything else with `fallback` and writing the
   * repair back. A locked screen should always pass this: its layout is persisted user state that
   * outlives the code, and an appType the screen no longer renders becomes an empty box the user cannot
   * get out of, because a locked screen has no app picker.
   *
   * This used to be fourteen character-identical `normalizeLayout` functions in fourteen screens, each
   * with its own `useMemo` and persist-back `useEffect` — which is how nine screens ended up with no
   * guard at all and one with a guard that re-ran forever because it never wrote the correction back.
   */
  appTypes?: { allowed: readonly string[]; fallback: string };
  cwd?: string;
  root?: string;
  components?: PanelComponents;
  ephemeral?: EphemeralPanels | null;
  mobilePanelId?: string;
  onMobilePanelChange?: (id: string | null) => void;
};

const noop = () => {};

export const WorkspaceView = ({
  workspace,
  locked,
  appTypes,
  cwd = '~',
  root,
  components,
  ephemeral,
  mobilePanelId,
  onMobilePanelChange,
}: WorkspaceViewProps) => {
  const { registry } = useAppRegistry();
  const isMobile = useIsMobile();

  // Deliberately not memoised. `normalizeLayout` returns its input by reference when nothing needed
  // fixing, so the identity everything downstream depends on is already stable; a `useMemo` here would
  // only add a dependency array to get wrong, and the callers pass a fresh object literal for `appTypes`
  // anyway, which would defeat it. The walk is a handful of nodes.
  const layout = appTypes ? normalizeLayout(workspace.value, appTypes.allowed, appTypes.fallback) : workspace.value;
  const onLayoutChange = workspace.setValue;

  // Write the repair back, so a layout is fixed once rather than re-fixed on every mount for the rest of
  // its life. Terminates because the normalised tree normalises to itself.
  const rawLayout = workspace.value;
  useEffect(() => {
    if (!workspace.isLoaded || layout === rawLayout) return;
    onLayoutChange(layout);
  }, [workspace.isLoaded, layout, rawLayout, onLayoutChange]);
  const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
  const [dragSourceId, setDragSourceId] = useState<string | null>(null);
  // Per tab and per dashboard: putting the chat panel full screen and refreshing should come back full
  // screen, while a second window on the same screen keeps its own idea of what is blown up. A stored id
  // whose panel has since been removed simply fills nothing — this is a style toggle on the panel itself,
  // so there is nothing to strand.
  //
  // Keyed `FULLSCREEN_PANEL`, not the `MAXIMIZED_PANEL` this used to be. The old key briefly held a
  // `{ id, mode }` pair for the two depths; renaming it means a tab open across that change reads nothing
  // here rather than reading an object where a string is expected.
  const fullscreenKey = `FULLSCREEN_PANEL:${workspace.key}`;
  const [fullscreenPanelId, setFullscreenPanelId] = useSessionState<string | null>(fullscreenKey, null);
  const [transitioningPanelId, setTransitioningPanelId] = useState<string | null>(null);

  // The shell hides the nav header for it — a panel cannot paint over that from inside the content
  // region's stacking context. See `panel-fullscreen.ts`.
  usePublishPanelFullscreen(fullscreenPanelId !== null);

  const setFullscreenAnimated = useCallback(
    (id: string | null) => {
      const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } };
      const panelId = fullscreenPanelId ?? id;
      if (doc.startViewTransition && panelId) {
        setTransitioningPanelId(panelId);
        requestAnimationFrame(() => {
          const transition = doc.startViewTransition(() => flushSync(() => setFullscreenPanelId(id)));
          transition.finished.finally(() => setTransitioningPanelId(null));
        });
      } else {
        setFullscreenPanelId(id);
      }
    },
    [fullscreenPanelId, setFullscreenPanelId],
  );

  // What each panel wants done when it is closed, by panel id, stamped with the workspace it was
  // registered on. A ref rather than state: nothing renders from it, and a panel registering during its
  // own mount effect must not schedule a render of the whole workspace.
  //
  // Nothing is ever unregistered. A handler is dropped when it fires and replaced when the same panel id
  // registers again; a panel that merely unmounted keeps its handler, because "unmounted" is precisely
  // the signal this exists to avoid trusting. The stamp is what keeps that from leaking across
  // dashboards — the same panel id can exist on two of them, and a handler from the one you navigated
  // away from must not fire for the one you are on.
  const closeHandlers = useRef(new Map<string, { workspaceKey: string; run: () => void }>());
  const workspaceKeyRef = useRef(workspace.key);
  workspaceKeyRef.current = workspace.key;

  const registerPanelClose = useCallback((panelId: string, run: () => void) => {
    closeHandlers.current.set(panelId, { workspaceKey: workspaceKeyRef.current, run });
  }, []);

  const firePanelClose = useCallback((panelId: string) => {
    const entry = closeHandlers.current.get(panelId);
    if (!entry) return;
    closeHandlers.current.delete(panelId);
    if (entry.workspaceKey !== workspaceKeyRef.current) return;
    // A handler that throws must not stop the panel from closing — the layout write is the user's
    // instruction, and the cleanup is a best effort on top of it.
    try {
      entry.run();
    } catch (err) {
      console.error(`panel close handler for ${panelId} threw`, err);
    }
  }, []);

  // Every mutation goes through `onLayoutChange` as an UPDATER, never as a computed tree.
  //
  // Each of these used to close over `layout` as it was when the callback was made, and two of the paths
  // are not immediate: the resize debounce fires 500 ms after the drag began, and a window resize fires
  // `onLayout` on every group at once. So the second write was computed from a tree that predated the
  // first and silently undid it — remove a panel just after dragging a splitter and it came back. That is
  // worse than it sounds now that panel identity lives in the layout: the resurrected tree carries an
  // older `config`, so a panel that was just given an agent's name reverts to anonymous, and the agent it
  // addresses is no longer reachable through it.
  //
  // The guards below still test the rendered `layout` — they only decide whether a write is worth making,
  // and being one render stale there costs a redundant no-op write at worst.
  //
  // `handleSetApp` and `handleRemove` additionally fire the closing panel's `usePanelClose` handler.
  // They are the only two places that do, and they are the only two that know a close was *asked for* —
  // a drag, a swap and a mobile panel switch all tear a panel's component down without closing anything.
  const handleSetApp = useCallback(
    (panelId: string, appType: string | null) => {
      // Re-picking the app that is already there is not a close: `setApp` keeps the config and the app
      // never goes away. Every other transition retires whatever was running.
      if (findPanelApp(layout, panelId) !== appType) firePanelClose(panelId);
      onLayoutChange((prev) => setApp(prev, panelId, appType));
    },
    [layout, onLayoutChange, firePanelClose],
  );

  const handleSplit = useCallback(
    (panelId: string, direction: 'horizontal' | 'vertical') => {
      onLayoutChange((prev) => splitPanel(prev, panelId, direction));
    },
    [onLayoutChange],
  );

  const handleRemove = useCallback(
    (panelId: string) => {
      if (countPanels(layout) <= 1) return;
      // Fired before the write, from the rendered tree, rather than from inside the updater — an updater
      // has to stay pure and may run twice. The updater's own guard can therefore decline a removal we
      // already announced, if a concurrent write shrank the tree to one panel in between. That costs a
      // cleanup for a panel still on screen; it needs two writes to race inside one debounce window, and
      // the alternative is a side effect in an updater.
      firePanelClose(panelId);
      onLayoutChange((prev) => (countPanels(prev) <= 1 ? prev : removePanel(prev, panelId)));
    },
    [layout, onLayoutChange, firePanelClose],
  );

  const handleResized = useCallback(
    (groupId: string, sizes: number[]) => {
      onLayoutChange((prev) => updateSizes(prev, groupId, sizes));
    },
    [onLayoutChange],
  );

  const handleSetZoom = useCallback(
    (panelId: string, zoom: number) => {
      if (setZoom(layout, panelId, zoom) === layout) return;
      onLayoutChange((prev) => setZoom(prev, panelId, zoom));
    },
    [layout, onLayoutChange],
  );

  // Derived, not stored: the layout is the single copy, so a panel's config cannot drift from the panel.
  const panelConfigs = useMemo(() => collectPanelConfigs(layout), [layout]);

  // Memoised on the key, not recomputed inline: the provider value is a fresh object every render
  // anyway, but this one ends up in consumers' dependency arrays (`useAgentPanel`'s query key, the
  // terminal state key). A new identity per render is how `useClient()` silently disabled every
  // playback report in the Jellyfin player — same shape, so don't reproduce it here.
  const identity = useMemo(() => parseWorkspaceKey(workspace.key), [workspace.key]);

  const handleSetPanelConfig = useCallback(
    (panelId: string, config: PanelConfig | undefined) => {
      if (setPanelConfig(layout, panelId, config) === layout) return;
      onLayoutChange((prev) => setPanelConfig(prev, panelId, config));
    },
    [layout, onLayoutChange],
  );

  const handleSwap = useCallback(
    (sourceId: string, targetId: string) => {
      onLayoutChange((prev) => swapPanels(prev, sourceId, targetId));
      setSwapSourceId(null);
    },
    [onLayoutChange],
  );

  const handleMove = useCallback(
    (sourceId: string, targetId: string, position: DropPosition) => {
      onLayoutChange((prev) => movePanel(prev, sourceId, targetId, position));
      setDragSourceId(null);
    },
    [onLayoutChange],
  );

  const startDrag = useCallback((id: string | null) => {
    setSwapSourceId(null);
    setDragSourceId(id);
  }, []);

  // Escape cancels a *pending* swap or drag — modes you are halfway through, where it is the only way
  // out. Maximize is not one of those: it is a state you chose and that now outlives a refresh, so a key
  // press meant for whatever you are typing in should not throw it away. Restore is the panel's own
  // button.
  useEffect(() => {
    if (!swapSourceId && !dragSourceId) return;
    const onKeyDown = (ev: KeyboardEvent) => {
      if (ev.key === 'Escape') {
        setSwapSourceId(null);
        setDragSourceId(null);
      }
    };
    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, [swapSourceId, dragSourceId]);

  const ephemeralPanelRef = useRef<ComponentRef<typeof ResizablePanel>>(null);
  const isEphemeralOpen = !!ephemeral;

  useEffect(() => {
    const panel = ephemeralPanelRef.current;
    if (!panel) return;

    // `react-resizable-panels` asserts rather than no-ops when it is asked to resize a group it has not
    // laid out yet — and an assert thrown from an effect aborts the commit, which here means the whole
    // dashboard rather than the pane that could not be sized. The pane is a file preview; the panels
    // behind it are a night's work. Only the ephemeral pane's own geometry is at stake, and the next
    // render tries again.
    try {
      if (isEphemeralOpen) {
        panel.resize(100 - (ephemeral.defaultBaseSize ?? 40));
      } else {
        panel.collapse();
      }
    } catch (err) {
      console.warn('[workspace] could not size the ephemeral pane yet', err);
    }
  }, [isEphemeralOpen, ephemeral?.defaultBaseSize]);

  const onMobileBack = useMemo(() => {
    if (!isMobile) return null;
    if (mobilePanelId && onMobilePanelChange) return () => onMobilePanelChange(null);
    if (isEphemeralOpen && ephemeral?.onClose) return ephemeral.onClose;
    return null;
  }, [isMobile, mobilePanelId, onMobilePanelChange, isEphemeralOpen, ephemeral?.onClose]);

  // A fresh object literal here re-rendered every `useWorkspace()` consumer in every panel on every
  // `WorkspaceView` render — including each frame of a maximize animation, and including renders that
  // changed nothing a panel can see (the ephemeral pane opening, a mobile panel switch). Memoised, a panel
  // only re-renders when something it is actually given changes.
  const contextValue = useMemo<WorkspaceContextValue>(
    () => ({
      workspace: identity,
      cwd,
      root,
      panelConfigs,
      setPanelConfig: locked ? noop : handleSetPanelConfig,
      registerPanelClose,
      swapSourceId,
      setSwapSourceId,
      onSwap: handleSwap,
      dragSourceId,
      setDragSourceId: startDrag,
      onMove: handleMove,
      onSetZoom: handleSetZoom,
      fullscreenPanelId,
      setFullscreenPanelId: setFullscreenAnimated,
      transitioningPanelId,
      isMobile,
      onMobileBack,
    }),
    [
      identity,
      cwd,
      root,
      panelConfigs,
      locked,
      handleSetPanelConfig,
      registerPanelClose,
      swapSourceId,
      handleSwap,
      dragSourceId,
      startDrag,
      handleMove,
      handleSetZoom,
      fullscreenPanelId,
      setFullscreenAnimated,
      transitioningPanelId,
      isMobile,
      onMobileBack,
    ],
  );

  if (!workspace.isLoaded) return null;

  return (
    <WorkspaceProvider value={contextValue}>
      {isMobile && isEphemeralOpen && ephemeral ? (
        <WorkspaceRenderer
          layout={ephemeral.layout}
          registry={registry}
          components={ephemeral.components}
          isMobile={isMobile}
          onSetApp={noop}
          onSplit={noop}
          onRemove={noop}
          onResized={noop}
        />
      ) : (
        <ResizablePanelGroup direction="horizontal" className="h-full w-full">
          <ResizablePanel defaultSize={100} minSize={15}>
            <WorkspaceRenderer
              layout={layout}
              registry={registry}
              components={components}
              interactive
              locked={locked}
              isMobile={isMobile}
              mobilePanelId={mobilePanelId}
              onSetApp={locked ? noop : handleSetApp}
              onSplit={locked ? noop : handleSplit}
              onRemove={locked ? noop : handleRemove}
              onResized={handleResized}
            />
          </ResizablePanel>
          <ResizableHandle className="bg-transparent after:bg-transparent" disabled={!isEphemeralOpen} />
          <ResizablePanel ref={ephemeralPanelRef} collapsible collapsedSize={0} defaultSize={0} minSize={15}>
            {isEphemeralOpen ? (
              <WorkspaceRenderer
                layout={ephemeral.layout}
                registry={registry}
                components={ephemeral.components}
                onSetApp={noop}
                onSplit={noop}
                onRemove={noop}
                onResized={noop}
              />
            ) : null}
          </ResizablePanel>
        </ResizablePanelGroup>
      )}
    </WorkspaceProvider>
  );
};
