import { convexQuery } from '@convex-dev/react-query';
import { FullPageCenter } from '@tale/ui/full-page-center';
import { VStack } from '@tale/ui/layout';
import { SkeletonBox, SkeletonCircle } from '@tale/ui/skeleton';
import { Skeletonize } from '@tale/ui/skeleton-context';
import { Spinner } from '@tale/ui/spinner';
import { Text } from '@tale/ui/text';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Outlet, createFileRoute, useNavigate } from '@tanstack/react-router';
import { useMutation } from 'convex/react';
import { useEffect, useRef } from 'react';

import { AccessDenied } from '@/app/components/layout/access-denied';
import {
  AdaptiveHeaderProvider,
  AdaptiveHeaderSlot,
} from '@/app/components/layout/adaptive-header';
import { MobileBackButton } from '@/app/components/layout/mobile-back-button';
import { MobileBottomNav } from '@/app/components/layout/mobile-bottom-nav';
import { DirtyBlockerProvider } from '@/app/components/ui/editor';
import { Navigation } from '@/app/components/ui/navigation/navigation';
import { UserButton } from '@/app/components/user-button';
import {
  AbilityContext,
  AbilityLoadingContext,
} from '@/app/context/ability-context';
import { TwoFactorGraceBanner } from '@/app/features/auth/components/two-factor-grace-banner';
import { TwoFactorLowBackupCodesBanner } from '@/app/features/auth/components/two-factor-low-backup-codes-banner';
import { usePasswordExpiryGate } from '@/app/features/auth/hooks/use-password-expiry-gate';
import { ChangelogToastTrigger } from '@/app/features/changelog/components/changelog-toast-trigger';
import { configKeys } from '@/app/hooks/config-query-keys';
import { useConvexAuth } from '@/app/hooks/use-convex-auth';
import { useCurrentMemberContext } from '@/app/hooks/use-current-member-context';
import { TeamFilterProvider } from '@/app/hooks/use-team-filter';
import { toast } from '@/app/hooks/use-toast';
import { setActiveOrganizationId } from '@/app/lib/active-organization';
import { sessionQueryOptions } from '@/app/lib/auth/session-query';
import { ensureConvexQuery } from '@/app/lib/loader-preload';
import { markColdLoad } from '@/app/lib/perf/cold-load-trace';
import type { RouterContext } from '@/app/router';
import { api } from '@/convex/_generated/api';
import { authClient } from '@/lib/auth-client';
import { useT } from '@/lib/i18n/client';
import { defineAbilityFor, type AppAbility } from '@/lib/permissions/ability';

// Fire-and-forget warm of an action-backed config catalog into the SAME
// TanStack Query cache entry the `useActionQuery` hooks read (matching key +
// `staleTime: Infinity` + the `action(args)` queryFn), so the catalog loads
// DURING the auth/WS handshake instead of after the consuming component mounts.
// On mount the hook reads the warm cache and skips its own fetch; the result
// shape, key, and access checks are identical to the hook's path, so behavior
// is unchanged — this only moves the request earlier. Never awaited so a slow
// catalog can't stall the navigation, and `.catch` keeps a transient/auth
// failure from surfacing as an unhandled rejection (the hook refetches on
// mount as before in that case).
function prewarmConfigCatalog(
  context: RouterContext,
  queryKey: readonly unknown[],
  organizationId: string,
  action:
    | typeof api.agents.file_actions.listAgents
    | typeof api.providers.file_actions.listProviders,
): void {
  // `fetchQuery` (not `prefetchQuery`) so a failure reaches our `.catch` and is
  // logged rather than silently swallowed — `prefetchQuery` does `.catch(noop)`
  // internally. It populates the exact same cache entry either way.
  void context.queryClient
    .fetchQuery({
      queryKey,
      queryFn: () =>
        context.convexQueryClient.convexClient.action(action, {
          organizationId,
        }),
      staleTime: Infinity,
    })
    .catch((error: unknown) => {
      console.warn('Failed to prewarm config catalog', error);
    });
}

