// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// jsdom doesn't ship ResizeObserver; Radix primitives (popovers, sheets,
// tooltips) pulled in by the layout's children depend on it. Stub before any
// component mounts.
class MockResizeObserver {
  observe = vi.fn();
  unobserve = vi.fn();
  disconnect = vi.fn();
}
globalThis.ResizeObserver =
  MockResizeObserver as unknown as typeof ResizeObserver;

// --- Mocks ---

const mockUseParams = vi.fn(() => ({ id: 'test-org-id' }));
const mockNavigate = vi.fn();

vi.mock('@tanstack/react-router', () => ({
  createFileRoute: () => (config: Record<string, unknown>) => ({
    useParams: mockUseParams,
    // DashboardLayout warms the chat config catalog in an auth-gated effect via
    // Route.useRouteContext(); stub it so the prewarm is a harmless no-op here.
    useRouteContext: () => ({
      queryClient: { fetchQuery: vi.fn().mockResolvedValue(undefined) },
      convexQueryClient: { convexClient: { action: vi.fn() } },
    }),
    ...config,
  }),
  Outlet: () => <div data-testid="outlet" />,
  useNavigate: () => mockNavigate,
  useLocation: () => ({ pathname: '/dashboard/test-org-id' }),
  useParams: () => mockUseParams(),
  useBlocker: () => ({ status: 'idle', proceed: vi.fn(), reset: vi.fn() }),
}));

vi.mock('@/app/features/auth/hooks/use-password-expiry-gate', () => ({
  usePasswordExpiryGate: vi.fn(),
}));

const mockUseConvexAuth = vi.fn(() => ({
  isLoading: false,
  isAuthenticated: true,
}));
vi.mock('convex/react', () => ({
  useConvexAuth: () => mockUseConvexAuth(),
  useMutation: () => vi.fn(),
}));

