/**
 * @file One lane's card: title, progress bar with time-on-phase, branch/CI/PR
 * facts, the "needs you" banner sourced from Claude Code's Notification hook,
 * and the control row — which ends in the two deletions (lane, history) as
 * plain buttons, each gated by DestructiveLaneModal. A dead lane (its driving
 * session went silent while it should have been working) is called out
 * loudly — that is the failure this view exists to catch. Stage, kind, and
 * the "auto: <stage>" chip are NOT repeated here — this card only ever
 * renders inside the Workspace lane-detail section, whose header above it
 * already shows them.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DestructiveLaneModal } from "./DestructiveLaneModal";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";

/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
 *  each call is three git subprocesses server-side, and a branch name does not
 *  change on the timescale the lane list is polled at. */
const GIT_REFRESH_MS = 30_000;

/** How often a mounted card re-probes its stack. Faster than the git refresh
 *  because a stack dying is exactly what the user needs to see, and slower than
 *  the lane poll because each call opens a socket per declared port. */
const RUNTIME_REFRESH_MS = 10_000;

/** How often a mounted card re-reads locks held by this lane. Same refresh rate
 *  as the git facts (slow, since lock state changes infrequently). */
const LOCKS_REFRESH_MS = 30_000;

/**
 * The lane's own working copy, fetched per card rather than folded into the
 * polled lane list. Absent facts are not an error state: a lane may point at a
 * plain directory, and it renders as a card without a git row.
 */
function useLaneGitFacts(laneId: number): LaneGitFacts | null {
  const [facts, setFacts] = useState<LaneGitFacts | null>(null);

  useEffect(() => {
    let alive = true;
    const read = () => {
      api.lanes
        .git(laneId)
        .then((f) => {
          if (alive) setFacts(f);
        })
        .catch(() => {
          // Silent: a card that cannot read git shows the rest of itself. An
          // error banner here would fire on every lane on every server blip.
          if (alive) setFacts({ available: false });
        });
    };
    read();
    const timer = setInterval(read, GIT_REFRESH_MS);
    return () => {
      alive = false;
      clearInterval(timer);
    };
  }, [laneId]);

  return facts;
}

/**
 * The lane's own application stack. Same shape and same silence as the git
 * facts above: a lane without a `.ccam/profile` simply has no runtime row, which
 * is the common case and not an error worth a banner.
 *
 * `bump` lets an up/down action re-read immediately instead of waiting out the
 * poll interval.
 */
function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null {
  const [runtime, setRuntime] = useState<LaneRuntime | null>(null);

  useEffect(() => {
    let alive = true;
    const read = () => {
      api.lanes
        .runtime(laneId)
        .then((r) => {
          if (alive) setRuntime(r);
        })
        .catch(() => {
          if (alive) setRuntime({ available: false });
        });
    };
    read();
    const timer = setInterval(read, RUNTIME_REFRESH_MS);
    return () => {
      alive = false;
      clearInterval(timer);
    };
  }, [laneId, bump]);

  return runtime;
}

/**
 * Locks held by THIS lane, polled the same way runtime/git facts are.
 */
function useLaneLocks(laneSlot: number | null): NamedLock[] {
  const [locks, setLocks] = useState<NamedLock[]>([]);

  useEffect(() => {
    if (!laneSlot) return;
    let alive = true;
    const holder = `lane${laneSlot}`;
    const read = () => {
      api.locks
        .list()
        .then((data) => {
          if (alive) setLocks(data.locks.filter((l) => l.holder === holder));
        })
        .catch(() => {
          /* fails silently, same contract as the git/runtime pollers */
        });
    };
    read();
    const timer = setInterval(read, LOCKS_REFRESH_MS);
    return () => {
      alive = false;
      clearInterval(timer);
    };
  }, [laneSlot]);

  return locks;
}

const INTEGRATION_NAMES = ["tracker", "dev_qc", "ci_wait"] as const;

