import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { useConnection } from './ConnectionContext';
import { buildFileHistoryIndex } from '../data/versionGraph';

/**
 * Everything pulled from the connected server: user roster (with presence),
 * revision history, the depot tree, availability, and meetings.
 *
 * Data is cached PER PROJECT. Every project-scoped fetch returns different
 * results (and the roster/availability are scoped to the active project's
 * members), so the app keeps one bucket per projectId and exposes the active
 * project's bucket. This means switching projects shows that project's data
 * instantly instead of flashing empty while a refresh lands — and, crucially,
 * a consumer that diffs local state against server state (useFileControl's
 * "files deleted on the server" detection) never compares the newly-selected
 * project's workspace against the previously-selected project's depot tree,
 * which used to fire a spurious "everything was deleted" prompt on switch.
 *
 * Everything clears on disconnect. A poll keeps the active project's presence
 * and teammates' edits fresh; a WebSocket push channel can replace it later
 * (CambiumServer/server.py).
 */

const POLL_MS = 20000;

const EMPTY_BUCKET = {
  users: [],
  revisions: [],
  depotTree: null, // { children, modifiedMs }
  depotError: null,
  locks: {}, // relPath → { username, deviceId, deviceName }
  availability: {},
  meetings: [],
  ignoreText: '', // depot head ignore.txt (p4ignore-style); '' = nothing ignored
  usersLoaded: false, // first successful /api/users fetch landed for this project
  availabilityLoaded: false, // same, for /api/availability
};

const ServerDataContext = createContext(null);