vi.mock('@tanstack/react-query', () => ({
  useQuery: () => ({ data: undefined }),
  useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));

vi.mock('@/lib/auth-client', () => ({
  authClient: {
    getSession: vi.fn().mockResolvedValue({ data: null }),
    organization: { setActive: vi.fn() },
  },
}));

const mockUseCurrentMemberContext = vi.fn(
  (_organizationId?: string, _skip?: boolean) =>
    ({ data: null, isLoading: true, isError: false }) as {
      data: Record<string, unknown> | null | undefined;
      isLoading: boolean;
      isError: boolean;
    },
);
vi.mock('@/app/hooks/use-current-member-context', () => ({
  useCurrentMemberContext: (organizationId?: string, skip?: boolean) =>
    mockUseCurrentMemberContext(organizationId, skip),
}));

vi.mock('@/lib/i18n/client', () => ({
  useT: (ns: string) => ({
    t: (key: string) => `${ns}.${key}`,
  }),
}));

vi.mock('@/lib/permissions/ability', () => ({
  defineAbilityFor: () => ({ can: () => false, cannot: () => true }),
}));

vi.mock('@/convex/_generated/api', () => ({
  api: {
    members: { queries: { getCurrentMemberContext: 'mock-query-ref' } },
    two_factor: { queries: { getStatus: 'mock-status-ref' } },
    organizations: {
      record_org_switch: { recordOrgSwitch: 'mock-record-org-switch' },
    },
    // Referenced by DashboardLayout's auth-gated config-catalog prewarm effect.
    agents: { file_actions: { listAgents: 'mock-list-agents' } },
    providers: { file_actions: { listProviders: 'mock-list-providers' } },
  },
}));

vi.mock('@/app/features/auth/components/two-factor-grace-banner', () => ({
  TwoFactorGraceBanner: () => null,
}));

vi.mock(
  '@/app/features/auth/components/two-factor-low-backup-codes-banner',
  () => ({
    TwoFactorLowBackupCodesBanner: () => null,
  }),
);

vi.mock('@/app/features/changelog/components/changelog-toast-trigger', () => ({
  ChangelogToastTrigger: () => null,
}));

vi.mock('@convex-dev/react-query', () => ({
  convexQuery: vi.fn(),
}));

vi.mock('@/app/components/branding/branding-provider', () => ({
  BrandingProvider: ({ children }: { children: React.ReactNode }) => (
    <>{children}</>
  ),
  useBrandingContext: () => ({ accentColor: undefined, logoUrl: undefined }),
}));

vi.mock('@/app/components/layout/adaptive-header', () => ({
  AdaptiveHeaderProvider: ({ children }: { children: React.ReactNode }) => (
    <>{children}</>
  ),
  AdaptiveHeaderSlot: () => null,
}));

vi.mock('@/app/components/ui/navigation/navigation', () => ({
  Navigation: () => null,
}));

vi.mock('@/app/components/user-button', () => ({
  UserButton: () => null,
}));

vi.mock('@/app/components/layout/mobile-bottom-nav', () => ({
  MobileBottomNav: () => null,
}));

vi.mock('@/app/components/layout/mobile-back-button', () => ({
  MobileBackButton: () => null,
}));

vi.mock('@/app/hooks/use-team-filter', () => ({
  TeamFilterProvider: ({ children }: { children: React.ReactNode }) => (
    <>{children}</>
  ),
}));

// --- Tests ---

let DashboardLayout: React.ComponentType;

beforeEach(async () => {
  vi.clearAllMocks();
  mockUseParams.mockReturnValue({ id: 'test-org-id' });

  const mod = await import('@/app/routes/dashboard/$id');
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  DashboardLayout = (mod.Route as any).component as React.ComponentType;
});

afterEach(() => {
  cleanup();
});

describe('DashboardLayout', () => {
  it('shows skeleton (not outlet) while Convex auth is loading', () => {
    mockUseConvexAuth.mockReturnValue({
      isLoading: true,
      isAuthenticated: false,
    });
    mockUseCurrentMemberContext.mockReturnValue({
      data: null,
      isLoading: false,
      isError: false,
    });

    render(<DashboardLayout />);

    // Outlet must not render until access is confirmed — child routes mount
    // org-scoped Convex subscriptions that throw on UnauthorizedError.
    expect(screen.queryByTestId('outlet')).not.toBeInTheDocument();
    expect(screen.getAllByRole('status').length).toBeGreaterThan(0);
  });

  it('shows skeleton (not outlet) while member context query is loading', () => {
    mockUseConvexAuth.mockReturnValue({
      isLoading: false,
      isAuthenticated: true,
    });
    mockUseCurrentMemberContext.mockReturnValue({
      data: null,
      isLoading: true,
      isError: false,
    });

    render(<DashboardLayout />);

    expect(screen.queryByTestId('outlet')).not.toBeInTheDocument();
    expect(screen.getAllByRole('status').length).toBeGreaterThan(0);
  });

  it('shows skeleton when both auth and query are loading', () => {
    mockUseConvexAuth.mockReturnValue({
      isLoading: true,
      isAuthenticated: false,
    });
    mockUseCurrentMemberContext.mockReturnValue({
      data: null,
      isLoading: true,
      isError: false,
    });

    render(<DashboardLayout />);

    expect(screen.queryByTestId('outlet')).not.toBeInTheDocument();
    expect(screen.getAllByRole('status').length).toBeGreaterThan(0);
  });

  it('renders child routes when auth complete and member has role', () => {
    mockUseConvexAuth.mockReturnValue({
      isLoading: false,
      isAuthenticated: true,
    });
    mockUseCurrentMemberContext.mockReturnValue({
      data: {
        status: 'ok',
        role: 'admin',
        memberId: 'm1',
        organizationId: 'org-1',
      },
      isLoading: false,
      isError: false,
    });

    render(<DashboardLayout />);

    expect(screen.getByTestId('outlet')).toBeInTheDocument();
    expect(screen.queryByRole('status')).not.toBeInTheDocument();
  });

  it('shows access denied when user is not a member (no redirect)', () => {
    mockUseConvexAuth.mockReturnValue({
      isLoading: false,
      isAuthenticated: true,
    });
    mockUseCurrentMemberContext.mockReturnValue({
      data: { status: 'not_member' },
      isLoading: false,
      isError: false,
    });

    render(<DashboardLayout />);

    expect(screen.getByText('accessDenied.noMembership')).toBeInTheDocument();
    expect(screen.queryByTestId('outlet')).not.toBeInTheDocument();
    // not_member intentionally preserves the AccessDenied "you've been removed"
    // UX — should NOT trigger redirect.
    expect(mockNavigate).not.toHaveBeenCalled();
  });

  it('redirects to /dashboard/ when organization does not exist', () => {
    mockUseConvexAuth.mockReturnValue({
      isLoading: false,
      isAuthenticated: true,
    });
    mockUseCurrentMemberContext.mockReturnValue({
      data: { status: 'not_found' },
      isLoading: false,
      isError: false,
    });

    render(<DashboardLayout />);

    expect(mockNavigate).toHaveBeenCalledTimes(1);
    expect(mockNavigate).toHaveBeenCalledWith({
      to: '/dashboard',
      replace: true,
    });
    // Until redirect commits, the AccessDenied fallback is shown without
    // layout chrome (no outlet, no Navigation subscriptions).
    expect(screen.queryByTestId('outlet')).not.toBeInTheDocument();
  });

  it('does not redirect twice on re-render with not_found status', () => {
    mockUseConvexAuth.mockReturnValue({
      isLoading: false,
      isAuthenticated: true,
    });
    mockUseCurrentMemberContext.mockReturnValue({
      data: { status: 'not_found' },
      isLoading: false,
      isError: false,
    });

    const { rerender } = render(<DashboardLayout />);
    rerender(<DashboardLayout />);
    rerender(<DashboardLayout />);

    // useRef guard keeps redirect a one-shot effect across re-renders.
    expect(mockNavigate).toHaveBeenCalledTimes(1);
  });

  it('passes organizationId from route params to useCurrentMemberContext', () => {
    mockUseParams.mockReturnValue({ id: 'my-org-456' });
    mockUseConvexAuth.mockReturnValue({
      isLoading: false,
      isAuthenticated: true,
    });
    mockUseCurrentMemberContext.mockReturnValue({
      data: null,
      isLoading: true,
      isError: false,
    });

    render(<DashboardLayout />);

    expect(mockUseCurrentMemberContext.mock.calls[0]?.[0]).toBe('my-org-456');
  });

  it('passes skip=true when auth is loading', () => {
    mockUseConvexAuth.mockReturnValue({
      isLoading: true,
      isAuthenticated: false,
    });
    mockUseCurrentMemberContext.mockReturnValue({
      data: null,
      isLoading: true,
      isError: false,
    });

    render(<DashboardLayout />);

    expect(mockUseCurrentMemberContext.mock.calls[0]?.[1]).toBe(true);
  });

  it('passes skip=false when auth is ready', () => {
    mockUseConvexAuth.mockReturnValue({
      isLoading: false,
      isAuthenticated: true,
    });
    mockUseCurrentMemberContext.mockReturnValue({
      data: null,
      isLoading: true,
      isError: false,
    });

    render(<DashboardLayout />);

    expect(mockUseCurrentMemberContext.mock.calls[0]?.[1]).toBe(false);
  });

  it('shows skeleton (not outlet) when query errors without prior data', () => {
    mockUseConvexAuth.mockReturnValue({
      isLoading: false,
      isAuthenticated: true,
    });
    mockUseCurrentMemberContext.mockReturnValue({
      data: null,
      isLoading: false,
      isError: true,
    });

    render(<DashboardLayout />);

    // Without resolved memberContext we can't confirm access — fall through
    // to the loading skeleton rather than rendering Outlet (which would mount
    // org-scoped subscriptions) or AccessDenied (which would be misleading
    // for a transient error).
    expect(screen.queryByTestId('outlet')).not.toBeInTheDocument();
    expect(
      screen.queryByText('accessDenied.noMembership'),
    ).not.toBeInTheDocument();
    expect(screen.getAllByRole('status').length).toBeGreaterThan(0);
  });

  it('keeps outlet visible when query errors but has previous data', () => {
    mockUseConvexAuth.mockReturnValue({
      isLoading: false,
      isAuthenticated: true,
    });
    mockUseCurrentMemberContext.mockReturnValue({
      data: {
        status: 'ok',
        role: 'admin',
        memberId: 'm1',
        organizationId: 'org-1',
      },
      isLoading: false,
      isError: true,
    });

    render(<DashboardLayout />);

    expect(screen.getByTestId('outlet')).toBeInTheDocument();
    expect(screen.queryByRole('status')).not.toBeInTheDocument();
  });
});
