/**
 * @file Workspace.tsx
 * @description Merged workspace page combining lanes (agent work units) and runs
 * (Claude Code spawns). Displays a horizontal lane strip at the top with counters,
 * the selected lane's pipeline map, and run configuration/console/history below.
 * Runs are tied to lanes: starting a run posts to POST /api/lanes/:id/start.
 * When a cwd isn't owned by any lane, calls POST /api/lanes/ensure first.
 *
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */
/* =============================================================================
 * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
 * =============================================================================
 * ## Design constraints
 * - Local-first: no telemetry leaves the machine unless the user configures webhooks.
 * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
 *   philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
 * - Destructive flows stay behind explicit confirmation modals and server-side gates.
 * - Internationalization: user-visible strings belong in i18n JSON, not literals here.
 * - The console never writes a lane's stage: no POST /api/lanes/:id/stage calls.
 *
 * ## Internal dependencies
 * - `../lib/api` — REST client including lanes and run endpoints.
 * - `../lib/types` — Lane, Lane Counts, run handles, etc.
 * - `../lib/eventBus` — WebSocket subscription.
 * - `../components/lanes/` — LaneCard, PipelineMap, DestructiveLaneModal.
 * - `../components/run/` — RunConsole, RunSetup, RunHistory, ActiveRunsSwitcher.
 *
 * ## Public surface
 * - `Workspace` — merged page; see TSDoc on the symbol for behavior.
 *
 * ============================================================================= */
/* -----------------------------------------------------------------------------
 * EXPORT CATALOG
 * **Workspace**
 *   The main exported page component. Merges lanes and runs into one workspace.
 *
 * ----------------------------------------------------------------------------- */

import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, LaneCounts, ProofFeature, WSMessage } from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard from "../components/lanes/LaneCard";
import LaneStripCard from "../components/lanes/LaneStripCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";

// ── Page ──────────────────────────────────────────────────────────────