function useLaneIntegrations(
  laneId: number,
  available: boolean
): Record<(typeof INTEGRATION_NAMES)[number], boolean> | null {
  const [state, setState] = useState<Record<string, boolean> | null>(null);

  useEffect(() => {
    if (!available) {
      setState(null);
      return;
    }
    let alive = true;
    Promise.all(INTEGRATION_NAMES.map((name) => api.lanes.integration(laneId, name)))
      .then((results) => {
        if (!alive) return;
        const next: Record<string, boolean> = {};
        INTEGRATION_NAMES.forEach((name, i) => {
          next[name] = results[i]?.enabled ?? false;
        });
        setState(next);
      })
      .catch(() => {
        if (alive) setState(null);
      });
    return () => {
      alive = false;
    };
  }, [laneId, available]);

  return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
}

const LIVENESS_DOT: Record<Lane["liveness"], string> = {
  active: "bg-status-success",
  idle: "bg-surface-4",
  dead: "bg-status-danger",
};

function since(sec: number | null): string {
  if (sec === null) return "—";
  if (sec < 60) return `${sec}s`;
  if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
  return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
}

export default function LaneCard({
  lane,
  onAction,
  childWorktrees,
  onSelectLane,
}: {
  lane: Lane;
  onAction: (action: string, body?: Record<string, unknown>) => void;
  /** Other lanes whose `source_repo` is this lane's `cwd` — populated only
   *  when this lane is itself a source repo (typically an adopted one) that
   *  other lanes were provisioned as worktrees from. */
  childWorktrees?: Lane[];
  /** Jumps the Workspace page's selection to another lane's card. */
  onSelectLane?: (id: number) => void;
}) {
  const { t } = useTranslation(["lanes"]);
  const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>(
    null
  );
  const [menuOpen, setMenuOpen] = useState(false);
  const [runtimeBump, setRuntimeBump] = useState(0);
  const [runtimeBusy, setRuntimeBusy] = useState<"up" | "down" | null>(null);
  const [bootLine, setBootLine] = useState<string | null>(null);
  const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(
    null
  );
  const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
  const git = useLaneGitFacts(lane.id);
  const runtime = useLaneRuntime(lane.id, runtimeBump);
  const locks = useLaneLocks(lane.slot);
  const integrations = useLaneIntegrations(lane.id, runtime?.available === true);

  /**
   * Boot or stop the lane's stack. Deliberately NOT routed through `onAction`:
   * that prop drives the lane's Claude run, and folding a second lifecycle into
   * it would make "stop" ambiguous about which thing it stops.
   *
   * `up` answers 202 and keeps booting in the background, so the button stays
   * busy until the server's `lane_runtime` message says the attempt finished —
   * resolving the request is not the same as the stack being up.
   */
  const runtimeAction = async (which: "up" | "down") => {
    setRuntimeBusy(which);
    setBootLine(null);
    try {
      if (which === "down") {
        await api.lanes.down(lane.id);
        setRuntimeBusy(null);
      } else {
        await api.lanes.up(lane.id);
      }
    } catch {
      // The failure surfaces as the runtime row's lastError on the next read; a
      // toast here would say the same thing twice.
      setRuntimeBusy(null);
    } finally {
      setRuntimeBump((n) => n + 1);
    }
  };

  const runLaneAction = async (
    which: "agents" | "mcp" | "sync-check",
    fn: () => Promise<string>
  ) => {
    setLaneActionBusy(which);
    setLaneActionResult(null);
    try {
      setLaneActionResult(await fn());
    } catch (err) {
      setLaneActionResult(err instanceof Error ? err.message : String(err));
    } finally {
      setLaneActionBusy(null);
    }
  };

  const handleAgentsInstall = () =>
    runLaneAction("agents", async () => {
      const result = await api.lanes.agentsInstall(lane.id);
      return t("actions.agentsInstallResult", { files: result.installed.join(", ") });
    });

  const handleMcpSync = () =>
    runLaneAction("mcp", async () => {
      const result = await api.lanes.mcpSync(lane.id);
      return t("actions.mcpSyncResult", { servers: result.servers.join(", ") || "none" });
    });

  const handleSyncCheck = () =>
    runLaneAction("sync-check", async () => {
      const result = await api.lanes.syncBaseCheck(lane.id);
      if (result.code === 5 && result.collisions && result.collisions.length > 0) {
        const c = result.collisions[0]!;
        return t("actions.syncCheckCollision", { file: c.file, suggestion: c.suggestion });
      }
      return t("actions.syncCheckClean", {
        count: result.devDelta?.length ?? 0,
        overlap: result.overlap?.length ? result.overlap.join(", ") : "none",
      });
    });

  /**
   * Live boot feedback. A build can run for minutes, and a card showing only a
   * disabled button through all of it reads as a hang. The hook's own output
   * lines are the honest progress indicator.
   */
  useEffect(
    () =>
      eventBus.subscribe((msg) => {
        const data = msg.data as { laneId?: number; line?: string } | undefined;
        if (!data || data.laneId !== lane.id) return;
        if (msg.type === "lane_hook_output" && typeof data.line === "string") {
          setBootLine(data.line);
        }
        if (msg.type === "lane_runtime") {
          setRuntimeBusy(null);
          setBootLine(null);
          setRuntimeBump((n) => n + 1);
        }
      }),
    [lane.id]
  );

  return (
    <>
      <div
        data-testid={`lane-card-${lane.id}`}
        className="flex flex-col rounded-xl border border-border bg-surface-4 p-4 transition-colors hover:border-border-light"
      >
        {/* Identity strip: which lane, and is it alive. Kept on one line and in
            uppercase so a wall of cards can be scanned vertically. */}
        <div className="mb-3 flex items-center justify-between">
          <span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
            {t("cardId", { id: lane.id })}
          </span>
          <span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
            <span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
            {/* i18next returns the KEY on a miss, so `|| raw` never fires and a
                non-standard status rendered as the literal "status.foo".
                defaultValue makes it degrade to the raw status instead. */}
            {lane.liveness === "dead"
              ? t("statusDead")
              : t(`status.${lane.status}`, { defaultValue: lane.status })}
          </span>
        </div>

        <h3 className="mb-3 text-[15px] font-semibold leading-snug text-fg-primary">
          {lane.title || lane.cwd}
        </h3>

        {/* Progress line: how far through the pipeline and how long the
            current stage has been sitting there. The stage's own name is
            already in the Workspace header above; not repeated here. */}
        <div className="mb-2 flex items-center gap-2 text-xs">
          <div
            className={`h-1 flex-1 overflow-hidden rounded-full ${
              lane.progress > 0 ? "bg-surface-2" : "bg-transparent"
            }`}
          >
            <div
              data-testid="lane-progress-fill"
              className="h-full rounded-full bg-blue-600 transition-[width]"
              style={{ width: `${lane.progress}%` }}
            />
          </div>
          {lane.progress > 0 && (
            <span className="tabular-nums text-fg-secondary">{lane.progress}%</span>
          )}
          <span className="tabular-nums text-fg-muted">{since(lane.stage_seconds)}</span>
        </div>

        {lane.ci_status && (
          <div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px]">
            <span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
              CI {lane.ci_status}
            </span>
          </div>
        )}

        {lane.needs_action && (
          <div className="mb-3 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
            ⚠ {lane.needs_action}
          </div>
        )}

        {/* Runtime strip: the lane's own app stack, shown only for lanes that
            declare a profile. A port that drifted from `base + slot` is called
            out — the number is otherwise predictable from the slot, and silently
            serving on a different one is exactly the surprise worth flagging. */}
        {runtime?.available && runtime.provisioned && (
          <div
            data-testid={`lane-runtime-${lane.id}`}
            className="mb-3 rounded border border-border bg-surface-2/50 px-2 py-1.5 font-mono text-[11px]"
          >
            <div className="mb-1 flex items-center justify-between">
              <span className="text-fg-muted">{t("runtime.slot", { slot: runtime.slot })}</span>
              <span
                className={runtime.healthy ? "text-status-success" : "text-fg-muted"}
                data-testid="lane-runtime-state"
              >
                {runtime.healthy
                  ? t("runtime.healthy")
                  : runtime.up
                    ? t("runtime.partial")
                    : t("runtime.down")}
              </span>
            </div>
            {Object.entries(runtime.ports).map(([name, info]) => (
              <div key={name} className="flex items-center gap-1.5 truncate">
                <span
                  className={`h-1.5 w-1.5 shrink-0 rounded-full ${
                    info.listening ? "bg-status-success" : "bg-surface-4"
                  }`}
                />
                <span className="text-fg-secondary">{name}</span>
                <span className="text-fg-muted">:{info.port ?? "—"}</span>
                {info.port !== null && info.port !== info.expected && (
                  <span
                    className="truncate text-status-warning/80"
                    title={t("runtime.steppedAsideTitle", { expected: info.expected })}
                  >
                    ⚠ {info.expected}
                  </span>
                )}
              </div>
            ))}
            {/* While a boot is in flight the hook's own latest line IS the
                progress bar — a build can take minutes, and a disabled button
                with nothing moving reads as a hang. */}
            {bootLine !== null && (
              <div
                data-testid="lane-runtime-bootline"
                className="mt-1 truncate text-fg-muted"
                title={bootLine}
              >
                {bootLine}
              </div>
            )}
            {bootLine === null && runtime.lastError && (
              <div
                className="mt-1 truncate text-status-danger/90"
                title={runtime.lastError.message}
              >
                {runtime.lastError.code || "error"}: {runtime.lastError.message}
              </div>
            )}
          </div>
        )}

        {locks.length > 0 && (
          <div
            className="mb-3 flex items-center gap-1 text-xs text-status-warning"
            data-testid={`lane-locks-${lane.id}`}
            title={locks.map((l) => `${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")}
          >
            🔒 {t("locks.held", { count: locks.length })}
          </div>
        )}

        <dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary">
          {git?.available && (
            <div data-testid="lane-git" className="space-y-1">
              <div className="truncate">
                ⑂ {git.branch}
                <span className="ml-2 text-fg-muted">{git.head}</span>
              </div>
              <div className="truncate text-fg-muted" title={git.subject}>
                {git.subject}
              </div>
              {(git.dirty > 0 || git.untracked > 0) && (
                <div className="text-status-warning/80">
                  {t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
                </div>
              )}
            </div>
          )}
          {/* The lane's own recorded branch, shown only when git could not be
              read — otherwise it duplicates the live branch above. */}
          {!git?.available && lane.branch && <div className="truncate">⑂ {lane.branch}</div>}
          <div className="truncate text-fg-muted" title={lane.cwd}>
            {lane.cwd}
          </div>
        </dl>

        {childWorktrees && childWorktrees.length > 0 && (
          <div
            data-testid="lane-child-worktrees"
            className="mb-3 space-y-1 text-[11px] text-fg-secondary"
          >
            <div className="text-fg-muted">
              {t("worktrees.heading", { count: childWorktrees.length })}
            </div>
            <ul className="space-y-0.5">
              {childWorktrees.map((w) => (
                <li key={w.id}>
                  <button
                    type="button"
                    onClick={() => onSelectLane?.(w.id)}
                    className="truncate text-left text-blue-400 hover:underline"
                    title={w.cwd}
                  >
                    #{w.id} {w.title || w.cwd} · {w.status}
                  </button>
                </li>
              ))}
            </ul>
          </div>
        )}

        {integrations && (
          <div className="mb-2 flex items-center gap-1.5 text-[10px]">
            {(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
              <span
                key={name}
                data-testid={`lane-integration-${name}`}
                className={`rounded-full px-2 py-0.5 ${
                  integrations[name]
                    ? "bg-status-success/10 text-status-success"
                    : "bg-surface-2 text-fg-muted"
                }`}
              >
                {t(`integrations.${name}`)}
              </span>
            ))}
          </div>
        )}

        {/* mt-auto pins the controls to the bottom so cards of differing height
            in one grid row still line their buttons up. */}
        <div className="mt-auto flex items-center gap-1 border-t border-border pt-2 text-xs">
          {(["start", "stop", "clear"] as const).map((a) => (
            <button
              key={a}
              type="button"
              data-testid={`lane-action-${a}`}
              onClick={(e) => {
                e.stopPropagation();
                onAction(a);
              }}
              className={`rounded px-2 py-1 transition-colors ${
                a === "start"
                  ? "bg-blue-600/15 text-blue-400 hover:bg-blue-600/25"
                  : "text-fg-secondary hover:bg-surface-2 hover:text-fg-secondary"
              }`}
              title={a === "start" ? t("tooltipStart") : undefined}
            >
              {t(`action.${a}`)}
            </button>
          ))}
          {/* Stack controls, only for a lane whose repo declares a profile.
              Separate from start/stop above: those drive the lane's Claude run,
              these drive the application it is working on. */}
          {runtime?.available && (
            <button
              type="button"
              data-testid="lane-runtime-toggle"
              disabled={runtimeBusy !== null}
              onClick={(e) => {
                e.stopPropagation();
                void runtimeAction(runtime.provisioned && runtime.up ? "down" : "up");
              }}
              className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
              title={t("runtime.toggleTitle")}
            >
              {runtimeBusy
                ? t(`runtime.busy.${runtimeBusy}`)
                : runtime.provisioned && runtime.up
                  ? t("runtime.stop")
                  : t("runtime.boot")}
            </button>
          )}
          {runtime?.available && (
            <>
              <button
                type="button"
                data-testid="lane-agents-install"
                disabled={laneActionBusy !== null}
                onClick={(e) => {
                  e.stopPropagation();
                  void handleAgentsInstall();
                }}
                className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
              >
                {laneActionBusy === "agents" ? t("actions.busy") : t("actions.agentsInstall")}
              </button>
              <button
                type="button"
                data-testid="lane-mcp-sync"
                disabled={laneActionBusy !== null}
                onClick={(e) => {
                  e.stopPropagation();
                  void handleMcpSync();
                }}
                className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
              >
                {laneActionBusy === "mcp" ? t("actions.busy") : t("actions.mcpSync")}
              </button>
              <button
                type="button"
                data-testid="lane-sync-check"
                disabled={laneActionBusy !== null}
                onClick={(e) => {
                  e.stopPropagation();
                  void handleSyncCheck();
                }}
                className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
              >
                {laneActionBusy === "sync-check" ? t("actions.busy") : t("actions.syncCheck")}
              </button>
            </>
          )}
          {/* Deleting the lane and deleting its history are each their own
              button, by request: hiding "delete" behind a ⋯ made it unfindable,
              and the one label that read as "delete" was `clear`. Neither fires
              directly — both open DestructiveLaneModal, which shows the exact
              counts and demands a confirmation. `reset` stays in the menu; it
              is the rare one and only exists for managed lanes. */}
          <div className="ml-auto flex items-center gap-1">
            <button
              type="button"
              data-testid="lane-action-purge"
              onClick={(e) => {
                e.stopPropagation();
                setDestructiveAction("purge");
              }}
              className="rounded px-2 py-1 text-status-danger/80 transition-colors hover:bg-status-danger/10 hover:text-status-danger"
            >
              {t("action.purge")}
            </button>
            <button
              type="button"
              data-testid="lane-action-remove"
              onClick={(e) => {
                e.stopPropagation();
                setDestructiveAction("remove");
              }}
              className="rounded px-2 py-1 text-status-danger transition-colors hover:bg-status-danger/15 hover:text-status-danger"
            >
              {lane.kind === "adopted" ? t("action.forget") : t("action.remove")}
            </button>
            {/* Adopted lanes have no worktree to reset, so the menu would be
                empty — it is not rendered at all rather than opening onto
                nothing. */}
            {lane.kind === "managed" && (
              <div className="relative">
                <button
                  type="button"
                  data-testid="lane-more"
                  aria-haspopup="menu"
                  aria-expanded={menuOpen}
                  onClick={(e) => {
                    e.stopPropagation();
                    setMenuOpen((v) => !v);
                  }}
                  className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
                  title={t("moreActions")}
                >
                  ⋯
                </button>
                {menuOpen && (
                  <div
                    role="menu"
                    className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
                  >
                    <button
                      type="button"
                      role="menuitem"
                      data-testid="lane-action-reset"
                      onClick={(e) => {
                        e.stopPropagation();
                        setMenuOpen(false);
                        setDestructiveAction("reset");
                      }}
                      className="block w-full px-3 py-1.5 text-left text-status-warning hover:bg-status-warning/10"
                    >
                      {t("action.reset")}
                    </button>
                  </div>
                )}
              </div>
            )}
          </div>
        </div>

        {laneActionResult && (
          <p
            data-testid="lane-action-result"
            className="mt-1 truncate text-[11px] text-fg-secondary"
          >
            {laneActionResult}
          </p>
        )}
      </div>

      {destructiveAction && (
        <DestructiveLaneModal
          lane={lane}
          action={destructiveAction}
          open
          onClose={() => setDestructiveAction(null)}
          onConfirm={(body) => {
            setDestructiveAction(null);
            onAction(destructiveAction, body);
          }}
        />
      )}
    </>
  );
}
