/**
 * Per-test execution page (M7).
 *
 * Two zones, mirror of the spec §M7:
 *   - Red zone (red border)  — command, output, markdown comment, mark-executed.
 *   - Blue zone (cyan border) — detection level, markdown comment, evidence dropzone.
 *
 * State transitions are driven from a small button row in the header. The
 * "modified by X Ns ago" indicator polls `/missions/{id}/activity?since=…`
 * every 15s while the page is mounted (and the document is visible).
 *
 * Field-level permissions:
 *   - Admins always see and write everything.
 *   - `mission.write_red_fields` enables the red-side form.
 *   - `mission.write_blue_fields` enables the blue-side form + uploads.
 * The server is the ultimate arbiter (PUT/POST will 403 if a side is forbidden);
 * the UI just disables inputs the user cannot write to reduce confusion.
 */

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';

import { MarkdownField } from '@/components/MarkdownField';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import {
  ApiError,
  apiDelete,
  apiFetch,
  apiGet,
  apiPost,
  apiPut,
} from '@/lib/api';
import { useAuth } from '@/lib/auth';
import {
  EVIDENCE_ALLOWED_EXTENSIONS,
  EVIDENCE_MAX_BYTES,
  MISSION_TEST_STATE_ACCENT,
  MISSION_TEST_STATE_LABEL,
  VALID_TEST_TRANSITIONS,
  missionKeys,
  missionTestKeys,
  type ActivityResponse,
  type DetectionLevel,
  type DetectionLevelList,
  type MissionTestDetail,
  type MissionTestEvidence,
  type TestTransitionPayload,
  type UpdateMissionTestPayload,
} from '@/lib/missions';

const POLL_INTERVAL_MS = 15_000;

function formatBytes(n: number): string {
  if (n < 1024) return `${n} B`;
  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
  return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}

function formatRelative(iso: string | null | undefined): string {
  if (!iso) return 'never';
  const then = new Date(iso).getTime();
  const now = Date.now();
  const seconds = Math.max(0, Math.floor((now - then) / 1000));
  if (seconds < 60) return `${seconds}s ago`;
  const minutes = Math.floor(seconds / 60);
  if (minutes < 60) return `${minutes}m ago`;
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `${hours}h ago`;
  const days = Math.floor(hours / 24);
  return `${days}d ago`;
}

/**
 * Convert a UTC ISO datetime to the wall-clock `YYYY-MM-DDTHH:MM` string that
 * `<input type="datetime-local">` expects (and displays) in the operator's
 * timezone. The naive `new Date(iso).toISOString().slice(0, 16)` shortcut
 * round-trips through UTC and silently shifts the time by the local offset
 * every time the user types, making the hour un-editable in any non-UTC TZ.
 */