export const Route = createFileRoute('/dashboard/$id')({
  // Warm the membership/ability context and team filter, but DO NOT block the
  // transition on them. The layout shell + side-nav rail paint immediately
  // (NavRailPlaceholder) and the live getCurrentMemberContext subscription
  // upgrades the rail to the real Navigation the moment access resolves — so
  // the sidebar loads independently of the page content instead of the whole
  // shell waiting on a (potentially slow) membership round-trip. On warm entry
  // the prefetched cache resolves synchronously on first render, so there's no
  // placeholder flash; on cold entry the rail's masked placeholder stands in
  // while access catches up. The component's gating logic surfaces
  // denied/not-found states, so an access error here is non-fatal.
  loader: ({ context, params }) => {
    // The team filter (TeamFilterProvider) reads getMyTeams on every dashboard
    // page; warm it alongside the gating member context.
    void context.queryClient.prefetchQuery(
      convexQuery(api.members.queries.getMyTeams, {
        organizationId: params.id,
      }),
    );
    void ensureConvexQuery(
      context,
      api.members.queries.getCurrentMemberContext,
      { organizationId: params.id },
    ).catch((error: unknown) => {
      console.warn('Failed to preload member context', error);
    });
    // NOTE: the chat agent/provider catalog prewarm is intentionally NOT done
    // here. Firing it in the loader raced the WS auth handshake, so on every
    // cold load the action ran unauthenticated → `UNAUTHENTICATED` (logged by
    // the Convex client) + a failed/wasted query. It now runs in DashboardLayout
    // gated on `isAuthenticated` (see the effect there), which still warms the
    // cache before the chat composer mounts but never before auth is ready.
  },
  component: DashboardLayout,
});

