import type { ReactNode } from 'react';
import { describe, expect, test, beforeEach } from 'bun:test';
import { render, fireEvent, screen } from '@testing-library/react';
import type { AppRegistryMap, LayoutPanel, PanelComponents } from './types';
import type { WorkspaceContextValue } from './WorkspaceContext';
import { WorkspaceProvider, inertInteraction } from './WorkspaceContext';
import { ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from './layout-utils';
import { PanelSlot } from './PanelSlot';

/**
 * The panel's chrome. It has no state of its own worth speaking of, which is exactly why it went
 * untested and exactly why it was able to ship a button that did nothing it said: every question here is
 * "in this mode, which controls exist, and does each one call the handler it is named after" — cheap to
 * answer and nobody had.
 *
 * The mode matrix is the substance. `interactive`, `locked`, `isMobile`, `isLastPanel`, `fullscreenPanelId`
 * and an app's own `zoomable`/`transparent` flags combine into chrome that ranges from full traffic
 * lights down to a bare outline, and the combinations are chosen in six separate ternaries spread over
 * the file. A control appearing in a mode that should not have it is a way to edit a locked screen; a
 * control missing from a mode that should is what the close button was.
 *
 * `WorkspaceView.test.tsx` covers the same chrome from above, through the real renderer. This drives
 * `PanelSlot` directly so it can put the workspace into states a whole view cannot easily be pushed
 * into — fullscreen, mid-swap, mobile.
 */

const Icon = ((props: Record<string, unknown>) => <svg {...props} />) as AppRegistryMap[string]['icon'];

const App = ({ panelId }: { panelId: string }) => <div data-testid="app">{panelId}</div>;
const Boom = () => {
  throw new Error('app exploded');
};

const registry: AppRegistryMap = {
  notes: { name: 'Notes', icon: Icon, component: App },
  terminal: { name: 'Terminal', icon: Icon, component: App, zoomable: false },
  overlay: { name: 'Overlay', icon: Icon, component: App, transparent: true },
  boom: { name: 'Boom', icon: Icon, component: Boom },
  hidden: { name: 'Hidden', icon: Icon, component: App, availableOnPanel: false },
};

const panel = (appType: string | null, extra: Partial<LayoutPanel> = {}): LayoutPanel => ({
  type: 'panel',
  id: 'p1',
  appType,
  ...extra,
});

type Calls = {
  setApp: [string, string | null][];
  split: [string, 'horizontal' | 'vertical'][];
  remove: string[];
  zoom: [string, number][];
  swap: [string, string][];
  fullscreen: (string | null)[];
  swapSource: (string | null)[];
  mobileBack: number;
};

let calls: Calls;

beforeEach(() => {
  calls = { setApp: [], split: [], remove: [], zoom: [], swap: [], fullscreen: [], swapSource: [], mobileBack: 0 };
});

type MountOptions = {
  panel?: LayoutPanel;
  interactive?: boolean;
  locked?: boolean;
  noHeader?: boolean;
  isLastPanel?: boolean;
  components?: PanelComponents;
  context?: Partial<WorkspaceContextValue>;
};

function mount(options: MountOptions = {}) {
  const context: WorkspaceContextValue = {
    workspace: null,
    cwd: '~',
    panelConfigs: {},
    ...inertInteraction,
    isMobile: false,
    onMobileBack: null,
    onSwap: (source, target) => calls.swap.push([source, target]),
    setSwapSourceId: (id) => calls.swapSource.push(id),
    setFullscreenPanelId: (id) => calls.fullscreen.push(id),
    onSetZoom: (id, z) => calls.zoom.push([id, z]),
    ...options.context,
  };

  return render(
    <WorkspaceProvider value={context}>
      <PanelSlot
        panel={options.panel ?? panel('notes')}
        registry={registry}
        components={options.components}
        interactive={options.interactive ?? true}
        locked={options.locked ?? false}
        noHeader={options.noHeader ?? false}
        isLastPanel={options.isLastPanel ?? false}
        onSetApp={(id, type) => calls.setApp.push([id, type])}
        onSplit={(id, dir) => calls.split.push([id, dir])}
        onRemove={(id) => calls.remove.push(id)}
      />
    </WorkspaceProvider>,
  );
}

describe('the red button does what its own label says', () => {
  test('a panel with siblings closes', () => {
    // Regression pin, from the other side. `TrafficLights` used `isLastPanel` only to pick the tooltip
    // and called `onClearApp` in both branches, so this button emptied the panel and left the box. It is
    // the one control that removes anything, and it removed nothing for as long as it existed.
    mount({ isLastPanel: false });

    fireEvent.click(screen.getByTitle('Close panel'));

    expect(calls.remove).toEqual(['p1']);
    expect(calls.setApp).toEqual([]);
  });

  test('the only panel clears, because removing it would leave nothing', () => {
    mount({ isLastPanel: true });

    fireEvent.click(screen.getByTitle('Clear app'));

    expect(calls.setApp).toEqual([['p1', null]]);
    expect(calls.remove).toEqual([]);
  });
});

describe('which chrome each mode gets', () => {
  test('interactive and unlocked: close and full screen', () => {
    mount();

    expect(screen.getByTitle('Close panel')).toBeTruthy();
    expect(screen.getByTitle('Full screen')).toBeTruthy();
  });

  test('locked: full screen only — a locked screen is not the user’s to edit', () => {
    mount({ locked: true });

    expect(screen.queryByTitle('Close panel')).toBeNull();
    expect(screen.queryByTitle('Clear app')).toBeNull();
    expect(screen.getByTitle('Full screen')).toBeTruthy();
  });

  test('not interactive: no chrome buttons at all', () => {
    mount({ interactive: false });

    expect(screen.queryByTitle('Close panel')).toBeNull();
    expect(screen.queryByTitle('Full screen')).toBeNull();
    expect(screen.queryByTitle('Zoom in')).toBeNull();
    expect(screen.getByTestId('app')).toBeTruthy();
  });

  test('mobile: no traffic lights and no zoom — the header is a Back button', () => {
    mount({ context: { isMobile: true, onMobileBack: () => calls.mobileBack++ } });

    expect(screen.queryByTitle('Close panel')).toBeNull();
    expect(screen.queryByTitle('Zoom in')).toBeNull();

    fireEvent.click(screen.getByText('Back'));
    expect(calls.mobileBack).toBe(1);
  });

  test('the green and amber lights are gone, at both depths of the old feature', () => {
    // They drove a shallower "maximize" that stopped below the nav header — all a panel can reach from
    // inside the content region's stacking context. Full screen replaced it outright, so the pair went
    // rather than becoming a second way to do a subset of one thing.
    const view = mount();
    expect(screen.queryByTitle('Maximize')).toBeNull();
    expect(screen.queryByTitle('Restore')).toBeNull();
    view.unmount();

    mount({ context: { fullscreenPanelId: 'p1' } });
    expect(screen.queryByTitle('Maximize')).toBeNull();
    expect(screen.queryByTitle('Restore')).toBeNull();
  });

  test('red survives full screen, because closing and shrinking were never the same act', () => {
    // The amber light used to REPLACE red while maximized, so the way out could never be a way to delete.
    // With amber gone that guard would have cost the close button entirely; the fullscreen toggle sits
    // beside red instead, and the two read differently enough to carry it.
    mount({ context: { fullscreenPanelId: 'p1' } });

    expect(screen.getByTitle('Close panel')).toBeTruthy();
    expect(screen.getByTitle('Exit full screen')).toBeTruthy();
    expect(calls.remove).toEqual([]);
  });

  test('noHeader drops the whole header bar but keeps the app', () => {
    mount({ noHeader: true });

    expect(screen.queryByTitle('Close panel')).toBeNull();
    expect(screen.queryByText('Notes')).toBeNull();
    expect(screen.getByTestId('app')).toBeTruthy();
  });
});

describe('full screen is the only depth, and the only control', () => {
  const wrapper = () => document.querySelector<HTMLElement>('[data-panel-id] > div');
  const fullscreen = { fullscreenPanelId: 'p1' };

  test('one toggle in, one toggle out', () => {
    const view = mount();
    fireEvent.click(screen.getByTitle('Full screen'));
    expect(calls.fullscreen).toEqual(['p1']);
    view.unmount();

    mount({ context: fullscreen });
    fireEvent.click(screen.getByTitle('Exit full screen'));
    expect(calls.fullscreen).toEqual(['p1', null]);
  });

  test('locked screens get it, and it is the only chrome they get', () => {
    // Their whole restriction is "not yours to edit". Full screen edits nothing — no layout write, not
    // even a persisted-per-dashboard one — so it is the one control that survives the lock.
    mount({ locked: true });

    fireEvent.click(screen.getByTitle('Full screen'));

    expect(calls.fullscreen).toEqual(['p1']);
    expect(screen.queryByTitle('Close panel')).toBeNull();
    expect(screen.queryByTitle('Clear app')).toBeNull();
  });

  test('no app opts out: unlike zoom, this is a property of the frame', () => {
    mount({ panel: panel('terminal') });

    expect(screen.queryByTitle('Zoom in')).toBeNull();
    expect(screen.getByTitle('Full screen')).toBeTruthy();
  });

  test('not on mobile and not on an inert render', () => {
    const view = mount({ context: { isMobile: true } });
    expect(screen.queryByTitle('Full screen')).toBeNull();
    view.unmount();

    mount({ interactive: false });
    expect(screen.queryByTitle('Full screen')).toBeNull();
  });

  test('it takes the window, dropping the gutter and rounding that say "floating above the app"', () => {
    // `inset-0` is only the whole window because the shell hides its own header for it (see
    // panel-fullscreen.ts). `top-[56px]` was the old shallower depth clearing the nav it could not paint
    // over; it should appear nowhere now.
    const view = mount({ context: fullscreen });
    expect(wrapper()?.className).toContain('inset-0');
    expect(wrapper()?.className).not.toContain('rounded-lg');
    expect(wrapper()?.className).not.toContain('top-[56px]');
    view.unmount();

    mount();
    expect(wrapper()?.className).toContain('rounded-lg');
    expect(wrapper()?.className).not.toContain('fixed');
  });
});

describe('the empty panel', () => {
  test('offers the apps, and picking one sets it', () => {
    mount({ panel: panel(null) });

    fireEvent.click(screen.getByText('Notes'));

    expect(calls.setApp).toEqual([['p1', 'notes']]);
  });

  test('an app marked availableOnPanel: false is not on offer', () => {
    mount({ panel: panel(null) });

    expect(screen.queryByText('Hidden')).toBeNull();
  });

  test('locked or inert, it is an outline and nothing else', () => {
    const view = mount({ panel: panel(null), locked: true });
    expect(screen.queryByText('Notes')).toBeNull();
    view.unmount();

    mount({ panel: panel(null), interactive: false });
    expect(screen.queryByText('Notes')).toBeNull();
  });
});

describe('zoom', () => {
  test('the controls step by ZOOM_STEP and reset to 1', () => {
    mount({ panel: panel('notes', { zoom: 1.1 }) });

    fireEvent.click(screen.getByTitle('Zoom in'));
    fireEvent.click(screen.getByTitle('Zoom out'));
    fireEvent.click(screen.getByTitle('Reset zoom'));

    expect(calls.zoom).toEqual([
      ['p1', 1.1 + ZOOM_STEP],
      ['p1', 1.1 - ZOOM_STEP],
      ['p1', 1],
    ]);
  });

  test('the controls disable at the bounds rather than sending an out-of-range value', () => {
    const view = mount({ panel: panel('notes', { zoom: ZOOM_MAX }) });
    expect(screen.getByTitle<HTMLButtonElement>('Zoom in').disabled).toBe(true);
    expect(screen.getByTitle<HTMLButtonElement>('Zoom out').disabled).toBe(false);
    view.unmount();

    mount({ panel: panel('notes', { zoom: ZOOM_MIN }) });
    expect(screen.getByTitle<HTMLButtonElement>('Zoom out').disabled).toBe(true);
  });

  test('an app that opted out gets no control', () => {
    mount({ panel: panel('terminal') });

    expect(screen.queryByTitle('Zoom in')).toBeNull();
    expect(screen.getByTitle('Close panel')).toBeTruthy();
  });

  test('the scale is CSS `zoom`, and is absent at 100%', () => {
    // Not `transform: scale`. A transform repaints at a different size without re-laying out, so the
    // panel keeps its 100% geometry and anything sized against a percentage lands wrong — it broke
    // Chat's bottom-pinned composer. Writing no style at all at 1 keeps an untouched panel byte-identical
    // to what it rendered before the control existed.
    const view = mount({ panel: panel('notes', { zoom: 1.4 }) });
    const zoomed = document.querySelector<HTMLElement>('[style*="zoom"]');
    expect(zoomed?.style.zoom).toBe('1.4');
    expect(zoomed?.style.transform).toBeFalsy();
    view.unmount();

    mount({ panel: panel('notes', { zoom: 1 }) });
    expect(document.querySelector('[style*="zoom"]')).toBeNull();
  });
});

describe('an app that throws is contained', () => {
  test('the fallback replaces the app and the chrome survives', () => {
    mount({ panel: panel('boom') });

    expect(screen.getByText('This panel failed to render')).toBeTruthy();
    expect(screen.getByText('app exploded')).toBeTruthy();
    // The rest of the workspace is still the user's to drive — that is the whole point of the boundary.
    expect(screen.getByTitle('Close panel')).toBeTruthy();
  });

  test('the fallback offers to clear the panel, and clearing is what recovers it', () => {
    mount({ panel: panel('boom') });

    fireEvent.click(screen.getByText('Clear this panel'));

    expect(calls.setApp).toEqual([['p1', null]]);
  });

  test('a locked panel is offered no clear — there is nothing there it may edit', () => {
    mount({ panel: panel('boom'), locked: true });

    expect(screen.getByText('This panel failed to render')).toBeTruthy();
    expect(screen.queryByText('Clear this panel')).toBeNull();
  });
});

describe('a screen-supplied component beats the registry', () => {
  const Custom = ({ panelId }: { panelId: string }) => <div data-testid="custom">{panelId}</div>;

  test('a bare component is rendered, and is told which panel it is', () => {
    mount({ components: { p1: Custom } });

    expect(screen.getByTestId('custom').textContent).toBe('p1');
    expect(screen.queryByTestId('app')).toBeNull();
  });

  test('an entry’s header, provider and onClose are all honoured', () => {
    const Header = ({ panelId }: { panelId: string }) => <span data-testid="header">{panelId}</span>;
    const Provider = ({ panelId, children }: { panelId: string; children: ReactNode }) => (
      <div data-testid="provider" data-panel={panelId}>
        {children}
      </div>
    );
    let closed = 0;

    mount({ components: { p1: { component: Custom, header: Header, provider: Provider, onClose: () => closed++ } } });

    expect(screen.getByTestId('header').textContent).toBe('p1');
    expect(screen.getByTestId('provider').getAttribute('data-panel')).toBe('p1');
    // The header's own close — an app-level dismissal, not the panel's traffic light.
    fireEvent.click(screen.getByTestId('header').parentElement!.querySelector('button')!);
    expect(closed).toBe(1);
  });
});

describe('swap', () => {
  test('another panel’s swap offers this one as a target, and clicking it swaps', () => {
    mount({ context: { swapSourceId: 'p9' } });

    fireEvent.click(screen.getByText('Swap here'));

    expect(calls.swap).toEqual([['p9', 'p1']]);
  });

  test('the source panel shows a cancel affordance instead of a target', () => {
    mount({ context: { swapSourceId: 'p1' } });

    expect(screen.queryByText('Swap here')).toBeNull();
    fireEvent.click(screen.getByText('Swapping... click to cancel'));

    expect(calls.swapSource).toEqual([null]);
  });

  test('a locked panel is not a swap target — a swap would edit the layout', () => {
    mount({ locked: true, context: { swapSourceId: 'p9' } });

    expect(screen.queryByText('Swap here')).toBeNull();
  });
});

describe('a transparent app gets no chrome, by its own declaration', () => {
  test('no header, no card — but still an error boundary', () => {
    const view = mount({ panel: panel('overlay') });

    expect(screen.queryByText('Overlay')).toBeNull();
    expect(screen.queryByTitle('Close panel')).toBeNull();
    expect(screen.getByTestId('app')).toBeTruthy();
    view.unmount();

    mount({ panel: { type: 'panel', id: 'p1', appType: 'overlay' }, components: { p1: Boom } });
    expect(screen.getByText('This panel failed to render')).toBeTruthy();
  });
});
