"use client";

import { createContext, useContext, useState, useCallback, useEffect, useRef } from "react";
import { toast } from "sonner";

import type { CategoryDTO, GroupDTO, TodoDTO } from "@/types/board";
import {
  createCategory,
  deleteCategory as deleteCategoryAction,
  reorderCategories,
} from "@/lib/actions/categories";
import {
  createGroup,
  updateGroup as updateGroupAction,
  deleteGroup as deleteGroupAction,
  archiveGroup as archiveGroupAction,
  reorderGroupsInCategory,
  moveGroupToCategory,
} from "@/lib/actions/groups";
import {
  createTodo,
  createTodos,
  updateTodo as updateTodoAction,
  toggleTodo as toggleTodoAction,
  deleteTodo as deleteTodoAction,
} from "@/lib/actions/todos";
import { updateGroupNote } from "@/lib/actions/notes";
import { getBoardSnapshot } from "@/lib/actions/board";

// How often a visible tab checks for changes made on another device (see
// the sync effect in BoardProvider). Deliberately not real time: a phone
// tap lands here within a tick at most -- and immediately if the tab only
// just came to the foreground.
const POLL_INTERVAL_MS = 30_000;

interface BoardContextValue {
  categories: CategoryDTO[];
  // Whether the admin-configured AI provider has an API URL + model saved
  // -- gates AI-powered actions like the "Add using AI" to-do button.
  aiConfigured: boolean;
  addCategory: (name: string) => Promise<void>;
  removeCategory: (categoryId: string) => Promise<void>;
  reorderLanes: (orderedIds: string[]) => Promise<void>;
  addGroup: (categoryId: string, title: string, color: string) => Promise<GroupDTO | undefined>;
  editGroup: (
    groupId: string,
    categoryId: string,
    data: { title?: string; color?: string }
  ) => Promise<void>;
  removeGroup: (groupId: string, categoryId: string) => Promise<void>;
  archiveGroup: (groupId: string, categoryId: string) => Promise<void>;
  reorderGroups: (categoryId: string, orderedIds: string[]) => Promise<void>;
  moveGroup: (
    groupId: string,
    fromCategoryId: string,
    toCategoryId: string,
    orderedTarget: string[],
    orderedSource: string[]
  ) => Promise<void>;
  saveNote: (groupId: string, categoryId: string, noteContent: string) => Promise<boolean>;
  addTodo: (
    groupId: string,
    categoryId: string,
    title: string,
    details?: string
  ) => Promise<boolean>;
  addTodos: (
    groupId: string,
    categoryId: string,
    todos: { title: string; details?: string }[]
  ) => Promise<boolean>;
  editTodo: (
    todoId: string,
    groupId: string,
    categoryId: string,
    data: { title?: string; details?: string | null }
  ) => Promise<boolean>;
  toggleTodoDone: (
    todoId: string,
    groupId: string,
    categoryId: string,
    completed: boolean
  ) => Promise<void>;
  removeTodo: (todoId: string, groupId: string, categoryId: string) => Promise<void>;
  // True while a dnd-kit drag is live -- a state replacement mid-drag would
  // swap the SortableContext items out from under it, so remote sync
  // defers until the drag ends.
  suspendRemoteSync: (suspended: boolean) => void;
}

const BoardContext = createContext<BoardContextValue | null>(null);

