/**
 * `useFirstRun` — view-model for the first-run wizard and profile management
 * (L5). Owns the wizard state (selectable reference candidates + selection +
 * availability) and the actions. Consumes the {@link ProfileGateway}
 * exclusively — never `invoke()` — so the feature is testable with a mock.
 *
 * **Invariant (ticket #28)**: `busy` gates only the requests the wizard cannot
 * render without (`firstRunState`) or that mutate state (`configureProfiles`).
 * It NEVER depends on `detectProfiles`: CLI detection is a detached, best-effort
 * step tracked by the separate {@link FirstRunViewModel.detecting} flag, bounded
 * by {@link DETECT_TIMEOUT_MS}. A detection that panics backend-side (leaving the
 * `invoke` promise forever pending) must not freeze "Save and continue" /
 * "Detect installed CLIs".
 */

import { useCallback, useEffect, useRef, useState } from "react";

import type {
  AgentProfile,
  FirstRunState,
  GatewayError,
  OpenCodeConfig,
  ProfileAvailability,
} from "@/domain";
import { useGateways } from "@/app/di";
import { buildWizardEntries, type WizardEntry, type WizardMode } from "./wizardEntries";

export type { WizardEntry, WizardMode } from "./wizardEntries";

/** How long the UI waits for a detection round before giving up on it. */
export const DETECT_TIMEOUT_MS = 3_000;

/** What the first-run wizard UI needs from this hook. */
export interface FirstRunViewModel {
  /** Whether the wizard should be shown (null while loading). */
  isFirstRun: boolean | null;
  /** The candidate rows (selectable reference profiles). */
  entries: WizardEntry[];
  /** Last error message, or null. */
  error: string | null;
  /** Whether a blocking request is in flight (load / save). Never detection. */
  busy: boolean;
  /** Whether a best-effort detection round is in flight. Never disables actions. */
  detecting: boolean;
  /** Toggles selection of a candidate by id. */
  toggle: (id: string) => void;
  /** Replaces a candidate's profile (edited command/args/injection). */
  updateProfile: (id: string, profile: AgentProfile) => void;
  /**
   * Mints a new OpenCode profile from the seed (F36 — several local OpenCode
   * profiles) and appends it, pre-selected, to the rows. Optionally overrides the
   * name/config (used by "Duplicate" to carry a row's endpoint over).
   */
  addOpenCodeProfile: (input?: {
    name?: string;
    opencode?: OpenCodeConfig;
  }) => Promise<void>;
  /** Removes a candidate by id. */
  remove: (id: string) => void;
  /** Runs detection over all candidates, filling availability. */
  detect: () => Promise<void>;
  /** Persists the selected profiles and closes the wizard. */
  finish: () => Promise<void>;
  /** Re-checks first-run state (e.g. reopening profile management). */
  reload: () => Promise<void>;
}

function describe(e: unknown): string {
  if (e && typeof e === "object" && "message" in e) {
    return String((e as GatewayError).message);
  }
  return String(e);
}

/**
 * @param mode Which wizard to build (ticket #44): `"firstRun"` (default) for the
 * very first launch, or `"edit"` when reopened via *Settings ▸ Configure
 * profiles* — the latter pre-loads and pre-selects the already-configured
 * profiles and does NOT let detection alter that initial selection.
 */