export function ServerDataProvider({ children }) {
  const { connected, api, account, activeProjectId } = useConnection();
  // { [projectId]: bucket }. Buckets are only ever created for a real project.
  const [store, setStore] = useState({});

  // The active project can change while an async fetch is in flight; capture
  // the id a fetch was issued for so its result lands in the right bucket.
  const activeRef = useRef(activeProjectId);
  activeRef.current = activeProjectId;

  const patchBucket = useCallback((projectId, patch) => {
    if (!projectId) return;
    setStore((prev) => ({
      ...prev,
      [projectId]: { ...EMPTY_BUCKET, ...prev[projectId], ...patch },
    }));
  }, []);

  // Poll dedupe: the six pollers re-fetch every 20 s and JSON parsing gives
  // every payload a fresh identity even when the bytes are unchanged. Effects
  // downstream key on identity (useFileControl's compute_status effect), so a
  // no-op poll used to cascade into six render passes and an IPC round trip.
  // Serialize each applied patch and skip the state update when the next one
  // is byte-identical.
  const sigRef = useRef({}); // { [projectId]: { [sigKey]: serialized patch } }

  const patchIfChanged = useCallback(
    (projectId, sigKey, patch) => {
      if (!projectId) return;
      const sig = JSON.stringify(patch);
      const sigs = (sigRef.current[projectId] ??= {});
      if (sigs[sigKey] === sig) return;
      sigs[sigKey] = sig;
      patchBucket(projectId, patch);
    },
    [patchBucket]
  );

  // Optimistic writers patch the bucket directly, so the stored signature no
  // longer describes what's on screen. Drop it: the next poll must land even
  // if the server payload byte-matches the pre-optimistic one (e.g. an
  // optimistic lock the server rejected).
  const clearSig = useCallback((projectId, sigKey) => {
    delete sigRef.current[projectId]?.[sigKey];
  }, []);

  const refreshUsers = useCallback(async () => {
    if (!api) return;
    const pid = activeRef.current;
    const result = await api.getUsers();
    if (result.ok) patchIfChanged(pid, 'users', { users: result.users, usersLoaded: true });
  }, [api, patchIfChanged]);

  const refreshRevisions = useCallback(async () => {
    if (!api) return;
    const pid = activeRef.current;
    const result = await api.getRevisions();
    if (result.ok) patchIfChanged(pid, 'revisions', { revisions: result.revisions });
  }, [api, patchIfChanged]);

  const refreshDepotTree = useCallback(async () => {
    if (!api) return null;
    const pid = activeRef.current;
    const result = await api.getDepotTree();
    if (result.ok) {
      patchIfChanged(pid, 'depot', {
        depotTree: {
          children: result.children,
          modifiedMs: result.modifiedMs,
        },
        depotError: null,
      });
      return result; // callers (Pull Latest) need the fresh tree immediately
    }
    // Error path bypasses the signature (and voids it): the next successful
    // fetch must repaint even if it byte-matches the pre-error tree, because
    // the bucket now carries depotError.
    clearSig(pid, 'depot');
    patchBucket(pid, { depotError: result.error });
    return null;
  }, [api, patchIfChanged, patchBucket, clearSig]);

  const refreshLocks = useCallback(async () => {
    if (!api) return;
    const pid = activeRef.current;
    const result = await api.getLocks();
    if (result.ok) patchIfChanged(pid, 'locks', { locks: result.locks ?? {} });
  }, [api, patchIfChanged]);

  const refreshIgnore = useCallback(async () => {
    if (!api) return;
    const pid = activeRef.current;
    const result = await api.getIgnore();
    if (result.ok) patchIfChanged(pid, 'ignore', { ignoreText: result.text ?? '' });
  }, [api, patchIfChanged]);

  const refreshAvailability = useCallback(async () => {
    if (!api) return;
    const pid = activeRef.current;
    const result = await api.getAvailability();
    if (result.ok) {
      patchIfChanged(pid, 'availability', {
        availability: result.availability,
        meetings: result.meetings,
        availabilityLoaded: true,
      });
    }
  }, [api, patchIfChanged]);

  // Optimistic lock table update (checkout/checkin) for the active project.
  const setLocks = useCallback(
    (locks) => {
      const pid = activeRef.current;
      clearSig(pid, 'locks');
      patchBucket(pid, { locks: locks ?? {} });
    },
    [patchBucket, clearSig]
  );

  // Initial load + poll for the active project; clear everything on disconnect.
  useEffect(() => {
    if (!connected || !api || !activeProjectId) {
      sigRef.current = {};
      setStore({});
      return undefined;
    }
    refreshUsers();
    refreshRevisions();
    refreshDepotTree();
    refreshLocks();
    refreshAvailability();
    refreshIgnore();
    const timer = setInterval(() => {
      refreshUsers();
      refreshLocks();
      refreshAvailability();
      // Keep teammates' edits fresh too: the depot tree drives file statuses
      // (a teammate's submit shows as Outdated) and the revision log drives the
      // History window. Without these two, History only updated after your own
      // submit/rollback and stayed stale for everyone else's work.
      refreshDepotTree();
      refreshRevisions();
      // The ignore list can change (someone submits ignore.txt); keep it fresh.
      refreshIgnore();
    }, POLL_MS);
    return () => clearInterval(timer);
    // activeProjectId is a dependency because the API client is a stable
    // instance across project switches; changing the active project changes
    // what every project-scoped fetch (and the scoped users/availability
    // filters) returns, so a switch must re-load into that project's bucket.
  }, [
    connected,
    api,
    activeProjectId,
    refreshUsers,
    refreshRevisions,
    refreshDepotTree,
    refreshLocks,
    refreshAvailability,
    refreshIgnore,
  ]);

  const bucket = store[activeProjectId] ?? EMPTY_BUCKET;
  const {
    users,
    revisions,
    depotTree,
    depotError,
    locks,
    availability,
    meetings,
    ignoreText,
    usersLoaded,
    availabilityLoaded,
  } = bucket;

  // Whole-log walk once per revision-log change (memoized): per-file history
  // for the version graph + tree fraction. FileTree is virtualized, so only
  // visible rows slice this.
  const fileHistoryIndex = useMemo(() => buildFileHistoryIndex(revisions), [revisions]);

  // Overlay the session's own account onto the roster so edits in Account
  // settings show up immediately instead of waiting for the next poll.
  const roster = useMemo(() => {
    if (!account) return users;
    return users.map((user) =>
      user.username.toLowerCase() === account.username.toLowerCase()
        ? {
            ...user,
            displayName: account.displayName,
            status: account.status,
            timezone: account.timezone,
            online: true,
          }
        : user
    );
  }, [users, account]);

  const getUser = useCallback(
    (username) => {
      const found = roster.find(
        (u) => u.username.toLowerCase() === (username ?? '').toLowerCase()
      );
      if (found) {
        return {
          username: found.username,
          displayName: found.displayName,
          avatarUrl:
            found.username.toLowerCase() === account?.username.toLowerCase()
              ? account.avatarUrl
              : api?.avatarUrl(found.avatar) ?? null,
        };
      }
      const handle = (username ?? '').replace(/^@+/, '') || 'user';
      return {
        username,
        displayName: handle.charAt(0).toUpperCase() + handle.slice(1),
        avatarUrl: null,
      };
    },
    [roster, account, api]
  );

  const setMyAvailability = useCallback(
    (slots) => {
      if (!api || !account) return;
      const pid = activeRef.current;
      const list = [...slots].sort();
      // Optimistic: the grid reflects the change immediately; the PUT persists it.
      clearSig(pid, 'availability');
      patchBucket(pid, { availability: { ...(store[pid]?.availability ?? {}), [account.username]: list } });
      api.setMyAvailability(list);
    },
    [api, account, patchBucket, store, clearSig]
  );

  const saveMeeting = useCallback(
    async (payload, meetingId = null) => {
      if (!api) return { ok: false, error: 'Not connected.' };
      const pid = activeRef.current;
      const result = meetingId
        ? await api.updateMeeting(meetingId, payload)
        : await api.createMeeting(payload);
      if (result.ok && result.meeting) {
        const current = store[pid]?.meetings ?? [];
        clearSig(pid, 'availability');
        patchBucket(pid, {
          meetings: meetingId
            ? current.map((m) => (m.id === meetingId ? result.meeting : m))
            : [...current, result.meeting],
        });
      }
      return result;
    },
    [api, patchBucket, store, clearSig]
  );

  const removeMeeting = useCallback(
    async (meetingId) => {
      if (!api) return { ok: false, error: 'Not connected.' };
      const pid = activeRef.current;
      const result = await api.deleteMeeting(meetingId);
      if (result.ok) {
        clearSig(pid, 'availability');
        patchBucket(pid, {
          meetings: (store[pid]?.meetings ?? []).filter((m) => m.id !== meetingId),
        });
      }
      return result;
    },
    [api, patchBucket, store, clearSig]
  );

  const value = useMemo(
    () => ({
      users: roster,
      revisions,
      fileHistoryIndex,
      depotTree,
      depotError,
      locks,
      setLocks,
      availability,
      meetings,
      ignoreText,
      usersLoaded,
      availabilityLoaded,
      getUser,
      refreshUsers,
      refreshRevisions,
      refreshDepotTree,
      refreshLocks,
      refreshAvailability,
      refreshIgnore,
      setMyAvailability,
      saveMeeting,
      removeMeeting,
    }),
    [
      roster,
      revisions,
      fileHistoryIndex,
      depotTree,
      depotError,
      locks,
      setLocks,
      availability,
      meetings,
      ignoreText,
      usersLoaded,
      availabilityLoaded,
      getUser,
      refreshUsers,
      refreshRevisions,
      refreshDepotTree,
      refreshLocks,
      refreshAvailability,
      refreshIgnore,
      setMyAvailability,
      saveMeeting,
      removeMeeting,
    ]
  );

  return <ServerDataContext.Provider value={value}>{children}</ServerDataContext.Provider>;
}

export function useServerData() {
  const ctx = useContext(ServerDataContext);
  if (!ctx) throw new Error('useServerData must be used within ServerDataProvider');
  return ctx;
}
