import { useCallback, useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import type { UserState } from './useSettings';

export const DASHBOARD_STATE_QUERY_KEY = ['DASHBOARD_STATE'];

const QUERY_KEY = DASHBOARD_STATE_QUERY_KEY;

export function useDashboardState<T>(key: string, defaultValue: T) {
  const client = useClient();
  const { isAuthenticated } = useAuth();
  const queryClient = useQueryClient();
  const clientRef = useRef(client);
  clientRef.current = client;

  const {
    data: state = {},
    isSuccess,
    isError,
  } = useQuery<UserState>({
    queryKey: QUERY_KEY,
    enabled: isAuthenticated,
    queryFn: () => client.get<UserState>('/dashboards'),
    // `Infinity` meant this was fetched once per page load and never again, with no `invalidateQueries`
    // anywhere in the repo — so two windows onto the same dashboards diverged permanently and neither was
    // ever told. Refetching on focus is the cheapest thing that converges them: you come back to the tab
    // and it catches up.
    //
    // The other three knobs are the safety around it, and they are not defaults. This cache is optimistic:
    // a write updates it immediately and the PATCH follows. A refetch that started before that PATCH
    // landed returns pre-write state and would overwrite the value we already showed — the same
    // lost-update shape the layout updaters exist to prevent, and self-healing only until the next
    // mutation composes on top of the stale tree. So: never on mount (splitting a panel mounts a fresh
    // consumer, which is precisely when a write is in flight), never on reconnect, and on focus only when
    // nothing has been written for a moment.
    staleTime: 30_000,
    refetchOnMount: false,
    refetchOnReconnect: false,
    refetchOnWindowFocus: () => inFlightWrites === 0 && Date.now() - lastWriteAt > 2_000,
  });

  // Seed default to backend when key is missing after initial fetch
  const seededKeyRef = useRef<string | null>(null);
  useEffect(() => {
    if (!isSuccess || seededKeyRef.current === key) return;
    seededKeyRef.current = key;

    const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
    if (!(key in currentState)) {
      queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: defaultValue });
      const seq = nextWriteSeq(key);
      persist(clientRef.current, key, defaultValue).catch((err: unknown) =>
        revert(queryClient, key, seq, undefined, err),
      );
    }
  }, [isSuccess, key, defaultValue, queryClient]);

  const value = readValue(state, key, defaultValue);

  const setValue = useCallback(
    (update: T | ((prev: T) => T)) => {
      const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
      const currentValue = readValue(currentState, key, defaultValue);
      const newValue = typeof update === 'function' ? (update as (prev: T) => T)(currentValue) : update;

      queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });

      const seq = nextWriteSeq(key);
      persist(clientRef.current, key, newValue).catch((err: unknown) =>
        revert(queryClient, key, seq, key in currentState ? currentValue : undefined, err),
      );
    },
    [key, defaultValue, queryClient],
  );

  // `isLoaded` is true on a FAILED fetch as well as a successful one, and the distinction is `loadFailed`.
  //
  // Consumers use `isLoaded` to decide whether to render at all — `WorkspaceView` returns null without it.
  // While that meant only "the request is in flight" it was fine. The moment `/dashboards` could answer 403,
  // it meant a permanently blank screen: no layout, no panels, no request to the feature the screen is for,
  // and nothing on screen to say why. A screen that cannot remember its layout should still BE a screen.
  //
  // So a failed load resolves to the caller's `defaultValue` and renders. `loadFailed` is exposed so a
  // consumer can say "not saved" rather than pretend, and `setValue` still attempts its PATCH — if the
  // failure was transient the write succeeds, and if it was a 403 the existing revert path reports it.
  return { key, value, setValue, isLoaded: isSuccess || isError, loadFailed: isError };
}

/**
 * Every instance of this hook addresses one query key, so the write-in-flight bookkeeping the focus
 * refetch reads is module-level rather than per-instance. `lastWriteAt` is stamped on both ends: the
 * quiet period that has to elapse is measured from when the last write *settled*, not when it started.
 */
let inFlightWrites = 0;
let lastWriteAt = 0;

/**
 * Per-key write sequence, and the reason `revert` cannot just look at the cache.
 *
 * It used to ask "does the cache still hold exactly what I wrote?" by reference — and for a container that
 * question can never be true. `setQueryData` runs React Query's structural sharing, which rebuilds the
 * object it stores rather than keeping the one it was handed (verified against @tanstack/react-query
 * 5.101.4: an object value comes back `!==` what went in, a string comes back `===`). So the guard silently
 * inverted for exactly the values this store exists to hold: every layout, every `config.agentName`. A
 * refused write kept its optimistic value in the cache while the toast said it had been rolled back, and
 * the change vanished at the next reload — the invisible loss the rollback was added to end.
 *
 * A sequence number asks the question the identity check meant to ask — "has anything written this key
 * since?" — and does not depend on identity at all. Caveat, unchanged from before: if two writes to one key
 * both fail, the later one reverts to what the earlier one put there. Recovering further would mean keeping
 * a known-good value per key, and two consecutive refusals is a server that is telling you plainly.
 */