export function Workspace() {
  const { t: tLanes } = useTranslation("lanes");
  const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);

  // Lane state
  const [lanes, setLanes] = useState<Lane[]>([]);
  const [counts, setCounts] = useState<LaneCounts>({ total: 0, running: 0, needs_you: 0, dead: 0 });
  const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
  const [laneActionError, setLaneActionError] = useState<string | null>(null);
  const [addLaneOpen, setAddLaneOpen] = useState(false);
  const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null);
  const [features, setFeatures] = useState<LaneFeature[]>([]);
  const [pipelineTemplates, setPipelineTemplates] = useState<
    { id: string; name: string; nodes: { id: string }[] }[]
  >([]);
  const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
  const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);

  // Run state kept at page level: shared across every pane, or drives the
  // lane strip itself rather than any one pane's form.
  const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
  const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>(
    null
  );
  const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
  const [defaultCwd, setDefaultCwd] = useState<string>("");
  const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);

  // Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
  const refreshLanes = useCallback(async () => {
    try {
      const r = await api.lanes.list();
      setLanes(r.lanes);
      setCounts(r.counts);
      setSelectedLaneId((cur) =>
        cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
      );
    } catch {
      // Silent fail for lanes load
    }
  }, []);

  useEffect(() => {
    api.run
      .binary()
      .then(setBinaryStatus)
      // Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
      // `claude` is missing from PATH — leave the probe unresolved rather than
      // showing a misleading "claude missing" banner for an unrelated fault.
      .catch(() => undefined);
    api.run
      .list()
      .then(setActiveRuns)
      .catch(() => undefined);
    api.lanes
      .pipelines()
      .then((r) => setPipelineTemplates(r.pipelines))
      .catch(() => undefined);
    void refreshLanes();
    api.run
      .cwds()
      .then((r) => {
        setCwdSuggestions(r.items);
        // Pre-fill cwd with the user's home directory — a neutral default.
        // Spawning in the dashboard's own cwd would make ad-hoc runs inherit
        // this repo's project context (.claude/agents, skills, rules,
        // CLAUDE.md, .mcp.json), which is almost never what an ad-hoc run
        // wants and can bloat the initial request (issue #202). Fall back to
        // the dashboard cwd when no home suggestion exists. The user can
        // change it; we just don't want an invisible default.
        const home = r.items.find((s) => s.kind === "home");
        const dashboard = r.items.find((s) => s.kind === "dashboard");
        const preferred = home || dashboard;
        if (preferred) {
          setDefaultCwd(preferred.path);
        }
      })
      .catch(() => undefined);

    // Subscribe to lane updates from the event bus
    return eventBus.subscribe((msg: WSMessage) => {
      if (msg.type !== "lane_update") return;
      const payload = msg.data as { lane?: Lane; removed?: number };
      if (payload.removed !== undefined) {
        void refreshLanes();
        return;
      }
      const lane = payload.lane;
      if (!lane) return;
      setLanes((cur) => {
        const i = cur.findIndex((l) => l.id === lane.id);
        if (i === -1) {
          void refreshLanes();
          return cur;
        }
        const next = [...cur];
        next[i] = lane;
        return next;
      });
    });
  }, [refreshLanes]);

  // A reconnect (e.g. the dashboard server restarting) resumes the WS but does
  // not replay missed lane_update diffs, so a lane whose fields changed while
  // disconnected — status, needs_action — would keep showing its pre-restart
  // badges forever with no further server-side change to broadcast. Refetch
  // the full list whenever the socket comes back up.
  useEffect(
    () =>
      eventBus.onConnection((isConnected) => {
        if (isConnected) void refreshLanes();
      }),
    [refreshLanes]
  );

  const refreshList = useCallback(() => {
    api.run
      .list()
      .then(setActiveRuns)
      .catch(() => undefined);
  }, []);

  // Background poll so the run list and history reflect external changes
  // (server-boot reconciliation, sibling tabs, direct DB edits) even when
  // no WS event fires. Lighter than typical WS gaps; aggressive enough that
  // status flips appear within seconds without needing a manual refresh.
  useEffect(() => {
    const tick = setInterval(() => {
      refreshList();
    }, 5000);
    return () => clearInterval(tick);
  }, [refreshList]);

  // Refresh whenever the tab regains focus / visibility - typical when the
  // user comes back from running `claude` in a terminal and wants to see the
  // current state of every run without waiting for the next poll.
  useEffect(() => {
    const onFocus = () => refreshList();
    const onVis = () => {
      if (document.visibilityState === "visible") refreshList();
    };
    window.addEventListener("focus", onFocus);
    document.addEventListener("visibilitychange", onVis);
    return () => {
      window.removeEventListener("focus", onFocus);
      document.removeEventListener("visibilitychange", onVis);
    };
  }, [refreshList]);

  const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;

  // Feature list follows the selected lane, resets the viewer on lane switch.
  useEffect(() => {
    setViewedFeatureSlug(null);
    setViewedFeature(null);
    if (currentLane === null || currentLane === undefined) {
      setFeatures([]);
      return;
    }
    api.lanes.features
      .list(currentLane.id)
      .then((data) => setFeatures(data.features))
      .catch(() => setFeatures([]));
  }, [currentLane?.id]);

  // Fetch the archived snapshot when the picker selects one — read-only, never
  // touches the live lane.
  useEffect(() => {
    if (!currentLane || !viewedFeatureSlug) {
      setViewedFeature(null);
      return;
    }
    let cancelled = false;
    api.lanes.features
      .show(currentLane.id, viewedFeatureSlug)
      .then((data) => {
        if (!cancelled) setViewedFeature(data.feature);
      })
      .catch(() => {
        if (!cancelled) setViewedFeature(null);
      });
    return () => {
      cancelled = true;
    };
  }, [currentLane?.id, viewedFeatureSlug]);

  // Proof gallery follows the selected lane.
  useEffect(() => {
    if (!currentLane) {
      setProofFeatures([]);
      return;
    }
    api.lanes.proof
      .list(currentLane.id)
      .then((data) => setProofFeatures(data.features))
      .catch(() => setProofFeatures([]));
  }, [currentLane?.id, viewedFeatureSlug]);

  // Resolve the active feature slug: either the user-selected one, or the lane's active_feature_id
  const activeFeatureSlug =
    viewedFeatureSlug ??
    (currentLane?.active_feature_id
      ? features.find((f) => f.id === currentLane?.active_feature_id)?.slug
      : null);
  const proofFeature = activeFeatureSlug
    ? proofFeatures.find((f) => f.slug === activeFeatureSlug)
    : null;

  // Viewport locked only when a live run is showing (TerminalView needs locked
  // viewport for chat scrolling). The config-card screen needs normal page flow
  // so the form is fully reachable on short windows.
  const viewportLocked = paneHasActiveRun;

  const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
    setLaneActionError(null);
    try {
      if (action === "reset" || action === "remove" || action === "purge") {
        await api.lanes.action(id, action, {
          confirm: true,
          expect: body?.expect,
          ...(body?.force === true ? { force: true } : {}),
        });
      } else {
        await api.lanes.action(id, action, body);
      }
      if (action === "remove") await refreshLanes();
    } catch (err) {
      setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown"));
    }
  };

  // Mirrors `ccam lanes pipeline <template> <id>`: same PATCH, same
  // stage-mismatch warning when the current declared stage matches no node
  // in the newly chosen template.
  const handlePipelineChange = async (id: number, pipeline: string) => {
    setLaneActionError(null);
    try {
      const { lane } = await api.lanes.update(id, { pipeline });
      await refreshLanes();
      if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
        setLaneActionError(
          tLanes("pipelinePicker.stageMismatch", {
            stage: lane.stage,
            pipeline: lane.pipeline,
            nodes: lane.pipeline_nodes.map((n) => n.id).join(", "),
          })
        );
      }
    } catch (err) {
      setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown"));
    }
  };

  return (
    <div
      className={
        viewportLocked
          ? "h-[calc(100vh-2.5rem)] lg:h-[calc(100vh-3rem)] flex flex-col gap-5"
          : "space-y-5"
      }
    >
      {/* Page header: what this screen is, and the four numbers that say
          whether anything needs a human right now. */}
      <div className="flex flex-wrap items-center gap-3 border-b border-border pb-3">
        <h2 className="text-base font-semibold tracking-tight text-fg-primary">
          {tLanes("title")}
        </h2>
        <div className="flex flex-wrap items-center gap-1.5 text-xs">
          <span
            data-testid="count-total"
            className="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
          >
            {counts.total} {tLanes("countTotal")}
          </span>
          <span
            data-testid="count-running"
            className="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
          >
            {counts.running} {tLanes("countRunning")}
          </span>
          {counts.needs_you > 0 && (
            <span
              data-testid="count-needs-you"
              className="rounded-full bg-status-warning/20 px-2.5 py-0.5 text-status-warning"
            >
              {counts.needs_you} {tLanes("countNeedsYou")}
            </span>
          )}
          {counts.dead > 0 && (
            <span
              data-testid="count-dead"
              className="rounded-full bg-status-danger/20 px-2.5 py-0.5 text-status-danger"
            >
              {counts.dead} {tLanes("countDead")}
            </span>
          )}
        </div>
        <button
          onClick={() => setAddLaneOpen(true)}
          className="ml-auto flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
          title={tLanes("addLane")}
        >
          <Plus className="h-3.5 w-3.5" />
          {tLanes("add")}
        </button>
      </div>

      {/* Lane carousel: pick a lane here, read it below. Horizontal scroll with
          snap so a dozen lanes stay in one row instead of a wall of cards. */}
      <div
        data-testid="lane-strip"
        className="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1"
      >
        {lanes.map((l) => (
          <LaneStripCard
            key={l.id}
            lane={l}
            selected={selectedLaneId === l.id}
            onSelect={() => setSelectedLaneId(l.id)}
          />
        ))}
        {!lanes.length && (
          <p className="text-sm text-fg-muted">
            {tLanes("emptyState")} <code>ccam lanes add --cwd $(pwd)</code>
          </p>
        )}
      </div>

      {/* The selected lane's pipeline, full width — the thing you actually
          come to this page to read. */}
      {currentLane && (
        <section data-testid="lane-detail" className="card p-4">
          <div className="mb-3 flex flex-wrap items-baseline gap-2">
            <span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
              {tLanes("cardId", { id: currentLane.id })}
            </span>
            <span className="truncate text-sm font-semibold text-fg-primary">
              {currentLane.title || currentLane.cwd}
            </span>
            <select
              data-testid="pipeline-picker"
              aria-label={tLanes("pipelinePicker.label")}
              className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
              value={currentLane.pipeline}
              disabled={!!viewedFeature}
              title={
                viewedFeature
                  ? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
                  : undefined
              }
              onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
            >
              {(pipelineTemplates.length
                ? pipelineTemplates
                : [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
              ).map((p) => (
                <option key={p.id} value={p.id}>
                  {p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
                </option>
              ))}
            </select>
            {/* `stage` defaults to the DB sentinel "idle" until the driving
                session ever calls `ccam stage` — that string collides with
                `status`'s own "idle"/"running" vocabulary, so a lane that is
                actively running but has never declared a stage read as if it
                were sitting idle. Show a distinct label instead of the raw
                sentinel whenever it doesn't match any node this pipeline
                actually has. */}
            <span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
              {currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
                ? currentLane.stage
                : tLanes("stageUndeclared")}
            </span>
            {currentLane.detected_stage && (
              <span
                data-testid="detail-auto-stage"
                title={currentLane.detected_signal || undefined}
                className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
              >
                {tLanes("autoStage", { stage: currentLane.detected_stage })}
              </span>
            )}
            {features.length > 0 && (
              <select
                data-testid="feature-picker"
                className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
                value={viewedFeatureSlug ?? ""}
                onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
              >
                <option value="">{tLanes("features.live")}</option>
                {features.map((f) => (
                  <option key={f.slug} value={f.slug}>
                    {f.slug}
                    {f.archived_at ? ` (${tLanes("features.archived")})` : ""}
                  </option>
                ))}
              </select>
            )}
          </div>
          <div className="mb-3">
            <LaneCard
              lane={currentLane}
              onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
              childWorktrees={lanes.filter(
                (l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
              )}
              onSelectLane={setSelectedLaneId}
            />
          </div>
          <div className="mb-3">
            <PipelineMap
              nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
              detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
            />
            {viewedFeature && (
              <p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
                {tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
              </p>
            )}
          </div>
          {proofFeature &&
            (Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
              <div data-testid="proof-gallery" className="mt-2">
                {proofFeature.ticket_report && (
                  <a
                    href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
                    target="_blank"
                    rel="noreferrer"
                    className="text-xs text-fg-muted"
                  >
                    {tLanes("proof.ticketReport")}
                  </a>
                )}
                {Object.entries(proofFeature.groups).map(([group, images]) => (
                  <div key={group} className="mt-1">
                    <span className="text-xs text-fg-muted">
                      {group} · {images.length}
                    </span>
                    <div className="flex flex-wrap gap-1">
                      {images.slice(0, 8).map((img) => (
                        <img
                          key={img}
                          loading="lazy"
                          className="h-16 w-16 rounded object-cover"
                          src={api.lanes.proof.imageUrl(
                            currentLane.id,
                            proofFeature.slug,
                            group,
                            img
                          )}
                          alt={img}
                        />
                      ))}
                      {images.length > 8 && (
                        <span className="text-xs text-fg-muted">+{images.length - 8}</span>
                      )}
                    </div>
                  </div>
                ))}
              </div>
            )}
          <div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
            <LaneConsolePane
              lanes={lanes}
              laneId={selectedLaneId}
              showLaneSelector={false}
              onLaneIdChange={(id) => setSelectedLaneId(id)}
              onLaneCreated={(lane) =>
                setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
              }
              binaryStatus={binaryStatus}
              cwdSuggestions={cwdSuggestions}
              activeRuns={activeRuns}
              wsConnected={wsConnected}
              defaultCwd={defaultCwd}
              onHasActiveRunChange={setPaneHasActiveRun}
            />
          </div>
        </section>
      )}

      <AddLaneModal
        open={addLaneOpen}
        cwdSuggestions={cwdSuggestions}
        onClose={() => setAddLaneOpen(false)}
        onAdded={(lane) => {
          setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]));
          setSelectedLaneId(lane.id);
          void refreshLanes();
        }}
      />

      {laneActionError && (
        <p
          role="alert"
          className="rounded border border-status-danger bg-status-danger/40 px-3 py-2 text-sm text-status-danger"
        >
          {tLanes("actionError", { message: laneActionError })}
        </p>
      )}

      {/* No lane selected (none exist, or nothing picked yet): the console has
          nowhere to attach, so it falls back to page level. Without this the
          start form would be unreachable on a fresh install. */}
      {!currentLane && (
        <div className="flex min-h-0 flex-col gap-2">
          <LaneConsolePane
            lanes={lanes}
            laneId={selectedLaneId}
            showLaneSelector={true}
            onLaneIdChange={(id) => setSelectedLaneId(id)}
            onLaneCreated={(lane) =>
              setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
            }
            binaryStatus={binaryStatus}
            cwdSuggestions={cwdSuggestions}
            activeRuns={activeRuns}
            wsConnected={wsConnected}
            defaultCwd={defaultCwd}
          />
        </div>
      )}
    </div>
  );
}