export function useFirstRun(mode: WizardMode = "firstRun"): FirstRunViewModel {
  const { profile } = useGateways();
  const [isFirstRun, setIsFirstRun] = useState<boolean | null>(null);
  const [entries, setEntries] = useState<WizardEntry[]>([]);
  const [error, setError] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);
  const [detecting, setDetecting] = useState(false);

  /** Monotonic id of the latest detection round; older rounds are ignored. */
  const detectRun = useRef(0);
  const detectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(
    () => () => {
      // Unmount: invalidate any in-flight round (its promise may never settle).
      detectRun.current += 1;
      if (detectTimer.current !== null) clearTimeout(detectTimer.current);
    },
    [],
  );

  /**
   * Best-effort detection round, detached from `busy`. A round is abandoned
   * after {@link DETECT_TIMEOUT_MS} — the underlying promise may stay pending
   * forever (backend panic ⇒ no IPC response), so `detecting` is released by a
   * timer, never by the promise alone. `preselect` ticks the installed CLIs (the
   * auto-detection on open); the manual button only refreshes availability.
   */
  const runDetection = useCallback(
    async (
      candidates: AgentProfile[],
      opts: { preselect: boolean; reportError: boolean },
    ) => {
      const run = detectRun.current + 1;
      detectRun.current = run;
      const stale = () => detectRun.current !== run;

      setDetecting(true);
      if (detectTimer.current !== null) clearTimeout(detectTimer.current);
      detectTimer.current = setTimeout(() => {
        if (!stale()) setDetecting(false);
      }, DETECT_TIMEOUT_MS);

      try {
        const results: ProfileAvailability[] =
          await profile.detectProfiles(candidates);
        if (stale()) return;
        const byId = new Map(results.map((r) => [r.profile.id, r.available]));
        setEntries((prev) =>
          prev.map((e) => {
            const available = byId.get(e.profile.id) ?? e.available;
            return {
              ...e,
              available,
              selected: opts.preselect ? available === true : e.selected,
            };
          }),
        );
      } catch (e) {
        // Detection unavailable: rows stay as they are (the user can tick them
        // by hand and re-run). Only the explicit button surfaces the failure.
        if (stale()) return;
        if (opts.reportError) setError(describe(e));
      } finally {
        if (!stale()) {
          if (detectTimer.current !== null) clearTimeout(detectTimer.current);
          detectTimer.current = null;
          setDetecting(false);
        }
      }
    },
    [profile],
  );

  const reload = useCallback(async () => {
    setBusy(true);
    setError(null);
    let candidates: AgentProfile[] | null = null;
    try {
      // In edit mode we also load the already-configured profiles so they come
      // back pre-selected with their real config (ticket #44). Fetched together
      // so a single blocking window covers the whole load.
      const [state, configured]: [FirstRunState, AgentProfile[]] =
        await Promise.all([
          profile.firstRunState(),
          mode === "edit" ? profile.listProfiles() : Promise.resolve([]),
        ]);
      setIsFirstRun(state.isFirstRun);
      const built = buildWizardEntries({
        mode,
        configured,
        references: state.referenceProfiles,
      });
      candidates = built.map((e) => e.profile);
      // Show the rows immediately (availability not probed yet).
      setEntries(built);
    } catch (e) {
      setError(describe(e));
      setIsFirstRun(false);
    } finally {
      // Released as soon as the rows are rendered — detection is not awaited.
      setBusy(false);
    }

    // Auto-detect once on open, detached: only fills availability. In firstRun
    // mode it also pre-checks the installed CLIs (`preselect`); in edit mode it
    // must NOT touch the initial selection — the configured profiles stay
    // selected and references stay unselected (ticket #44), so `preselect:false`.
    // The manual "Detect installed CLIs" button stays available to re-probe.
    if (candidates !== null) {
      void runDetection(candidates, {
        preselect: mode === "firstRun",
        reportError: false,
      });
    }
  }, [profile, runDetection, mode]);

  useEffect(() => {
    void reload();
  }, [reload]);

  const toggle = useCallback((id: string) => {
    setEntries((prev) =>
      prev.map((e) =>
        e.profile.id === id ? { ...e, selected: !e.selected } : e,
      ),
    );
  }, []);

  const updateProfile = useCallback((id: string, next: AgentProfile) => {
    setEntries((prev) =>
      prev.map((e) => (e.profile.id === id ? { ...e, profile: next } : e)),
    );
  }, []);

  const addOpenCodeProfile = useCallback(
    async (input?: { name?: string; opencode?: OpenCodeConfig }) => {
      setError(null);
      try {
        const created = await profile.cloneOpenCodeProfileFromSeed(input);
        // Freshly cloned profiles start selected (the user opted in by adding
        // one) and available=null (detection re-probes them on demand).
        setEntries((prev) => [
          ...prev,
          { profile: created, selected: true, available: null },
        ]);
      } catch (e) {
        setError(describe(e));
      }
    },
    [profile],
  );

  const remove = useCallback((id: string) => {
    setEntries((prev) => prev.filter((e) => e.profile.id !== id));
  }, []);

  const detect = useCallback(async () => {
    setError(null);
    await runDetection(
      entries.map((e) => e.profile),
      { preselect: false, reportError: true },
    );
  }, [runDetection, entries]);

  const finish = useCallback(async () => {
    setBusy(true);
    setError(null);
    try {
      const chosen = entries.filter((e) => e.selected).map((e) => e.profile);
      await profile.configureProfiles(chosen);
      setIsFirstRun(false);
    } catch (e) {
      setError(describe(e));
    } finally {
      setBusy(false);
    }
  }, [profile, entries]);

  return {
    isFirstRun,
    entries,
    error,
    busy,
    detecting,
    toggle,
    updateProfile,
    addOpenCodeProfile,
    remove,
    detect,
    finish,
    reload,
  };
}