function isoToLocalInputValue(iso: string | null | undefined): string {
  if (!iso) return '';
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return '';
  const pad = (n: number) => String(n).padStart(2, '0');
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

/** Parse a `datetime-local` input value (already in wall-clock local time)
 * back into a UTC ISO string. Returns null if the input is empty/invalid. */
function localInputValueToIso(local: string): string | null {
  if (!local) return null;
  const d = new Date(local);
  if (Number.isNaN(d.getTime())) return null;
  return d.toISOString();
}

function useMissionTest(missionId: string, testId: string) {
  return useQuery({
    queryKey: missionTestKeys.detail(missionId, testId),
    queryFn: () =>
      apiGet<MissionTestDetail>(`/missions/${missionId}/tests/${testId}`),
    enabled: !!missionId && !!testId,
  });
}

function useDetectionLevels(enabled: boolean) {
  return useQuery({
    queryKey: missionTestKeys.detectionLevels(),
    queryFn: () => apiGet<DetectionLevelList>('/detection-levels'),
    enabled,
    staleTime: 60_000,
  });
}

// --------------------------------------------------------------------------- //
// Activity indicator                                                          //
// --------------------------------------------------------------------------- //

function useActivityWatcher(
  missionId: string,
  testId: string,
  onTouched: () => void,
) {
  const lastServerTimeRef = useRef<string | null>(null);
  const onTouchedRef = useRef(onTouched);
  onTouchedRef.current = onTouched;

  useEffect(() => {
    if (!missionId) return;
    let cancelled = false;

    async function poll() {
      try {
        const since = lastServerTimeRef.current;
        const url =
          `/missions/${missionId}/activity` +
          (since ? `?since=${encodeURIComponent(since)}` : '');
        const res = await apiGet<ActivityResponse>(url);
        if (cancelled) return;
        lastServerTimeRef.current = res.server_time;
        // We only care about activity on the same test (the badge is local).
        if (res.items.some((it) => it.test_id === testId)) {
          onTouchedRef.current();
        }
      } catch {
        // Network blips are non-fatal — the badge just doesn't refresh.
      }
    }

    // Prime the timestamp without firing onTouched.
    void poll();
    const handle = window.setInterval(() => {
      if (document.visibilityState === 'visible') void poll();
    }, POLL_INTERVAL_MS);
    return () => {
      cancelled = true;
      window.clearInterval(handle);
    };
  }, [missionId, testId]);
}

// --------------------------------------------------------------------------- //
// Red zone                                                                    //
// --------------------------------------------------------------------------- //

interface RedZoneProps {
  test: MissionTestDetail;
  missionId: string;
  canWriteRed: boolean;
}

function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
  const qc = useQueryClient();
  const [command, setCommand] = useState(test.red_command ?? '');
  const [output, setOutput] = useState(test.red_output ?? '');
  const [comment, setComment] = useState(test.red_comment_md ?? '');
  const [override, setOverride] = useState(test.executed_at_overridden);
  // `executedAtLocal` is the raw `YYYY-MM-DDTHH:MM` string the datetime-local
  // input speaks; we only convert to/from UTC ISO at the boundaries
  // (initial sync + submit). Doing the round-trip on every keystroke shifted
  // the hour by the local TZ offset and made the time field uneditable in
  // any non-UTC zone (cf. tasks/lessons.md M7 fix).
  const [executedAtLocal, setExecutedAtLocal] = useState(
    isoToLocalInputValue(test.executed_at),
  );
  const dirty =
    command !== (test.red_command ?? '') ||
    output !== (test.red_output ?? '') ||
    comment !== (test.red_comment_md ?? '') ||
    override !== test.executed_at_overridden ||
    (override &&
      executedAtLocal !== isoToLocalInputValue(test.executed_at));

  // Sync local state only when the *identity* of the test changes (route
  // change). Polling refetches return a new object reference for the same
  // test_id; resetting on those would wipe whatever the user is mid-typing.
  // Successful saves push the new test through `setQueryData`, which is fine
  // — the saved fields already match local state.
  useEffect(() => {
    setCommand(test.red_command ?? '');
    setOutput(test.red_output ?? '');
    setComment(test.red_comment_md ?? '');
    setOverride(test.executed_at_overridden);
    setExecutedAtLocal(isoToLocalInputValue(test.executed_at));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [test.id]);

  const save = useMutation({
    mutationFn: (body: UpdateMissionTestPayload) =>
      apiPut<MissionTestDetail>(
        `/missions/${missionId}/tests/${test.id}`,
        body,
      ),
    onSuccess: (next) => {
      qc.setQueryData(missionTestKeys.detail(missionId, test.id), next);
      qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
    },
  });

  function submit() {
    const body: UpdateMissionTestPayload = {
      red_command: command.trim() || null,
      red_output: output.length === 0 ? null : output,
      red_comment_md: comment.trim() || null,
    };
    if (override !== test.executed_at_overridden) {
      body.executed_at_overridden = override;
    }
    if (override) {
      body.executed_at = localInputValueToIso(executedAtLocal);
    }
    save.mutate(body);
  }

  const apiErr = save.error instanceof ApiError ? save.error : null;
  const canOverride =
    canWriteRed && (test.state === 'executed' || test.state === 'reviewed_by_blue');

  return (
    <Card accent="red" className="flex flex-col gap-3" data-testid="red-zone">
      <SectionHeader prefix="Red" highlight="Execution" accent="red" />
      {apiErr && <Alert accent="red">{apiErr.message}</Alert>}

      {/* The execution timestamp is the anchor the blue team correlates their
          logs against, so it lives at the very top of the form (cf. user
          feedback 2026-05-15). */}
      <div
        className="rounded-md border border-red/40 bg-red/5 p-3"
        data-testid="red-executed-block"
      >
        <div className="flex flex-wrap items-baseline justify-between gap-2 mb-2">
          <span className="font-mono text-2xs uppercase tracking-wider2 text-red">
            Executed at
          </span>
          {test.executed_at ? (
            <code
              className="font-mono text-2xs text-text-bright"
              data-testid="red-executed-current"
            >
              {test.executed_at}
              {test.executed_at_overridden && (
                <span className="ml-2 text-text-dim">· overridden</span>
              )}
            </code>
          ) : (
            <span className="font-mono text-2xs text-text-dim">
              Not yet executed — use the "→ Executed" transition above to stamp.
            </span>
          )}
        </div>
        <label className="flex items-center gap-2 font-mono text-2xs text-text-dim">
          <input
            type="checkbox"
            checked={override}
            onChange={(e) => setOverride(e.target.checked)}
            disabled={!canOverride}
            data-testid="red-executed-override"
          />
          Override timestamp
          {!canOverride && (
            <span className="text-text-dim/60">
              (mark the test executed first)
            </span>
          )}
        </label>
        {override && (
          <div className="mt-2 flex flex-col gap-1">
            <input
              type="datetime-local"
              value={executedAtLocal}
              onChange={(e) => setExecutedAtLocal(e.target.value)}
              disabled={!canOverride}
              className="rounded-md border border-border bg-bg-card px-2 py-1 font-mono text-xs text-text"
              data-testid="red-executed-at"
            />
            <span className="font-mono text-3xs text-text-dim">
              Browser local time — server stores the UTC equivalent.
            </span>
          </div>
        )}
      </div>

      <TextField
        label="Command"
        value={command}
        onChange={(e) => setCommand(e.target.value)}
        disabled={!canWriteRed}
        className="font-mono"
        data-testid="red-command"
        placeholder="powershell -enc ..."
      />
      <label className="flex flex-col gap-1">
        <span className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
          Output
        </span>
        <textarea
          value={output}
          onChange={(e) => setOutput(e.target.value)}
          disabled={!canWriteRed}
          rows={8}
          className="w-full rounded-md border border-border bg-bg-card p-3 font-mono text-xs text-text"
          data-testid="red-output"
          placeholder="stdout / stderr capture"
        />
      </label>
      <MarkdownField
        label="Comment"
        value={comment}
        onChange={setComment}
        disabled={!canWriteRed}
        data-testid="red-comment"
      />
      <div className="flex justify-end">
        <Button
          accent="red"
          onClick={submit}
          disabled={!canWriteRed || !dirty || save.isPending}
          data-testid="red-save"
        >
          {save.isPending ? 'Saving…' : 'Save red fields'}
        </Button>
      </div>
    </Card>
  );
}

// --------------------------------------------------------------------------- //
// Blue zone                                                                   //
// --------------------------------------------------------------------------- //

interface BlueZoneProps {
  test: MissionTestDetail;
  missionId: string;
  canWriteBlue: boolean;
  detectionLevels: DetectionLevel[];
}

function BlueZone({ test, missionId, canWriteBlue, detectionLevels }: BlueZoneProps) {
  const qc = useQueryClient();
  const [comment, setComment] = useState(test.blue_comment_md ?? '');
  const [levelId, setLevelId] = useState(test.detection_level_id ?? '');
  const fileInputRef = useRef<HTMLInputElement>(null);
  const [dropError, setDropError] = useState<string | null>(null);

  const dirty =
    comment !== (test.blue_comment_md ?? '') ||
    levelId !== (test.detection_level_id ?? '');

  // Sync from server only on test-identity change (route nav), not on every
  // polling refetch — see RedZone for the rationale.
  useEffect(() => {
    setComment(test.blue_comment_md ?? '');
    setLevelId(test.detection_level_id ?? '');
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [test.id]);

  const save = useMutation({
    mutationFn: (body: UpdateMissionTestPayload) =>
      apiPut<MissionTestDetail>(
        `/missions/${missionId}/tests/${test.id}`,
        body,
      ),
    onSuccess: (next) => {
      qc.setQueryData(missionTestKeys.detail(missionId, test.id), next);
      qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
    },
  });

  const upload = useMutation({
    mutationFn: async (file: File) => {
      const fd = new FormData();
      fd.append('file', file);
      const res = await apiFetch(
        `/missions/${missionId}/tests/${test.id}/evidence`,
        { method: 'POST', body: fd },
      );
      if (!res.ok) {
        const body = await res
          .json()
          .catch(() => ({ error: 'upload_failed' as const }));
        throw new ApiError(res.status, body);
      }
      return (await res.json()) as MissionTestEvidence;
    },
    onSuccess: () => {
      qc.invalidateQueries({
        queryKey: missionTestKeys.detail(missionId, test.id),
      });
      qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
    },
  });

  const remove = useMutation({
    mutationFn: (evidenceId: string) =>
      apiDelete<{ ok: boolean }>(`/evidence/${evidenceId}`),
    onSuccess: () => {
      qc.invalidateQueries({
        queryKey: missionTestKeys.detail(missionId, test.id),
      });
      qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
    },
  });

  function submit() {
    save.mutate({
      blue_comment_md: comment.trim() || null,
      detection_level_id: levelId || null,
    });
  }

  function handleFiles(files: FileList | null) {
    if (!files || files.length === 0) return;
    setDropError(null);
    // Per spec §M7, max 25 MB + ext whitelist are server-enforced; the client
    // checks too so a drag-and-drop of a `.exe` doesn't waste a roundtrip
    // and the operator gets immediate feedback.
    for (const f of Array.from(files)) {
      const dotIdx = f.name.lastIndexOf('.');
      const ext = dotIdx >= 0 ? f.name.slice(dotIdx).toLowerCase() : '';
      if (!ext || !(EVIDENCE_ALLOWED_EXTENSIONS as readonly string[]).includes(ext)) {
        setDropError(
          `"${f.name}" has an unsupported extension. Accepted: ${EVIDENCE_ALLOWED_EXTENSIONS.join(', ')}.`,
        );
        continue;
      }
      if (f.size > EVIDENCE_MAX_BYTES) {
        setDropError(`"${f.name}" exceeds the 25 MB limit.`);
        continue;
      }
      upload.mutate(f);
    }
  }

  const apiErr =
    save.error instanceof ApiError
      ? save.error
      : upload.error instanceof ApiError
        ? upload.error
        : remove.error instanceof ApiError
          ? remove.error
          : null;

  return (
    <Card accent="cyan" className="flex flex-col gap-3" data-testid="blue-zone">
      <SectionHeader prefix="Blue" highlight="Detection" accent="cyan" />
      {apiErr && (
        <Alert accent="red">
          {apiErr.message}
          {typeof apiErr.payload === 'object' &&
          apiErr.payload &&
          'message' in (apiErr.payload as Record<string, unknown>)
            ? ` — ${(apiErr.payload as { message?: string }).message}`
            : ''}
        </Alert>
      )}
      <label className="flex flex-col gap-1">
        <span className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
          Detection level
        </span>
        <select
          value={levelId}
          onChange={(e) => setLevelId(e.target.value)}
          disabled={!canWriteBlue}
          className="rounded-md border border-border bg-bg-card px-2 py-2 font-mono text-xs text-text"
          data-testid="blue-detection-level"
        >
          <option value="">— none —</option>
          {detectionLevels.map((lvl) => (
            <option key={lvl.id} value={lvl.id}>
              {lvl.label_en} ({lvl.key})
            </option>
          ))}
        </select>
      </label>
      <MarkdownField
        label="Comment"
        value={comment}
        onChange={setComment}
        disabled={!canWriteBlue}
        data-testid="blue-comment"
      />
      <div className="flex justify-end">
        <Button
          accent="cyan"
          onClick={submit}
          disabled={!canWriteBlue || !dirty || save.isPending}
          data-testid="blue-save"
        >
          {save.isPending ? 'Saving…' : 'Save blue fields'}
        </Button>
      </div>

      <div className="flex flex-col gap-2">
        <p className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
          Evidence
        </p>
        <div
          className={`rounded-md border-2 border-dashed p-4 text-center ${
            canWriteBlue ? 'border-cyan/60' : 'border-border'
          }`}
          onDragOver={(e) => {
            if (!canWriteBlue) return;
            e.preventDefault();
          }}
          onDrop={(e) => {
            if (!canWriteBlue) return;
            e.preventDefault();
            handleFiles(e.dataTransfer.files);
          }}
          data-testid="evidence-dropzone"
        >
          <p className="font-mono text-2xs text-text-dim">
            {canWriteBlue
              ? 'Drag files here or click to pick'
              : 'Read-only — no upload permission'}
          </p>
          {canWriteBlue && (
            <p
              className="mt-1 font-mono text-3xs uppercase tracking-wider2 text-text-dim"
              data-testid="evidence-allowed-formats"
            >
              Accepted: {EVIDENCE_ALLOWED_EXTENSIONS.join(' · ')} ·{' '}
              max 25 MB / file
            </p>
          )}
          <input
            ref={fileInputRef}
            type="file"
            multiple
            // `accept` is the native filter the OS file-picker honours. It
            // accepts a comma-separated list of extensions or MIME types.
            // Servers re-check both, so this is a UX nicety, not a security
            // boundary.
            accept={EVIDENCE_ALLOWED_EXTENSIONS.join(',')}
            className="hidden"
            onChange={(e) => handleFiles(e.target.files)}
            data-testid="evidence-file-input"
          />
          {canWriteBlue && (
            <Button
              variant="ghost"
              accent="cyan"
              className="mt-2"
              onClick={() => fileInputRef.current?.click()}
              disabled={upload.isPending}
              data-testid="evidence-pick"
            >
              {upload.isPending ? 'Uploading…' : 'Pick files'}
            </Button>
          )}
          {dropError && (
            <p className="mt-2 font-mono text-2xs text-red">{dropError}</p>
          )}
        </div>

        {test.evidence.length === 0 ? (
          <p className="font-mono text-2xs text-text-dim">No evidence yet.</p>
        ) : (
          <table className="w-full font-mono text-2xs">
            <thead>
              <tr className="text-text-dim uppercase tracking-wider2">
                <th className="text-left py-1">File</th>
                <th className="text-left py-1">Size</th>
                <th className="text-left py-1">By</th>
                <th className="text-left py-1">SHA256</th>
                <th />
              </tr>
            </thead>
            <tbody data-testid="evidence-list">
              {test.evidence.map((ev) => (
                <tr
                  key={ev.id}
                  className="border-t border-border/40"
                  data-testid={`evidence-row-${ev.id}`}
                >
                  <td className="py-1 text-text-bright">{ev.original_filename}</td>
                  <td className="py-1">{formatBytes(ev.size_bytes)}</td>
                  <td className="py-1 text-text">
                    {ev.uploaded_by_email ?? '<deleted>'}
                  </td>
                  <td className="py-1">
                    <code className="text-text-dim">{ev.sha256.slice(0, 12)}…</code>
                  </td>
                  <td className="py-1 text-right">
                    <a
                      href={`/api/v1/evidence/${ev.id}?download=true`}
                      target="_blank"
                      rel="noreferrer"
                      className="text-cyan font-mono text-2xs"
                      data-testid={`evidence-download-${ev.id}`}
                    >
                      download
                    </a>
                    {canWriteBlue && (
                      <button
                        type="button"
                        onClick={() => {
                          if (
                            window.confirm(
                              `Soft-delete evidence "${ev.original_filename}"?`,
                            )
                          ) {
                            remove.mutate(ev.id);
                          }
                        }}
                        className="ml-2 text-rose font-mono text-2xs"
                        data-testid={`evidence-delete-${ev.id}`}
                        disabled={remove.isPending}
                      >
                        delete
                      </button>
                    )}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </div>
    </Card>
  );
}

// --------------------------------------------------------------------------- //
// Top-level page                                                              //
// --------------------------------------------------------------------------- //

export function MissionTestPage() {
  const params = useParams<{ id: string; testId: string }>();
  const missionId = params.id ?? '';
  const testId = params.testId ?? '';
  const navigate = useNavigate();
  const { state } = useAuth();
  const qc = useQueryClient();

  const detail = useMissionTest(missionId, testId);
  const levels = useDetectionLevels(
    !!state.user &&
      (state.user.is_admin ||
        state.user.permissions.includes('detection_level.read')),
  );

  const onActivityTouched = useCallback(() => {
    qc.invalidateQueries({
      queryKey: missionTestKeys.detail(missionId, testId),
    });
  }, [qc, missionId, testId]);

  useActivityWatcher(missionId, testId, onActivityTouched);

  const transition = useMutation({
    mutationFn: (body: TestTransitionPayload) =>
      apiPost<MissionTestDetail>(
        `/missions/${missionId}/tests/${testId}/transition`,
        body,
      ),
    onSuccess: (next) => {
      qc.setQueryData(missionTestKeys.detail(missionId, testId), next);
      qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
    },
  });

  const test = detail.data;

  const perms = useMemo(() => {
    const isAdmin = !!state.user?.is_admin;
    const codes = state.user?.permissions ?? [];
    return {
      isAdmin,
      canWriteRed: isAdmin || codes.includes('mission.write_red_fields'),
      canWriteBlue: isAdmin || codes.includes('mission.write_blue_fields'),
    };
  }, [state.user]);

  if (!missionId || !testId) {
    return (
      <Alert accent="red">Missing mission or test id in URL.</Alert>
    );
  }
  if (detail.isLoading) {
    return <p className="font-mono text-xs text-text-dim">Loading…</p>;
  }
  if (detail.error instanceof ApiError && detail.error.status === 404) {
    return (
      <Alert accent="rose">
        Mission test not found, or you are not a member of this mission.
      </Alert>
    );
  }
  if (!test) {
    return <Alert accent="red">Failed to load mission test.</Alert>;
  }

  const allowedTransitions = VALID_TEST_TRANSITIONS[test.state] ?? [];
  const transitionErr =
    transition.error instanceof ApiError ? transition.error : null;

  return (
    <div className="flex flex-col gap-4" data-testid="mission-test-page">
      <div className="flex flex-wrap items-baseline justify-between gap-2">
        <div>
          <Link
            to={`/missions/${missionId}`}
            className="font-mono text-2xs uppercase tracking-wider2 text-text-dim hover:text-text-bright"
            data-testid="back-to-mission"
          >
            ← Back to mission
          </Link>
          <h1 className="mt-1 font-mono text-xl font-bold text-text-bright">
            {test.snapshot_name}
          </h1>
          <p className="font-mono text-2xs text-text-dim">
            Last touched{' '}
            <span data-testid="last-actor-rel">{formatRelative(test.updated_at)}</span>
            {test.last_actor_email
              ? ` by ${test.last_actor_display_name ?? test.last_actor_email}`
              : ''}
          </p>
        </div>
        <div className="flex flex-wrap items-center gap-2">
          <span data-testid="state-pill">
            <Tag accent={MISSION_TEST_STATE_ACCENT[test.state]}>
              {MISSION_TEST_STATE_LABEL[test.state]}
            </Tag>
          </span>
          {allowedTransitions.map((target) => (
            <Button
              key={target}
              accent={MISSION_TEST_STATE_ACCENT[target]}
              variant="ghost"
              onClick={() => transition.mutate({ target_state: target })}
              disabled={transition.isPending}
              data-testid={`transition-${target}`}
            >
              → {MISSION_TEST_STATE_LABEL[target]}
            </Button>
          ))}
        </div>
      </div>

      {transitionErr && (
        <Alert accent="red">
          {transitionErr.payload &&
          typeof transitionErr.payload === 'object' &&
          'message' in (transitionErr.payload as Record<string, unknown>)
            ? `${transitionErr.message} — ${(transitionErr.payload as { message?: string }).message}`
            : transitionErr.message}
        </Alert>
      )}

      <Card className="flex flex-col gap-2">
        <div className="flex flex-wrap gap-2">
          {test.mitre_tags.map((tag) => (
            <Tag accent="cyan" key={`${tag.kind}-${tag.external_id}`}>
              {tag.external_id} — {tag.name}
            </Tag>
          ))}
          <Tag accent="teal">OPSEC: {test.snapshot_opsec_level}</Tag>
        </div>
        {test.snapshot_objective && (
          <p className="font-mono text-xs text-text">
            <span className="text-text-dim">Objective: </span>
            {test.snapshot_objective}
          </p>
        )}
        {test.snapshot_procedure_md && (
          <details className="font-mono text-2xs text-text-dim" data-testid="procedure">
            <summary className="cursor-pointer text-text">Procedure</summary>
            <pre className="mt-1 whitespace-pre-wrap text-text">
              {test.snapshot_procedure_md}
            </pre>
          </details>
        )}
        {test.snapshot_expected_red_md && (
          <details className="font-mono text-2xs text-text-dim">
            <summary className="cursor-pointer text-text">Expected (red)</summary>
            <pre className="mt-1 whitespace-pre-wrap text-text">
              {test.snapshot_expected_red_md}
            </pre>
          </details>
        )}
        {test.snapshot_expected_blue_md && (
          <details className="font-mono text-2xs text-text-dim">
            <summary className="cursor-pointer text-text">Expected (blue)</summary>
            <pre className="mt-1 whitespace-pre-wrap text-text">
              {test.snapshot_expected_blue_md}
            </pre>
          </details>
        )}
      </Card>

      <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
        <RedZone test={test} missionId={missionId} canWriteRed={perms.canWriteRed} />
        <BlueZone
          test={test}
          missionId={missionId}
          canWriteBlue={perms.canWriteBlue}
          detectionLevels={levels.data?.items ?? []}
        />
      </div>

      {/* Navigation hint when state moves to archived parent — out of M7 scope, but
          we still let the page render. */}
      {detail.error instanceof ApiError &&
        detail.error.status >= 400 &&
        detail.error.status < 500 && (
          <Button accent="cyan" onClick={() => navigate(`/missions/${missionId}`)}>
            Back to mission
          </Button>
        )}
    </div>
  );
}