export function BoardProvider({
  initialCategories,
  projectId,
  aiConfigured,
  children,
}: {
  initialCategories: CategoryDTO[];
  // Undefined means Home. The Project-scoped board reuses every bit of
  // this provider -- the only calls that need to know which board they're
  // on are the two that operate over "all of this board's categories"
  // rather than a specific existing category/group/todo id.
  projectId?: string;
  aiConfigured: boolean;
  children: React.ReactNode;
}) {
  const [categories, setCategories] = useState(initialCategories);

  // -- Cross-device sync ---------------------------------------------------
  // The board is seeded once from the server and otherwise only changed by
  // this tab's own optimistic mutations, so edits made on a second device
  // (checking off a to-do on a phone, renaming a lane, ...) never reach
  // this tab on their own. The poll below closes that gap: fetch a fresh
  // snapshot and, if it differs from local state and no local write is
  // pending or just landed, apply it. The refs are the guards that keep a
  // poll from ever clobbering local work.
  // Always mirrors `categories` for the diff below (updated by the effect
  // under it rather than every setCategories call site).
  const categoriesRef = useRef(categories);
  // Local mutations in flight (optimistic state ahead of the server).
  const inflightRef = useRef(0);
  // True while a drag is live (set from Board's drag start/end).
  const remoteSyncSuspendedRef = useRef(false);
  // A poll is already running.
  const pollingRef = useRef(false);
  // When the most recent local mutation settled (success or rollback).
  const lastLocalWriteAtRef = useRef(0);

  useEffect(() => {
    categoriesRef.current = categories;
  }, [categories]);

  // Marks a server action as "local work in progress" for the poll's
  // duration, and stamps when it settled. Every mutation below goes
  // through this so the sync effect can tell "state we are about to lose"
  // from "state we will happily overwrite with the server's copy".
  const track = useCallback(
    <T,>(promise: Promise<T>): Promise<T> => {
      inflightRef.current += 1;
      return promise.finally(() => {
        inflightRef.current -= 1;
        lastLocalWriteAtRef.current = Date.now();
      });
    },
    []
  );

  const suspendRemoteSync = useCallback((suspended: boolean) => {
    remoteSyncSuspendedRef.current = suspended;
  }, []);

  useEffect(() => {
    let disposed = false;

    async function refreshBoard() {
      if (disposed || pollingRef.current) return;
      if (remoteSyncSuspendedRef.current || inflightRef.current > 0) return;
      // No point fetching while the tab is hidden; the visibilitychange
      // handler below catches up the moment it is shown again.
      if (document.visibilityState !== "visible") return;
      pollingRef.current = true;
      try {
        const board = await getBoardSnapshot(projectId ?? null);
        if (disposed) return;
        // Re-check the guards at apply time, not just at start: a mutation
        // (or drag) that began while the fetch was in flight makes this
        // snapshot stale relative to local state. A write that settled in
        // the last couple of seconds may post-date the snapshot's read on
        // the server too, so defer it -- the next tick picks everything up.
        if (remoteSyncSuspendedRef.current || inflightRef.current > 0) return;
        if (Date.now() - lastLocalWriteAtRef.current < 2000) return;
        // The common case is "nothing changed" -- skip the setState so the
        // whole board doesn't re-render every tick.
        if (JSON.stringify(board) === JSON.stringify(categoriesRef.current)) return;
        setCategories(board);
      } catch {
        // Offline or a server hiccup -- the next tick retries. (If the
        // session itself is gone, the page's own navigation handles it.)
      } finally {
        pollingRef.current = false;
      }
    }

    function handleVisibilityChange() {
      if (document.visibilityState === "visible") void refreshBoard();
    }

    document.addEventListener("visibilitychange", handleVisibilityChange);
    const timer = window.setInterval(() => void refreshBoard(), POLL_INTERVAL_MS);
    return () => {
      disposed = true;
      document.removeEventListener("visibilitychange", handleVisibilityChange);
      window.clearInterval(timer);
    };
  }, [projectId]);

  const updateCategory = useCallback(
    (categoryId: string, updater: (c: CategoryDTO) => CategoryDTO) => {
      setCategories((prev) => prev.map((c) => (c.id === categoryId ? updater(c) : c)));
    },
    []
  );

  const updateGroupInState = useCallback(
    (categoryId: string, groupId: string, updater: (g: GroupDTO) => GroupDTO) => {
      updateCategory(categoryId, (c) => ({
        ...c,
        groups: c.groups.map((g) => (g.id === groupId ? updater(g) : g)),
      }));
    },
    [updateCategory]
  );

  const addCategory = useCallback(
    async (name: string) => {
      try {
        const category = await track(createCategory(name, projectId));
        setCategories((prev) => [...prev, category]);
      } catch {
        toast.error("Couldn't create category. Try again.");
      }
    },
    [projectId, track]
  );

  const removeCategory = useCallback(async (categoryId: string) => {
    let prevState: CategoryDTO[] = [];
    setCategories((prev) => {
      prevState = prev;
      return prev.filter((c) => c.id !== categoryId);
    });
    try {
      const result = await track(deleteCategoryAction(categoryId));
      if (result?.error) {
        setCategories(prevState);
        toast.error(result.error);
      }
    } catch {
      setCategories(prevState);
      toast.error("Couldn't delete category. Try again.");
    }
  }, [track]);

  const reorderLanes = useCallback(
    async (orderedIds: string[]) => {
      let prevState: CategoryDTO[] = [];
      setCategories((prev) => {
        prevState = prev;
        const byId = new Map(prev.map((c) => [c.id, c]));
        return orderedIds.map((id, order) => ({ ...byId.get(id)!, order }));
      });
      try {
        await track(reorderCategories(orderedIds, projectId));
      } catch {
        setCategories(prevState);
        toast.error("Couldn't reorder categories. Try again.");
      }
    },
    [projectId, track]
  );

  const addGroup = useCallback(async (categoryId: string, title: string, color: string) => {
    try {
      const group = await track(createGroup(categoryId, title, color));
      updateCategory(categoryId, (c) => ({ ...c, groups: [...c.groups, group] }));
      return group;
    } catch {
      toast.error("Couldn't create group. Try again.");
      return undefined;
    }
  }, [updateCategory, track]);

  const editGroup = useCallback(
    async (groupId: string, categoryId: string, data: { title?: string; color?: string }) => {
      try {
        await track(updateGroupAction(groupId, data));
        updateGroupInState(categoryId, groupId, (g) => ({ ...g, ...data }));
      } catch {
        toast.error("Couldn't update group. Try again.");
      }
    },
    [updateGroupInState, track]
  );

  const removeGroup = useCallback(async (groupId: string, categoryId: string) => {
    let prevState: CategoryDTO[] = [];
    setCategories((prev) => {
      prevState = prev;
      return prev.map((c) =>
        c.id === categoryId ? { ...c, groups: c.groups.filter((g) => g.id !== groupId) } : c
      );
    });
    try {
      await track(deleteGroupAction(groupId));
    } catch {
      setCategories(prevState);
      toast.error("Couldn't delete group. Try again.");
    }
  }, [track]);

  const archiveGroup = useCallback(async (groupId: string, categoryId: string) => {
    let prevState: CategoryDTO[] = [];
    setCategories((prev) => {
      prevState = prev;
      return prev.map((c) =>
        c.id === categoryId ? { ...c, groups: c.groups.filter((g) => g.id !== groupId) } : c
      );
    });
    try {
      await track(archiveGroupAction(groupId));
    } catch {
      setCategories(prevState);
      toast.error("Couldn't archive group. Try again.");
    }
  }, [track]);

  const reorderGroups = useCallback(async (categoryId: string, orderedIds: string[]) => {
    let prevState: CategoryDTO[] = [];
    setCategories((prev) => {
      prevState = prev;
      return prev.map((c) => {
        if (c.id !== categoryId) return c;
        const byId = new Map(c.groups.map((g) => [g.id, g]));
        return { ...c, groups: orderedIds.map((id, order) => ({ ...byId.get(id)!, order })) };
      });
    });
    try {
      await track(reorderGroupsInCategory(categoryId, orderedIds));
    } catch {
      setCategories(prevState);
      toast.error("Couldn't reorder groups. Try again.");
    }
  }, [track]);

  const moveGroup = useCallback(
    async (
      groupId: string,
      fromCategoryId: string,
      toCategoryId: string,
      orderedTarget: string[],
      orderedSource: string[]
    ) => {
      let prevState: CategoryDTO[] = [];
      setCategories((prev) => {
        prevState = prev;
        const sourceCat = prev.find((c) => c.id === fromCategoryId);
        const movedGroup = sourceCat?.groups.find((g) => g.id === groupId);
        if (!movedGroup) return prev;

        return prev.map((c) => {
          if (c.id === fromCategoryId) {
            const byId = new Map(c.groups.map((g) => [g.id, g]));
            return {
              ...c,
              groups: orderedSource.map((id, order) => ({ ...byId.get(id)!, order })),
            };
          }
          if (c.id === toCategoryId) {
            const byId = new Map(c.groups.map((g) => [g.id, g]));
            byId.set(groupId, movedGroup);
            return {
              ...c,
              groups: orderedTarget.map((id, order) => ({
                ...byId.get(id)!,
                categoryId: toCategoryId,
                order,
              })),
            };
          }
          return c;
        });
      });
      try {
        await track(moveGroupToCategory(groupId, toCategoryId, orderedTarget, orderedSource));
      } catch {
        setCategories(prevState);
        toast.error("Couldn't move group. Try again.");
      }
    },
    [track]
  );

  const saveNote = useCallback(
    async (groupId: string, categoryId: string, noteContent: string) => {
      try {
        await track(updateGroupNote(groupId, noteContent));
        updateGroupInState(categoryId, groupId, (g) => ({ ...g, noteContent }));
        return true;
      } catch {
        toast.error("Couldn't save note. Try again.");
        return false;
      }
    },
    [updateGroupInState, track]
  );

  const addTodo = useCallback(
    async (groupId: string, categoryId: string, title: string, details?: string) => {
      try {
        const todo = await track(createTodo(groupId, title, details));
        updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, todo] }));
        return true;
      } catch {
        toast.error("Couldn't add to-do. Try again.");
        return false;
      }
    },
    [updateGroupInState, track]
  );

  const addTodos = useCallback(
    async (groupId: string, categoryId: string, todos: { title: string; details?: string }[]) => {
      try {
        const created = await track(createTodos(groupId, todos));
        updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, ...created] }));
        return true;
      } catch {
        toast.error("Couldn't add to-dos. Try again.");
        return false;
      }
    },
    [updateGroupInState, track]
  );

  const editTodo = useCallback(
    async (
      todoId: string,
      groupId: string,
      categoryId: string,
      data: { title?: string; details?: string | null }
    ) => {
      try {
        await track(updateTodoAction(todoId, data));
        // Approximates the server's own `updatedAt` (set by the same write,
        // a moment later) closely enough for display purposes, without
        // waiting on a round trip just to read it back.
        const updatedAt = new Date().toISOString();
        updateGroupInState(categoryId, groupId, (g) => ({
          ...g,
          todos: g.todos.map((t) => (t.id === todoId ? { ...t, ...data, updatedAt } : t)),
        }));
        return true;
      } catch {
        toast.error("Couldn't update to-do. Try again.");
        return false;
      }
    },
    [updateGroupInState, track]
  );

  const toggleTodoDone = useCallback(
    async (todoId: string, groupId: string, categoryId: string, completed: boolean) => {
      const now = new Date().toISOString();
      let prevTodos: TodoDTO[] = [];
      updateCategory(categoryId, (c) => ({
        ...c,
        groups: c.groups.map((g) => {
          if (g.id !== groupId) return g;
          prevTodos = g.todos;
          return {
            ...g,
            todos: g.todos.map((t) =>
              t.id === todoId
                ? { ...t, completed, completedAt: completed ? now : null, updatedAt: now }
                : t
            ),
          };
        }),
      }));
      try {
        await track(toggleTodoAction(todoId, completed));
      } catch {
        updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
        toast.error("Couldn't update to-do. Try again.");
      }
    },
    [updateCategory, updateGroupInState, track]
  );

  const removeTodo = useCallback(
    async (todoId: string, groupId: string, categoryId: string) => {
      let prevTodos: TodoDTO[] = [];
      updateCategory(categoryId, (c) => ({
        ...c,
        groups: c.groups.map((g) => {
          if (g.id !== groupId) return g;
          prevTodos = g.todos;
          return { ...g, todos: g.todos.filter((t) => t.id !== todoId) };
        }),
      }));
      try {
        await track(deleteTodoAction(todoId));
      } catch {
        updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
        toast.error("Couldn't delete to-do. Try again.");
      }
    },
    [updateCategory, updateGroupInState, track]
  );

  return (
    <BoardContext.Provider
      value={{
        categories,
        aiConfigured,
        addCategory,
        removeCategory,
        reorderLanes,
        addGroup,
        editGroup,
        removeGroup,
        archiveGroup,
        reorderGroups,
        moveGroup,
        saveNote,
        addTodo,
        addTodos,
        editTodo,
        toggleTodoDone,
        removeTodo,
        suspendRemoteSync,
      }}
    >
      {children}
    </BoardContext.Provider>
  );
}

export function useBoard() {
  const ctx = useContext(BoardContext);
  if (!ctx) throw new Error("useBoard must be used within a BoardProvider");
  return ctx;
}