function DashboardLayout() {
  const { id: organizationId } = Route.useParams();
  usePasswordExpiryGate(organizationId);

  // Theme the app to this org's branding. BrandingProvider sits above the
  // router (it themes the pre-auth shell too) and can't read this route param
  // directly, so publish it to the active-org store it subscribes to. Reset on
  // unmount so leaving the dashboard reverts to the platform-default branding.
  useEffect(() => {
    setActiveOrganizationId(organizationId);
    return () => setActiveOrganizationId(undefined);
  }, [organizationId]);
  const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth();
  // Warm the chat composer's agent + provider catalogs ONCE auth is ready (the
  // chat page is the dashboard's default landing). Done here rather than in the
  // route loader because the loader raced the WS auth handshake — the catalog
  // actions ran unauthenticated and failed on every cold load. DashboardLayout
  // is the parent of the chat route, so this still populates the cache before
  // the composer mounts; on a warm navigation `isAuthenticated` is already true
  // so it fires immediately. Once per org.
  const routeContext = Route.useRouteContext();
  const catalogPrewarmedRef = useRef<string | null>(null);
  useEffect(() => {
    if (!isAuthenticated) return;
    if (catalogPrewarmedRef.current === organizationId) return;
    catalogPrewarmedRef.current = organizationId;
    prewarmConfigCatalog(
      routeContext,
      configKeys.list('agents', organizationId),
      organizationId,
      api.agents.file_actions.listAgents,
    );
    prewarmConfigCatalog(
      routeContext,
      configKeys.list('providers', organizationId),
      organizationId,
      api.providers.file_actions.listProviders,
    );
  }, [isAuthenticated, organizationId, routeContext]);
  const {
    data: memberContext,
    isLoading: isQueryLoading,
    isError,
  } = useCurrentMemberContext(organizationId, isAuthLoading);
  useEffect(() => {
    if (memberContext) markColdLoad('member-context');
  }, [memberContext]);
  const { t } = useT('accessDenied');
  const { t: tNotFound } = useT('common');
  const { t: tSettings } = useT('settings');

  // Session-active-org guard: if the session's activeOrganizationId doesn't
  // match the route, silently sync it to the route (user is already verified
  // as a member via useCurrentMemberContext above). This keeps routes,
  // queries, and Better Auth aligned without bouncing the user through the
  // picker. Audit-log the entry so it's captured even for deep-link arrivals.
  const queryClient = useQueryClient();
  const recordOrgSwitch = useMutation(
    api.organizations.record_org_switch.recordOrgSwitch,
  );
  const { data: session } = useQuery(sessionQueryOptions);
  const activeOrganizationId = session?.data?.session?.activeOrganizationId;
  const orgSyncRef = useRef<string | null>(null);
  useEffect(() => {
    if (memberContext?.status !== 'ok') return;
    if (!activeOrganizationId) return;
    if (activeOrganizationId === organizationId) return;
    // Prevent re-running for the same mismatch after a completed sync.
    if (orgSyncRef.current === organizationId) return;
    orgSyncRef.current = organizationId;
    void (async () => {
      try {
        await authClient.organization.setActive({ organizationId });
        // Audit + preference persistence is off the critical path — fire in the
        // background so the guard doesn't block on it. Errors are logged.
        void recordOrgSwitch({ organizationId }).catch((err) => {
          console.warn('Failed to record org switch audit entry:', err);
        });
        await queryClient.invalidateQueries({ queryKey: ['auth', 'session'] });
      } catch (err) {
        console.warn('Failed to sync active organization:', err);
        orgSyncRef.current = null;
      }
    })();
  }, [
    activeOrganizationId,
    organizationId,
    memberContext?.status,
    queryClient,
    recordOrgSwitch,
  ]);

  const abilityRef = useRef<{ role: string | null; ability: AppAbility }>(null);

  const status = memberContext?.status;
  const currentRole =
    memberContext?.status === 'ok' ? memberContext.role : null;

  if (!abilityRef.current || abilityRef.current.role !== currentRole) {
    abilityRef.current = {
      role: currentRole,
      ability: defineAbilityFor(currentRole),
    };
  }

  const { role, ability } = abilityRef.current;
  const isDisabled = role === 'disabled';
  const hasRole = role !== null && !isDisabled;
  const isLoading = isAuthLoading || isQueryLoading || isError;

  // "Switching" state: the route changed but the session/member-context is
  // still catching up. Without this, the previous org's cached Outlet would
  // flash briefly during a switch. Render the skeleton until the resolved
  // member context points at the route's org.
  const isSwitching =
    !isLoading &&
    memberContext?.status === 'ok' &&
    !!activeOrganizationId &&
    activeOrganizationId !== organizationId;

  useEffect(() => {
    if (isDisabled) {
      toast({
        title: t('disabled'),
        variant: 'destructive',
      });
    }
  }, [isDisabled, t]);

  // Bounce away from a deleted-org URL. Handles cross-tab live deletion (tab A
  // deletes the org; tab B's getCurrentMemberContext subscription pushes
  // 'not_found' via Convex reactivity), bookmarks/history into deleted orgs,
  // and racy fallthrough during deletion. /dashboard/ index has fallback logic
  // (persistedStillMember) that prevents looping back to the deleted org.
  // 'not_member' intentionally not redirected — preserve the AccessDenied
  // "you've been removed" message; the early-bailout below already prevents
  // its layout-chrome subscription leak.
  const navigate = useNavigate();
  const redirectedRef = useRef(false);
  useEffect(() => {
    if (status === 'not_found' && !redirectedRef.current) {
      redirectedRef.current = true;
      void navigate({ to: '/dashboard', replace: true });
    }
  }, [status, navigate]);

  // Access resolved but denied → full-page message. Don't mount layout chrome
  // that would leak Convex subscriptions.
  if (!hasRole && memberContext && status !== 'ok') {
    return (
      <FullPageCenter>
        {status === 'not_found' ? (
          <AccessDenied
            title={tNotFound('notFound.title')}
            message={t('workspaceNotFound')}
          />
        ) : (
          <AccessDenied message={t(isDisabled ? 'disabled' : 'noMembership')} />
        )}
      </FullPageCenter>
    );
  }

  // One real shell. The static frame renders immediately; the access-dependent
  // regions (side nav, mobile nav, banners, Outlet) each gate on `hasRole`
  // individually so they load in independently once access resolves. Until
  // then we must NOT mount the Convex-subscribing children — chat/agents/… use
  // convex/react's useQuery, which *throws* UnauthorizedError on mount — so
  // each region shows a masked placeholder in place of its real component.
  return (
    <AbilityContext.Provider value={ability}>
      <AbilityLoadingContext.Provider value={isLoading}>
        <TeamFilterProvider organizationId={organizationId}>
          <DirtyBlockerProvider>
            <AdaptiveHeaderProvider>
              <div className="flex h-full w-full flex-col overflow-hidden md:flex-row">
                {/* Safe-area inset clears the notch; the inner fixed-height row
                    vertically centers the back button, title and profile button
                    so none of them sit high/low in the bar on notch devices. */}
                <header className="bg-background border-border border-b px-4 pt-(--safe-top) md:hidden">
                  <div className="flex min-h-12 items-center gap-2">
                    <MobileBackButton organizationId={organizationId} />
                    <div className="min-w-0 flex-1">
                      <AdaptiveHeaderSlot />
                    </div>
                    <UserButton align="end" />
                  </div>
                </header>

                <div className="bg-background hidden h-full px-2 md:flex md:flex-[0_0_var(--nav-size)]">
                  {hasRole ? (
                    <Navigation organizationId={organizationId} />
                  ) : (
                    <NavRailPlaceholder />
                  )}
                </div>

                <main
                  id="main-content"
                  className="border-border bg-background flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden md:border-l"
                >
                  {hasRole && (
                    <TwoFactorGraceBanner organizationId={organizationId} />
                  )}
                  {hasRole && (
                    <TwoFactorLowBackupCodesBanner
                      organizationId={organizationId}
                    />
                  )}
                  {hasRole && <ChangelogToastTrigger />}
                  {hasRole ? (
                    isSwitching ? (
                      <FullPageCenter>
                        <VStack gap={3} align="center">
                          <Spinner
                            size="lg"
                            label={tSettings('organization.switchingLabel')}
                          />
                          <Text variant="muted" className="text-sm">
                            {tSettings('organization.switching')}
                          </Text>
                        </VStack>
                      </FullPageCenter>
                    ) : (
                      <Outlet />
                    )
                  ) : null}
                </main>
                {hasRole && <MobileBottomNav organizationId={organizationId} />}
              </div>
            </AdaptiveHeaderProvider>
          </DirtyBlockerProvider>
        </TeamFilterProvider>
      </AbilityLoadingContext.Provider>
    </AbilityContext.Provider>
  );
}