const writeSeq = new Map<string, number>();

function nextWriteSeq(key: string): number {
  const seq = (writeSeq.get(key) ?? 0) + 1;
  writeSeq.set(key, seq);
  return seq;
}

function persist(client: ReturnType<typeof useClient>, key: string, value: unknown): Promise<unknown> {
  return patchState(client, { [key]: value });
}

function patchState(client: ReturnType<typeof useClient>, patch: Record<string, unknown>): Promise<unknown> {
  inFlightWrites += 1;
  lastWriteAt = Date.now();
  return client.patch('/dashboards', patch).finally(() => {
    inFlightWrites -= 1;
    lastWriteAt = Date.now();
  });
}

/**
 * The same write, for the three callers that cannot use the hook.
 *
 * Creating, renaming and deleting a dashboard each move several keys at once — a rename PATCHes the new
 * layout and nulls three old ones in one body — so they build the patch themselves and update the cache
 * by hand. They were the last `.catch(() => {})` in this family: a delete that failed left the dashboard
 * gone from the list and alive on the server, reappearing at the next reload with no hint why.
 *
 * Recovery here is an invalidate rather than the hook's sequence-guarded revert. There is no single
 * previous value to swap back — the caller has already rewritten the roster and dropped keys from the
 * blob — and refetching is the only thing that makes the list agree with the server again.
 */
export function persistDashboardState(
  client: ReturnType<typeof useClient>,
  queryClient: ReturnType<typeof useQueryClient>,
  patch: Record<string, unknown>,
): void {
  patchState(client, patch).catch((err: unknown) => {
    const keys = Object.keys(patch)
      .map((k) => `"${k}"`)
      .join(', ');
    console.error(`[dashboard-state] failed to persist ${keys}`, err);
    toast.error('Could not save that change', {
      description: `${keys} was not written — reloading from the server. ${err instanceof Error ? err.message : String(err)}`,
    });
    void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
  });
}

/**
 * Read a stored value, or the caller's default when what is stored cannot be that value.
 *
 * Everything here arrives from jsonb as `unknown` and used to be cast straight to `T`. Nothing validates
 * it on the way in either, so one wrong-shaped row — a layout column that defaulted to `[]`, a
 * `panelId → sessionId` map that arrived as an array — became a `.children.map is not a function` during
 * render, which is a white screen with no recovery short of SQL.
 *
 * The check is kind-compatibility, not a schema: only object-shaped defaults are guarded, because those
 * are the values whose consumers traverse or spread them. A wrong *primitive* is a cosmetic surprise; a
 * wrong container is a crash. Deliberately does NOT write the correction back — a read should not
 * overwrite the server, and the next real `setValue` repairs the row anyway.
 */
function readValue<T>(state: UserState, key: string, defaultValue: T): T {
  if (!(key in state)) return defaultValue;

  const stored = state[key];
  const wantsContainer = typeof defaultValue === 'object' && defaultValue !== null;
  if (!wantsContainer) return stored as T;

  const isContainer = typeof stored === 'object' && stored !== null;
  if (isContainer && Array.isArray(stored) === Array.isArray(defaultValue)) return stored as T;

  if (!warned.has(key)) {
    warned.add(key);
    console.warn(`[dashboard-state] ignoring stored "${key}" — wrong shape, using the default instead`, stored);
  }
  return defaultValue;
}

const warned = new Set<string>();

/**
 * Undo an optimistic write the server refused, and say so.
 *
 * The optimistic cache is what made these failures invisible: the UI stayed correct until the next
 * reload, at which point the change was simply gone. That is worst for the things this store holds that
 * are not merely cosmetic — a chat panel's `config.agentName` is the address a peer agent is delivered
 * to, so a silently-dropped write leaves a panel that answers to a name it will not have tomorrow.
 *
 * Reverts only if nothing has written this key since. Writes to one key overlap freely (a window resize
 * fires one per group), and rolling back over a later successful write would turn one failure into two.
 * `seq` rather than the written value is deliberate and load-bearing — see `writeSeq` above.
 */
function revert(
  queryClient: ReturnType<typeof useQueryClient>,
  key: string,
  seq: number,
  previous: unknown,
  err: unknown,
): void {
  if (writeSeq.get(key) === seq) {
    const state = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
    const next = { ...state };
    if (previous === undefined) delete next[key];
    else next[key] = previous;
    queryClient.setQueryData(QUERY_KEY, next);
  }

  console.error(`[dashboard-state] failed to persist "${key}"`, err);
  toast.error('Could not save your layout', {
    description: `"${key}" was not written and has been rolled back. ${err instanceof Error ? err.message : String(err)}`,
  });
}
