/**
 * Keeps the workspace book on the server (server/middleware/book-api.ts).
 *
 * Design rules, all of them learned from losing data:
 *
 * 1. The indicator reports UNSAVED WORK, not the last successful event. The
 *    previous version set "saved" when a save succeeded and then had two code
 *    paths (`!armed`, `inFlight`) that dropped later edits with a bare
 *    `return`, leaving "Saved" on screen while nothing was being written. Now
 *    `dirtyAt` is the single source of truth: if it is set, the UI says so.
 * 2. A save cannot wedge the machine. Every request has an AbortController
 *    timeout, and a watchdog re-drives anything still pending. `inFlight` can
 *    no longer stick true forever and silently swallow edits.
 * 3. Local unsaved edits are never silently replaced by the server copy. The
 *    dirty flag is persisted, so a browser that was edited offline still knows
 *    it has unsaved work after a reload, and startup refuses to overwrite it.
 * 4. A genuine conflict asks the user. Neither side is discarded on its own.
 */
import { create } from "zustand";
import { useWorkspace } from "./store";
import type { WorkspaceData } from "./types";

export type SyncStatus =
  | "starting"
  | "unsaved" // local edits not yet on the server
  | "saving"
  | "saved"
  | "error" // a save failed; retrying. Local copy still holds the work
  | "conflict" // changed elsewhere too; waiting for the user to choose
  | "disabled"; // no server storage configured — browser-only

interface SyncState {
  status: SyncStatus;
  lastSavedAt: string | null;
  detail: string | null;
  /** Set when the server moved on independently; the user must choose. */
  conflict: { data: WorkspaceData; revision: number } | null;
  set: (patch: Partial<Omit<SyncState, "set">>) => void;
}

export const useSync = create<SyncState>((set) => ({
  status: "starting",
  lastSavedAt: null,
  detail: null,
  conflict: null,
  set: (patch) => set(patch),
}));

const SAVE_DEBOUNCE_MS = 900;
const RETRY_MS = 5_000;
/** A save that has not completed by now is treated as lost, not pending. */
const REQUEST_TIMEOUT_MS = 15_000;
/** Backstop: re-drive anything still unsaved, whatever the reason. */
const WATCHDOG_MS = 10_000;

/**
 * Sync bookkeeping, kept in its own localStorage key so it survives a reload.
 * `dirtyAt` outliving the tab is the whole point: it is what tells the next
 * session "this browser holds work the server has not got".
 */
const META_KEY = "fieldbook-sync-v1";
interface Meta {
  revision: number;
  dirtyAt: string | null;
}

function readMeta(): Meta {
  try {
    const raw = localStorage.getItem(META_KEY);
    if (!raw) return { revision: 0, dirtyAt: null };
    const m = JSON.parse(raw) as Partial<Meta>;
    return {
      revision: Number.isSafeInteger(m.revision) ? (m.revision as number) : 0,
      dirtyAt: typeof m.dirtyAt === "string" ? m.dirtyAt : null,
    };
  } catch {
    // Unreadable metadata must not be read as "clean" — that would license
    // overwriting local work we cannot vouch for.
    return { revision: 0, dirtyAt: new Date().toISOString() };
  }
}

function writeMeta(m: Meta) {
  try {
    localStorage.setItem(META_KEY, JSON.stringify(m));
  } catch {
    /* quota or blocked storage; the in-memory copy still drives this session */
  }
}

let meta: Meta = { revision: 0, dirtyAt: null };
let armed = false;
let applyingRemote = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let inFlight = false;
let started = false;

function snapshot(): WorkspaceData {
  const s = useWorkspace.getState();
  return {
    projects: s.projects,
    tasks: s.tasks,
    notes: s.notes,
    reminders: s.reminders,
    boards: s.boards,
    waiting: s.waiting,
    scratch: s.scratch,
    focusTaskId: s.focusTaskId,
  };
}

/** Recompute the visible status from state. Never call `set({status})` ad hoc. */
function publish(patch: Partial<Omit<SyncState, "set">> = {}) {
  const cur = useSync.getState();
  const status: SyncStatus =
    patch.status ??
    (cur.status === "disabled" || cur.conflict
      ? cur.status
      : meta.dirtyAt
        ? inFlight
          ? "saving"
          : "unsaved"
        : "saved");
  cur.set({ status, ...patch });
}

function markDirty() {
  meta = { ...meta, dirtyAt: new Date().toISOString() };
  writeMeta(meta);
  publish();
}

function markClean(revision: number) {
  meta = { revision, dirtyAt: null };
  writeMeta(meta);
  publish({ status: "saved", lastSavedAt: new Date().toISOString(), detail: null });
}

function applyRemote(data: WorkspaceData, revision: number) {
  applyingRemote = true;
  try {
    useWorkspace.getState().importData(data);
    meta = { revision, dirtyAt: null };
    writeMeta(meta);
  } finally {
    // Cleared in a microtask: zustand notifies subscribers after set() returns,
    // and clearing early would read that notification as a local edit.
    queueMicrotask(() => {
      applyingRemote = false;
    });
  }
}

async function putBook(data: WorkspaceData, revision: number): Promise<Response> {
  const ctl = new AbortController();
  const t = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS);
  try {
    return await fetch("/api/book", {
      method: "PUT",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ data, revision }),
      signal: ctl.signal,
    });
  } finally {
    clearTimeout(t);
  }
}