// One masked nav icon — mirrors the real NavigationItem footprint: a `size-5`
// glyph centered in a `p-2 rounded-lg` hover target. Used for both the primary
// list and the pinned footer buttons so the rail reads as a populated nav.
function NavIconSkeleton() {
  return (
    <div className="flex items-center justify-center rounded-lg p-2">
      <SkeletonBox>
        <div className="size-5" />
      </SkeletonBox>
    </div>
  );
}

// The full primary nav (chat, projects, conversations, knowledge, agents,
// automations, settings). The real list is CASL-gated down to a few items and
// the gated count isn't known until access resolves, so the placeholder
// optimistically renders the whole set — that's a pixel match for the common
// admin/owner case and only over-draws a slot or two for limited members. The
// middle is a top-aligned `flex-1` region with a pinned footer, so the count
// never reflows the rail; it only sets which icon slots the placeholder fills.
const PLACEHOLDER_NAV_ITEMS = 7;

// Masked desktop side-nav rail shown while access resolves. Mirrors the real
// Navigation geometry — logo, primary icon list, and the pinned footer (bell +
// user avatar) — so it slots in without reflow.
function NavRailPlaceholder() {
  return (
    <Skeletonize loading>
      <div className="border-border flex h-full flex-col">
        <div className="flex flex-shrink-0 items-center justify-center py-3">
          <div className="flex size-8 items-center justify-center">
            <SkeletonBox>
              <div className="size-5" />
            </SkeletonBox>
          </div>
        </div>
        <div className="mx-1 min-h-0 flex-1 overflow-y-auto py-4">
          <div className="space-y-2">
            {Array.from({ length: PLACEHOLDER_NAV_ITEMS }, (_, i) => (
              // eslint-disable-next-line react/no-array-index-key
              <NavIconSkeleton key={i} />
            ))}
          </div>
        </div>
        <div className="flex flex-shrink-0 flex-col items-center gap-2 py-3">
          {/* Notification bell */}
          <NavIconSkeleton />
          {/* UserButton avatar */}
          <div className="flex items-center justify-center rounded-lg p-2">
            <SkeletonCircle>
              <div className="size-5" />
            </SkeletonCircle>
          </div>
        </div>
      </div>
    </Skeletonize>
  );
}

// Full-frame dashboard chrome for the redirect routes (`/dashboard`,
// `/dashboard/create-organization`) that have no Outlet/nav of their own and
// just need the shell to show while they resolve which org to route to.
// Mirrors the resolved layout's outer frame so the real chrome slots in without
// reflow.
export function DashboardShellFrame() {
  return (
    <div className="flex h-dvh w-full flex-col overflow-hidden md:flex-row">
      {/* Mobile top bar */}
      <div className="bg-background border-border flex items-center gap-2 border-b p-2 pt-[calc(var(--safe-top)+0.5rem)] md:hidden">
        <Skeletonize loading>
          <SkeletonBox>
            <div className="size-8" />
          </SkeletonBox>
          <SkeletonBox>
            <div className="h-4 w-32" />
          </SkeletonBox>
        </Skeletonize>
      </div>

      {/* Desktop side nav */}
      <div className="bg-background hidden h-full px-2 md:flex md:flex-[0_0_var(--nav-size)]">
        <NavRailPlaceholder />
      </div>

      <main className="border-border bg-background flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden md:border-l" />

      {/* Mobile bottom-nav placeholder */}
      <div className="bg-background border-border flex min-h-12 border-t pb-(--safe-bottom) md:hidden" />
    </div>
  );
}