async function save(): Promise<void> {
  // Note what these guards do NOT do: return without reporting. The status is
  // derived from `dirtyAt`, so an edit that cannot be saved right now still
  // shows as unsaved rather than inheriting an old "Saved".
  if (!armed || inFlight || useSync.getState().conflict) {
    publish();
    return;
  }
  if (timer) {
    clearTimeout(timer);
    timer = null;
  }

  const pending = snapshot();
  const base = meta.revision;
  inFlight = true;
  publish();

  try {
    const res = await putBook(pending, base);

    if (res.status === 409) {
      const body = (await res.json()) as { data?: WorkspaceData; revision?: number };
      if (body?.data && typeof body.revision === "number") {
        // Both sides have work. Discarding either one silently is what caused
        // the losses this file exists to prevent, so ask.
        publish({
          status: "conflict",
          conflict: { data: body.data, revision: body.revision },
          detail: "This book was also changed somewhere else.",
        });
      } else {
        publish({ status: "error", detail: "The server rejected the save (no copy returned)." });
      }
      return;
    }

    if (!res.ok) throw new Error(`server returned ${res.status}`);

    const body = (await res.json()) as { revision: number };
    // Only clear the dirty flag if nothing changed while the request was out.
    if (JSON.stringify(snapshot()) === JSON.stringify(pending)) {
      markClean(body.revision);
    } else {
      meta = { revision: body.revision, dirtyAt: meta.dirtyAt };
      writeMeta(meta);
      publish({ lastSavedAt: new Date().toISOString(), detail: null });
      schedule();
    }
  } catch (err) {
    const aborted = err instanceof DOMException && err.name === "AbortError";
    publish({
      status: "error",
      detail: aborted
        ? "The save timed out. Your work is still here and will be retried."
        : err instanceof Error
          ? err.message
          : "save failed",
    });
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => void save(), RETRY_MS);
  } finally {
    inFlight = false;
  }
}

function schedule() {
  if (!armed) {
    publish();
    return;
  }
  if (timer) clearTimeout(timer);
  timer = setTimeout(() => void save(), SAVE_DEBOUNCE_MS);
}

/** Resolve a conflict by keeping what is in this browser. */
export function keepMine(): void {
  const c = useSync.getState().conflict;
  if (!c) return;
  // Adopt the server's revision so the compare-and-swap succeeds. The server
  // keeps their version in book_history either way, so nothing is destroyed.
  meta = { revision: c.revision, dirtyAt: new Date().toISOString() };
  writeMeta(meta);
  useSync.getState().set({ conflict: null, detail: null });
  publish({ status: "unsaved" });
  void save();
}

/** Resolve a conflict by taking the version saved elsewhere. */
export function useTheirs(): void {
  const c = useSync.getState().conflict;
  if (!c) return;
  applyRemote(c.data, c.revision);
  useSync.getState().set({ conflict: null, detail: null });
  publish({ status: "saved", lastSavedAt: new Date().toISOString() });
}

export async function startSync(): Promise<void> {
  if (started) return;
  started = true;
  meta = readMeta();

  try {
    const res = await fetch("/api/book", { headers: { accept: "application/json" } });

    if (res.status === 503) {
      publish({
        status: "disabled",
        detail: "Server storage is not configured, so this book lives only in this browser.",
      });
      return;
    }
    if (!res.ok) throw new Error(`server returned ${res.status}`);

    const body = (await res.json()) as {
      data: WorkspaceData | null;
      revision: number;
      updatedAt: string | null;
    };

    if (meta.dirtyAt) {
      // This browser holds work the server never received. Never overwrite it
      // with the server copy — that is precisely how an offline session's work
      // used to disappear on the next load.
      if (body.data && body.revision !== meta.revision) {
        publish({
          status: "conflict",
          conflict: { data: body.data, revision: body.revision },
          detail: "This browser has unsaved work, and the book also changed elsewhere.",
        });
        armed = true;
        subscribe();
        return;
      }
      publish({ status: "unsaved", detail: "Unsaved work from a previous session." });
    } else if (body.data && body.revision > 0) {
      applyRemote(body.data, body.revision);
      publish({ status: "saved", lastSavedAt: body.updatedAt });
    } else {
      meta = { revision: 0, dirtyAt: new Date().toISOString() };
      writeMeta(meta);
      publish({ status: "unsaved" });
    }
  } catch (err) {
    // Unknown server state: stay armed so edits are still tracked and retried,
    // but say plainly that nothing is saved yet.
    publish({
      status: "error",
      detail: err instanceof Error ? err.message : "could not reach the server",
    });
    armed = true;
    subscribe();
    startWatchdog();
    return;
  }

  armed = true;
  subscribe();
  startWatchdog();
  if (meta.dirtyAt) schedule();
}

function subscribe() {
  useWorkspace.subscribe(() => {
    if (applyingRemote) return;
    markDirty();
    schedule();
  });

  if (typeof document !== "undefined") {
    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "hidden" && meta.dirtyAt) void save();
    });
    // Best-effort warning if they close the tab with work still unsaved.
    window.addEventListener("beforeunload", (e) => {
      if (meta.dirtyAt && !useSync.getState().conflict) {
        e.preventDefault();
        e.returnValue = "";
      }
    });
  }
}

/**
 * The backstop. Whatever goes wrong — a dropped request, a bug in the state
 * machine above, a tab asleep through its own timer — anything still unsaved
 * gets driven again from here.
 */
function startWatchdog() {
  setInterval(() => {
    if (!armed || inFlight || useSync.getState().conflict) return;
    if (meta.dirtyAt && !timer) void save();
  }, WATCHDOG_MS);
}

/** Force an immediate write. */
export function saveNow(): void {
  if (timer) {
    clearTimeout(timer);
    timer = null;
  }
  void save();
}
