/**
 * Action and Binding Inspector — Step 15.5 / 15.6 / 16.5 / 17.1 / 17.3 / 17.4 / 17.5 / 18.1
 *
 * Visual REST action authoring plus binding inspection for the current project,
 * synchronized through the canonical document in ProjectContext.
 *
 * ── Diagnostics computed ──────────────────────────────────────────────────────
 *  Per action (Step 15.6):
 *    • Action is not triggered by any component or page event  (info)
 *  Per action (Step 16.5 / 18.1):
 *    • Template references a component that does not exist     (warning)
 *    • Template uses unsupported property (not "value")        (warning)
 *    • Template references a variable that does not exist      (warning)  ← 18.1
 *    • Malformed template syntax (unclosed {{)                 (warning)
 *
 *  Per binding (Step 15.6 + 17.1 + 18.1):
 *    • Source references an action that does not exist         (warning)
 *    • Source path grammar is not a supported action-response path (warning)
 *    • Target references a component that does not exist       (warning)
 *    • Target component name is duplicated on a page           (warning)
 *    • Target property not supported for the component type    (warning)
 *      (.value → JsonViewer/Label; .options → Dropdown; .rows → Table)
 *    • Target is a variable that is not declared               (warning)  ← 18.1
 *    • Trigger is unsupported for variable-target bindings     (warning)  ← 18.1
 *    • No component or page event fires the source action       (warning)
 *    • Trigger is unsupported for response bindings            (warning)
 *    • Trigger "onClick" is legacy — recommend "onSuccess"     (info)
 *
 *  Per component event (surfaced inside binding diagnostics):
 *    • Event actionId references a missing action              (warning)
 *
 *  Per Table component (Step 17.4):
 *    • Duplicate column keys                                   (warning)
 *    • Empty column key                                        (warning)
 *    • Binding targeting Table.rows / Table.value              (warning)
 */

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useProject } from '../../context/ProjectContext';
import { executeAction } from '../../api/proxyApi';
import type { ProxyResponse } from '../../api/proxyApi';
import type { RestAction, Binding, CanvasComponent, DropdownOption, Page, TableColumn, Variable } from '../../types/project';
import { extractTemplates, classifyVariableExpression } from '../Preview/templateUtils';
import {
  isTargetPropertySupported,
  classifyTrigger,
  parseActionSourcePath,
  parseComponentTargetPath,
  parseVariableTargetPath,
} from '../Preview/bindingUtils';
import styles from './ActionInspector.module.css';
import RestActionEditor from './RestActionEditor';
import ResponseBindingEditor from './ResponseBindingEditor';
import VariableEditor from './VariableEditor';
import SecretManager from './SecretManager';
import { listSecrets } from '../../api/secretsApi';
import type { SecretMetadata } from '../../api/secretsApi';
import ValidationSummary from '../ValidationSummary';
import {
  createResponseBinding,
  findVariableReferences,
  removeBindingAt,
  removeVariable,
  replaceBindingAt,
  responseTargetOptions,
  setVariable,
} from './configurationUtils';
import {
  appendRestAction,
  buildDeleteConfirmation,
  collectReferencedActionIds,
  createRestAction,
  duplicateRestAction,
  findActionReferences,
  removeRestActionAt,
  replaceRestActionAt,
  validateRestAction,
} from './actionEditorUtils';

// ── Diagnostic types ──────────────────────────────────────────────────────────

type DiagSeverity = 'warn' | 'info';

type Diagnostic = {
  severity: DiagSeverity;
  message: string;
};

/**
 * All diagnostics indexed by a stable render subject so cards can look up their own.
 * Keys: action indices (isolating duplicate IDs), binding IDs, and synthetic subjects.
 * Also a special key "__componentEvents" for component-event issues not tied to
 * a specific binding (currently unused — shown on binding cards that reference
 * the missing action).
 */
type DiagMap = Record<string, Diagnostic[]>;

function actionDiagnosticKey(actionIndex: number): string {
  return `__action_${actionIndex}`;
}

// ── Pure diagnostic computation ───────────────────────────────────────────────

/**
 * Computes all diagnostics from the project in a single pass.
 * Never throws — any unexpected input is handled gracefully.
 * Returns a DiagMap keyed by action index, binding ID, or synthetic subject.
 */
function computeDiagnostics(
  actions: RestAction[],
  bindings: Binding[],
  pages: Page[],
  allComponents: CanvasComponent[],
  variables: Record<string, Variable>,
): DiagMap {
  const declaredVariableNames = new Set(Object.keys(variables));
  const map: DiagMap = {};

  function add(key: string, severity: DiagSeverity, message: string) {
    if (!map[key]) map[key] = [];
    map[key].push({ severity, message });
  }

  const actionIds = new Set(actions.map((a) => a.id));
  const componentsByName = new Map(allComponents.map((c) => [c.name, c]));

  // ── Index: which actionIds are triggered by a component or page event ─────
  const triggeredActionIds = new Set<string>();
  for (const page of pages) {
    for (const ev of page.events ?? []) {
      triggeredActionIds.add(ev.actionId);
    }
  }
  for (const comp of allComponents) {
    for (const ev of comp.events ?? []) {
      triggeredActionIds.add(ev.actionId);
    }
  }

  // ── Index: for a given actionId, which components have an onClick event ───
  // key: actionId → component names that fire it via onClick
  const onClickTriggers = new Map<string, string[]>();
  for (const comp of allComponents) {
    for (const ev of comp.events ?? []) {
      if (ev.event === 'onClick') {
        const list = onClickTriggers.get(ev.actionId) ?? [];
        list.push(comp.name);
        onClickTriggers.set(ev.actionId, list);
      }
    }
  }

  // ── 1. Diagnostics per action ─────────────────────────────────────────────

  actions.forEach((action, actionIndex) => {
    const diagnosticKey = actionDiagnosticKey(actionIndex);
    for (const issue of validateRestAction(action, actions)) {
      add(diagnosticKey, issue.severity, issue.message);
    }

    // 1a. Untriggered action
    if (!triggeredActionIds.has(action.id)) {
      add(
        diagnosticKey,
        'info',
        `Action "${action.name}" is not triggered by any component or page event. ` +
        `Assign it to a supported component event or to a page onLoad event.`,
      );
    }

    // 1b. Template diagnostics (Step 16.5)
    const { tokens, malformed } = extractTemplates(action);

    for (const token of tokens) {
      if (token.namespace === 'variables') {
        if (!declaredVariableNames.has(token.name)) {
          add(
            diagnosticKey,
            'warn',
            `Template "${token.raw}" at ${token.location} references variable ` +
            `"${token.name}" which is not declared in project.variables.`,
          );
        }
        continue;
      }

      // Component token — check property and existence
      if (token.propertyName !== 'value') {
        add(
          diagnosticKey,
          'warn',
          `Template "${token.raw}" at ${token.location} references property ` +
          `"${token.propertyName ?? '?'}". Only "value" is supported. ` +
          `Use {{components.<name>.value}}.`,
        );
        continue;
      }
      // Missing component
      if (token.componentName && !componentsByName.has(token.componentName)) {
        add(
          diagnosticKey,
          'warn',
          `Template "${token.raw}" at ${token.location} references component ` +
          `"${token.componentName}" which does not exist on any page.`,
        );
      }
    }

    for (const bad of malformed) {
      const isMalformedVar = classifyVariableExpression(bad.raw) === 'malformed';
      add(
        diagnosticKey,
        'warn',
        isMalformedVar
          ? `Malformed variable template "${bad.raw}" at ${bad.location}: ` +
            `expected {{variables.<name>}} where <name> contains no dots. ` +
            `The action cannot execute while this expression is present.`
          : `Malformed template at ${bad.location}: "${bad.raw}…" — ` +
            `missing closing "}}". Check your template syntax.`,
      );
    }
  });

  // ── Index: component name → all components with that name (duplicate check) ─
  const componentsByNameAll = new Map<string, CanvasComponent[]>();
  for (const comp of allComponents) {
    const list = componentsByNameAll.get(comp.name) ?? [];
    list.push(comp);
    componentsByNameAll.set(comp.name, list);
  }

  // ── 2. Diagnostics per binding ────────────────────────────────────────────

  for (const binding of bindings) {
    // Parse source using bindingUtils (handles extended paths like .response.body.field)
    const parsedSource = parseActionSourcePath(binding.source);

    // Legacy: also handle component-sourced bindings (non-action sources)
    const sourceComponentMatch = /^components\.([^.]+)\./.exec(binding.source);
    const sourceComponentName = sourceComponentMatch ? sourceComponentMatch[1] : null;

    const sourceActionId = parsedSource?.actionId ?? null;

    // Parse targets (both component and variable)
    const parsedTarget = parseComponentTargetPath(binding.target);
    const parsedVariableTarget = parseVariableTargetPath(binding.target);
    const targetComponentName = parsedTarget?.componentName ?? null;

    // 2a. Source action missing
    if (sourceActionId && !actionIds.has(sourceActionId)) {
      add(
        binding.id,
        'warn',
        `Source references action "${sourceActionId}" which does not exist in project.actions.`,
      );
    }

    // 2a2. Source begins with "actions." but doesn't parse as a valid response path
    if (binding.source.startsWith('actions.') && !parsedSource) {
      add(
        binding.id,
        'warn',
        `Source "${binding.source}" does not follow the supported action-response path grammar. ` +
        `Expected: actions.<actionId>.response[.body[.<field…>]]`,
      );
    }

    // 2b. Source component missing (for component-sourced bindings)
    if (sourceComponentName && !componentsByName.has(sourceComponentName)) {
      add(
        binding.id,
        'warn',
        `Source references component "${sourceComponentName}" which does not exist on any page.`,
      );
    }
    if (sourceComponentName && componentsByName.has(sourceComponentName)) {
      const candidates = componentsByNameAll.get(sourceComponentName) ?? [];
      if (candidates.length === 1 && (candidates[0].type !== 'Table' || binding.source !== `components.${sourceComponentName}.selectedRow`)) {
        add(binding.id, 'warn', 'Component binding sources must use components.<tableName>.selectedRow on a Table component.');
      }
      if ((binding.trigger ?? 'onChange') !== 'onChange') {
        add(binding.id, 'warn', 'Table selection bindings require trigger "onChange".');
      }
    }

    // 2c. Variable target diagnostics (Step 18.1)
    if (parsedVariableTarget) {
      const { variableName } = parsedVariableTarget;

      // 2c1. Variable must be declared
      if (!declaredVariableNames.has(variableName)) {
        add(
          binding.id,
          'warn',
          `Target "variables.${variableName}" references a variable that is not declared ` +
          `in project.variables. Declare the variable before using it as a binding target.`,
        );
      }

      // 2c2. Variable response bindings require onSuccess — onClick (legacy) is
      // not accepted for variable targets (unlike component targets).
      if (sourceActionId) {
        const triggerClass = classifyTrigger(binding.trigger);
        if (triggerClass !== 'onSuccess') {
          add(
            binding.id,
            'warn',
            `Variable response bindings require trigger "onSuccess". ` +
            `Got "${binding.trigger ?? 'onChange'}". ` +
            `Change the trigger to "onSuccess".`,
          );
        }
      }
    } else if (!parsedTarget) {
      // 2d. Target path doesn't start with "components." or "variables."
      if (!binding.target.startsWith('components.') && !binding.target.startsWith('variables.')) {
        add(
          binding.id,
          'warn',
          `Target "${binding.target}" is not a recognised path. ` +
          `Use "components.<name>.<property>" or "variables.<name>".`,
        );
      } else {
        // Starts with a recognised prefix but didn't parse — malformed
        add(
          binding.id,
          'warn',
          `Target "${binding.target}" could not be parsed. ` +
          `For components use "components.<name>.<property>"; for variables use "variables.<name>".`,
        );
      }
    }

    // 2d. Target component missing
    if (targetComponentName) {
      const candidates = componentsByNameAll.get(targetComponentName) ?? [];

      if (candidates.length === 0) {
        add(
          binding.id,
          'warn',
          `Target references component "${targetComponentName}" which does not exist on any page.`,
        );
      } else if (candidates.length > 1) {
        add(
          binding.id,
          'warn',
          `Target component name "${targetComponentName}" is ambiguous — ` +
          `${candidates.length} components share this name. Binding resolution will fail at runtime.`,
        );
      } else {
        const comp = candidates[0];

        // 2d2. Target property + component type must be a supported combination
        if (parsedTarget && !isTargetPropertySupported(comp.type, parsedTarget.property)) {
          add(
            binding.id,
            'warn',
            `Target property "${parsedTarget.property}" is not supported for ` +
            `component type "${comp.type}". ` +
            `Supported: .value → JsonViewer, Label; .options → Dropdown; .rows → Table.`,
          );
        }
      }
    }

    // 2e. No component or page event fires the source action at all
    if (sourceActionId && actionIds.has(sourceActionId) && !triggeredActionIds.has(sourceActionId)) {
      add(
        binding.id,
        'warn',
        `No component or page event fires action "${sourceActionId}". ` +
        `The binding will never receive a response. ` +
        `Assign the action to a supported component event or to a page onLoad event.`,
      );
    }

    // 2f. Trigger classification for component targets (Step 17.1)
    // Variable targets already had trigger checked in 2c — skip to avoid duplicates.
    if (sourceActionId && !parsedVariableTarget) {
      const triggerClass = classifyTrigger(binding.trigger);

      if (triggerClass === 'unsupported') {
        add(
          binding.id,
          'warn',
          `Trigger "${binding.trigger ?? 'onChange'}" is not supported for action-response ` +
          `bindings. Use "onSuccess" (or "onClick" for legacy compatibility).`,
        );
      } else if (triggerClass === 'onClick-legacy') {
        add(
          binding.id,
          'info',
          `Trigger "onClick" is a legacy response-mapping trigger. ` +
          `Consider migrating to "onSuccess".`,
        );
      }
    }
  }

  // ── 3. Diagnostics from component events (shown on bindings) ─────────────
  //    For each component event that references a missing action, find the
  //    bindings that reference that action and add the warning there.
  //    If no such binding exists, add a synthetic entry keyed by
  //    "__event_<component.name>_<actionId>".

  for (const comp of allComponents) {
    for (const ev of comp.events ?? []) {
      if (!actionIds.has(ev.actionId)) {
        // Find bindings that reference this action (any source path form)
        const relatedBindings = bindings.filter((b) => {
          const parsed = parseActionSourcePath(b.source);
          return parsed ? parsed.actionId === ev.actionId : false;
        });

        const msg =
          `Component "${comp.name}" has event "${ev.event}" referencing ` +
          `action "${ev.actionId}" which does not exist in project.actions.`;

        if (relatedBindings.length > 0) {
          for (const b of relatedBindings) add(b.id, 'warn', msg);
        } else {
          // No binding references this event — key by component+action
          add(`__event_${comp.name}_${ev.actionId}`, 'warn', msg);
        }
      }
    }
  }

  // ── 4. Diagnostics per Table component ───────────────────────────────────

  for (const comp of allComponents) {
    if (comp.type !== 'Table') continue;

    const rawColumns = comp.properties.columns;
    const columns: TableColumn[] = Array.isArray(rawColumns)
      ? (rawColumns as TableColumn[]).filter(
          (c) => c && typeof c.key === 'string' && typeof c.header === 'string',
        )
      : [];

    // 4a. Duplicate column keys
    const colKeys = columns.map((c) => c.key).filter(Boolean);
    const seenKeys = new Set<string>();
    const dupKeys = new Set<string>();
    for (const k of colKeys) {
      if (seenKeys.has(k)) dupKeys.add(k);
      seenKeys.add(k);
    }
    if (dupKeys.size > 0) {
      add(
        `__table_${comp.name}`,
        'warn',
        `Table "${comp.name}" has duplicate column keys: ` +
        `${[...dupKeys].map((k) => `"${k}"`).join(', ')}.`,
      );
    }

    // 4b. Empty column key
    if (columns.some((c) => !c.key)) {
      add(
        `__table_${comp.name}`,
        'warn',
        `Table "${comp.name}" has a column with an empty key. Edit columns in the property editor.`,
      );
    }
  }

  // ── 5. Diagnostics per Dropdown component ────────────────────────────────

  for (const comp of allComponents) {
    if (comp.type !== 'Dropdown') continue;

    const rawOptions = comp.properties.options;
    const options: DropdownOption[] = Array.isArray(rawOptions)
      ? (rawOptions as DropdownOption[]).filter(
          (o) => o && typeof o.label === 'string' && typeof o.value === 'string',
        )
      : [];

    // 4a. Duplicate option values
    const optionValues = options.map((o) => o.value).filter(Boolean);
    const seenValues = new Set<string>();
    const dupValues = new Set<string>();
    for (const v of optionValues) {
      if (seenValues.has(v)) dupValues.add(v);
      seenValues.add(v);
    }
    if (dupValues.size > 0) {
      add(
        `__dropdown_${comp.name}`,
        'warn',
        `Dropdown "${comp.name}" has duplicate option values: ` +
        `${[...dupValues].map((v) => `"${v}"`).join(', ')}. ` +
        `Runtime selection may be ambiguous.`,
      );
    }

    // 4b. Configured value not in options
    const configuredValue = typeof comp.properties.value === 'string'
      ? comp.properties.value
      : '';
    if (configuredValue !== '' && !options.some((o) => o.value === configuredValue)) {
      add(
        `__dropdown_${comp.name}`,
        'warn',
        `Dropdown "${comp.name}" configured value "${configuredValue}" is not present ` +
        `in its options list. Preview will show the placeholder instead.`,
      );
    }

    // 4c. Options with empty label or value
    for (const opt of options) {
      if (!opt.label || !opt.value) {
        add(
          `__dropdown_${comp.name}`,
          'warn',
          `Dropdown "${comp.name}" has an option with an empty ` +
          `${!opt.label ? 'label' : 'value'}. ` +
          `Edit options in the property editor.`,
        );
        break; // one warning is enough
      }
    }
  }

  return map;
}

// ── Helpers ───────────────────────────────────────────────────────────────────

function methodClass(method: string): string {
  switch (method.toUpperCase()) {
    case 'GET':    return styles.methodGet;
    case 'POST':   return styles.methodPost;
    case 'PUT':    return styles.methodPut;
    case 'PATCH':  return styles.methodPatch;
    case 'DELETE': return styles.methodDelete;
    default:       return styles.methodOther;
  }
}

function parseActionSource(expr: string): string | null {
  return parseActionSourcePath(expr)?.actionId ?? null;
}

function parseComponentExpr(expr: string): string | null {
  const m = /^components\.([^.]+)\./.exec(expr);
  return m ? m[1] : null;
}

const AUTH_LABELS: Record<string, string> = {
  anonymous:            'Anonymous',
  bearerToken:          'Bearer Token',
  basicAuth:            'Basic Auth',
  apiKeyHeader:         'API Key (Header)',
  apiKeyQueryParameter: 'API Key (Query)',
};

// ── DiagList sub-component ────────────────────────────────────────────────────

function DiagList({ diags }: { diags: Diagnostic[] }): React.ReactElement | null {
  if (diags.length === 0) return null;
  return (
    <div className={styles.diagList}>
      {diags.map((d, i) => (
        d.severity === 'warn' ? (
          <div key={i} className={styles.diagWarn}>
            <em className={styles.diagWarnIcon}>⚠</em>
            <span>{d.message}</span>
          </div>
        ) : (
          <div key={i} className={styles.diagInfo}>
            <em className={styles.diagInfoIcon}>ℹ</em>
            <span>{d.message}</span>
          </div>
        )
      ))}
    </div>
  );
}

// ── ActionCard ────────────────────────────────────────────────────────────────

type TestState =
  | { status: 'idle' }
  | { status: 'running' }
  | { status: 'ok';    response: ProxyResponse }
  | { status: 'error'; message: string };

type ActionCardProps = {
  action: RestAction;
  diags: Diagnostic[];
  allComponents: CanvasComponent[];
  isEditing: boolean;
  actionsLocked: boolean;
  expanded: boolean;
  onToggle: () => void;
  onEdit: () => void;
  onDuplicate: () => void;
  onDelete: () => void;
};

function ActionCard({
  action,
  diags,
  allComponents,
  isEditing,
  actionsLocked,
  expanded,
  onToggle,
  onEdit,
  onDuplicate,
  onDelete,
}: ActionCardProps): React.ReactElement {
  const [test, setTest] = useState<TestState>({ status: 'idle' });
  const requestGeneration = useRef(0);

  useEffect(() => {
    requestGeneration.current += 1;
    setTest({ status: 'idle' });
    return () => {
      requestGeneration.current += 1;
    };
  }, [action]);

  const handleTest = useCallback(async () => {
    const generation = ++requestGeneration.current;
    setTest({ status: 'running' });
    try {
      const response = await executeAction(action);
      if (generation !== requestGeneration.current) return;
      setTest({ status: 'ok', response });
    } catch (err) {
      if (generation !== requestGeneration.current) return;
      setTest({
        status: 'error',
        message: err instanceof Error ? err.message : String(err),
      });
    }
  }, [action]);

  const controlsLocked = actionsLocked || test.status === 'running';

  // Compute template info for display
  const { tokens, malformed } = useMemo(
    () => extractTemplates(action),
    [action],
  );
  const componentsByName = useMemo(
    () => new Map(allComponents.map((c) => [c.name, c])),
    [allComponents],
  );
  const hasTemplates = tokens.length > 0 || malformed.length > 0;

  return (
    <div className={styles.actionCard}>
      <button type="button" className={styles.cardToggle} onClick={onToggle} aria-expanded={expanded} aria-label={`${expanded ? 'Collapse' : 'Expand'} REST action ${action.name}`}>
        <span className={styles.toggleHeadingRow}>
          <span className={styles.recordType}>REST action</span>
          <span className={styles.toggleStatus}>
            {diags.length > 0 && <span className={styles.issueBadge}>{diags.length} issue{diags.length === 1 ? '' : 's'}</span>}
            <span className={styles.chevron} aria-hidden="true">{expanded ? '▾' : '▸'}</span>
          </span>
        </span>
        <span className={styles.actionCardHeader}>
          <span className={[styles.methodBadge, methodClass(action.method)].join(' ')}>{action.method}</span>
          <span className={styles.actionName}>{action.name}</span>
          <span className={styles.actionId}>{action.id}</span>
        </span>
        <span className={styles.actionUrl}>{action.url}</span>
        <span className={styles.actionMeta}>
          <span className={styles.authBadge}>{AUTH_LABELS[action.authenticationType] ?? action.authenticationType}</span>
        </span>
      </button>

      {expanded && <div className={styles.cardDetails}>
      {action.description && <span className={styles.actionDescription}>{action.description}</span>}

      {/* ── Template references (Step 16.5) ── */}
      {hasTemplates && (
        <div className={styles.templateSection}>
          <div className={styles.templateSectionTitle}>Template References</div>
          {tokens.map((token, i) => {
            const comp = token.componentName ? componentsByName.get(token.componentName) : null;
            const resolved = comp !== null && comp !== undefined;
            const unsupportedProp = token.propertyName !== 'value';
            return (
              <div key={i} className={styles.templateRow}>
                <code className={styles.templateRaw}>{token.raw}</code>
                <span className={styles.templateLocation}>{token.location}</span>
                {unsupportedProp ? (
                  <span className={styles.templateUnresolved}>
                    unsupported property &ldquo;{token.propertyName}&rdquo;
                  </span>
                ) : resolved ? (
                  <span className={styles.templateResolved}>
                    {comp!.name} / {comp!.type}
                  </span>
                ) : (
                  <span className={styles.templateUnresolved}>
                    component &ldquo;{token.componentName}&rdquo; not found
                  </span>
                )}
              </div>
            );
          })}
          {malformed.map((bad, i) => (
            <div key={`bad-${i}`} className={styles.templateRow}>
              <code className={styles.templateRaw}>{bad.raw}…</code>
              <span className={styles.templateLocation}>{bad.location}</span>
              <span className={styles.templateUnresolved}>malformed &#x2014; missing &#x7B;&#x7B;&#x7D;&#x7D; closing braces</span>
            </div>
          ))}
        </div>
      )}

      {/* ── Diagnostics ── */}
      <DiagList diags={diags} />

      <div className={styles.actionControls}>
        <button
          type="button"
          className={styles.smallButton}
          onClick={onEdit}
          disabled={controlsLocked}
          aria-label={`Edit REST action ${action.name}`}
        >
          {isEditing ? 'Editing' : 'Edit'}
        </button>
        <button
          type="button"
          className={styles.smallButton}
          onClick={onDuplicate}
          disabled={controlsLocked}
          aria-label={`Duplicate REST action ${action.name}`}
        >
          Duplicate
        </button>
        <button
          type="button"
          className={styles.dangerButton}
          onClick={onDelete}
          disabled={controlsLocked}
          aria-label={`Delete REST action ${action.name}`}
        >
          Delete
        </button>
      </div>

      {/* ── Test Action ── */}
      <div className={styles.testRow}>
        <button
          className={styles.testBtn}
          disabled={test.status === 'running' || actionsLocked}
          onClick={handleTest}
        >
          {test.status === 'running' ? 'Running…' : 'Test Action'}
        </button>

        {test.status === 'running' && (
          <span className={styles.testStatus}>Calling proxy…</span>
        )}

        {test.status === 'ok' && (
          <div className={styles.testResult}>
            <div className={styles.testStatus}>
              HTTP {test.response.status} {test.response.statusText}
              {' · '}{test.response.durationMs} ms
              {' · '}{test.response.ok ? '✓ ok' : '✗ non-2xx'}
            </div>
            <pre className={styles.testResultPre}>
              {JSON.stringify(test.response.body, null, 2)}
            </pre>
          </div>
        )}

        {test.status === 'error' && (
          <div className={styles.testResult}>
            <pre className={[styles.testResultPre, styles.testResultError].join(' ')}>
              {test.message}
            </pre>
          </div>
        )}
      </div>
      </div>}
    </div>
  );
}

// ── BindingCard ───────────────────────────────────────────────────────────────

type BindingCardProps = {
  binding: Binding;
  allComponents: CanvasComponent[];
  allActions: RestAction[];
  diags: Diagnostic[];
  expanded: boolean;
  onToggle: () => void;
  onEdit: () => void;
  onDelete: () => void;
};

function BindingCard({
  binding,
  allComponents,
  allActions,
  diags,
  expanded,
  onToggle,
  onEdit,
  onDelete,
}: BindingCardProps): React.ReactElement {
  // Resolve source
  const sourceActionId    = parseActionSource(binding.source);
  const sourceAction      = sourceActionId ? allActions.find((a) => a.id === sourceActionId) : null;
  const sourceComponentName = parseComponentExpr(binding.source);
  const sourceComponent   = sourceComponentName
    ? allComponents.find((c) => c.name === sourceComponentName)
    : null;

  // Resolve target
  const targetComponentName = parseComponentExpr(binding.target);
  const targetComponent     = targetComponentName
    ? allComponents.find((c) => c.name === targetComponentName)
    : null;
  const targetVariableName = parseVariableTargetPath(binding.target)?.variableName ?? null;

  const trigger = binding.trigger ?? 'onChange';

  return (
    <div className={styles.bindingCard}>
      <button type="button" className={styles.cardToggle} onClick={onToggle} aria-expanded={expanded} aria-label={`${expanded ? 'Collapse' : 'Expand'} binding ${binding.id}`}>
        <span className={styles.toggleHeadingRow}>
          <span className={styles.recordType}>{sourceActionId ? 'Response binding' : 'Component binding'}</span>
          <span className={styles.toggleStatus}>
            {diags.length > 0 && <span className={styles.issueBadge}>{diags.length} issue{diags.length === 1 ? '' : 's'}</span>}
            <span className={styles.chevron} aria-hidden="true">{expanded ? '▾' : '▸'}</span>
          </span>
        </span>
        <span className={styles.bindingCardHeader}><span className={styles.bindingId}>{binding.id}</span><span className={styles.triggerBadge}>{trigger}</span></span>
        <span className={styles.bindingCompactFlow}>{binding.source} <span aria-hidden="true">→</span> {binding.target}</span>
      </button>

      {expanded && <div className={styles.cardDetails}>

      {/* ── Source / Target flow ── */}
      <div className={styles.bindingFlow}>
        {/* Source */}
        <div className={styles.bindingRow}>
          <span className={styles.bindingLabel}>Source</span>
          <span className={styles.bindingExpr}>{binding.source}</span>
          {sourceAction && (
            <span className={styles.bindingResolved}>
              {sourceAction.method} {sourceAction.name}
            </span>
          )}
          {sourceComponent && (
            <span className={styles.bindingResolved}>
              {sourceComponent.name} / {sourceComponent.type}
            </span>
          )}
          {!sourceAction && !sourceComponent && sourceActionId && (
            <span className={styles.bindingUnresolved}>
              action &ldquo;{sourceActionId}&rdquo; not found
            </span>
          )}
          {!sourceAction && !sourceComponent && sourceComponentName && !sourceActionId && (
            <span className={styles.bindingUnresolved}>
              component &ldquo;{sourceComponentName}&rdquo; not found
            </span>
          )}
        </div>

        {/* Target */}
        <div className={styles.bindingRow}>
          <span className={styles.bindingLabel}>Target</span>
          <span className={styles.bindingExpr}>{binding.target}</span>
          {targetComponent ? (
            <span className={styles.bindingResolved}>
              {targetComponent.name} / {targetComponent.type}
            </span>
          ) : targetComponentName ? (
            <span className={styles.bindingUnresolved}>
              component &ldquo;{targetComponentName}&rdquo; not found
            </span>
          ) : targetVariableName ? (
            <span className={styles.bindingResolved}>
              Variable {targetVariableName}
            </span>
          ) : null}
        </div>
      </div>

      {/* ── Diagnostics ── */}
      <DiagList diags={diags} />
      <div className={styles.actionControls}>
        <button type="button" className={styles.smallButton} onClick={onEdit} aria-label={`Edit binding ${binding.id}`} disabled={!sourceActionId} title={!sourceActionId ? 'Edit component bindings in the JSON Editor.' : undefined}>Edit</button>
        <button type="button" className={styles.dangerButton} onClick={onDelete} aria-label={`Delete binding ${binding.id}`}>Delete</button>
      </div>
      </div>}
    </div>
  );
}

// ── Main component ────────────────────────────────────────────────────────────

function ActionInspector(): React.ReactElement {
  const { doc, setDoc } = useProject();
  const { actions, bindings, pages, variables } = doc.project;

  const allComponents = useMemo(
    () => pages.flatMap((p) => p.components),
    [pages],
  );

  const [editingActionIndex, setEditingActionIndex] = useState<number | null>(null);
  const [editingBindingIndex, setEditingBindingIndex] = useState<number | null>(null);
  const [editingVariableName, setEditingVariableName] = useState<string | null>(null);
  const [addingVariable, setAddingVariable] = useState(false);
  const [secrets, setSecrets] = useState<SecretMetadata[]>([]);
  const [openSections, setOpenSections] = useState(() => new Set(['actions']));
  const [openActions, setOpenActions] = useState<Set<string>>(() => new Set());
  const [openBindings, setOpenBindings] = useState<Set<string>>(() => new Set());
  const [openVariables, setOpenVariables] = useState<Set<string>>(() => new Set());

  const toggleOpen = useCallback((setter: React.Dispatch<React.SetStateAction<Set<string>>>, key: string) => {
    setter((current) => {
      const next = new Set(current);
      if (next.has(key)) next.delete(key); else next.add(key);
      return next;
    });
  }, []);

  const refreshSecrets = useCallback(async () => {
    try { setSecrets(await listSecrets()); } catch { setSecrets([]); }
  }, []);

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

  useEffect(() => {
    if (
      editingActionIndex !== null &&
      editingActionIndex >= actions.length
    ) {
      setEditingActionIndex(null);
    }
  }, [actions.length, editingActionIndex]);

  useEffect(() => {
    if (editingBindingIndex !== null && editingBindingIndex >= bindings.length) {
      setEditingBindingIndex(null);
    }
  }, [bindings.length, editingBindingIndex]);

  const handleAddAction = useCallback(() => {
    const action = createRestAction(
      actions,
      collectReferencedActionIds(doc),
    );
    setDoc((current) => appendRestAction(current, action));
    setEditingActionIndex(actions.length);
    setOpenActions((current) => new Set(current).add(`${action.id}-${actions.length}`));
  }, [actions, doc, setDoc]);

  const handleUpdateAction = useCallback(
    (actionIndex: number, action: RestAction) => {
      setDoc((current) => replaceRestActionAt(current, actionIndex, action));
    },
    [setDoc],
  );

  const handleDuplicateAction = useCallback(
    (source: RestAction) => {
      const duplicate = duplicateRestAction(
        source,
        actions,
        collectReferencedActionIds(doc),
      );
      setDoc((current) => appendRestAction(current, duplicate));
      setEditingActionIndex(actions.length);
      setOpenActions((current) => new Set(current).add(`${duplicate.id}-${actions.length}`));
    },
    [actions, doc, setDoc],
  );

  const handleDeleteAction = useCallback(
    (actionIndex: number, action: RestAction) => {
      const references = findActionReferences(doc, action.id);
      if (!window.confirm(buildDeleteConfirmation(action, references))) return;

      setDoc((current) => removeRestActionAt(current, actionIndex));
      setEditingActionIndex((current) => {
        if (current === null || current === actionIndex) return null;
        return current > actionIndex ? current - 1 : current;
      });
    },
    [doc, setDoc],
  );

  const targetOptions = useMemo(
    () => responseTargetOptions(allComponents, variables),
    [allComponents, variables],
  );

  const handleAddBinding = useCallback(() => {
    if (actions.length === 0 || targetOptions.length === 0) return;
    const binding = createResponseBinding(bindings, actions[0], targetOptions[0].value);
    setDoc((current) => ({ ...current, project: { ...current.project, bindings: [...current.project.bindings, binding] } }));
    setEditingBindingIndex(bindings.length);
    setOpenBindings((current) => new Set(current).add(`${binding.id}-${bindings.length}`));
  }, [actions, bindings, setDoc, targetOptions]);

  const handleDeleteVariable = useCallback((name: string) => {
    const references = findVariableReferences(doc, name);
    const suffix = references.length > 0
      ? `\n\nReferences will become unresolved:\n- ${references.join('\n- ')}`
      : '';
    if (!window.confirm(`Delete variable "${name}"?${suffix}`)) return;
    setDoc((current) => removeVariable(current, name));
  }, [doc, setDoc]);

  // Compute all diagnostics once per render cycle
  const diagMap = useMemo(
    () => computeDiagnostics(actions, bindings, pages, allComponents, variables),
    [actions, bindings, pages, allComponents, variables],
  );

  // Count total warnings/infos for section summary banners
  const actionWarnings = actions.reduce(
    (count, _action, actionIndex) => count + (
      diagMap[actionDiagnosticKey(actionIndex)]
        ?.filter((diagnostic) => diagnostic.severity === 'warn').length ?? 0
    ),
    0,
  );
  const actionInfos = actions.reduce(
    (count, _action, actionIndex) => count + (
      diagMap[actionDiagnosticKey(actionIndex)]
        ?.filter((diagnostic) => diagnostic.severity === 'info').length ?? 0
    ),
    0,
  );
  const bindingWarnings = bindings.reduce(
    (n, b) => n + (diagMap[b.id]?.filter((d) => d.severity === 'warn').length ?? 0),
    0,
  );
  const totalIssues = actionWarnings + actionInfos + bindingWarnings;

  return (
    <div className={styles.page}>
      <ValidationSummary />
      {/* ── Page header ── */}
      <div className={styles.pageHeader}>
        <div className={styles.pageTitle}>Actions &amp; Bindings</div>
        <div className={styles.pageSubtitle}>
          Create and configure anonymous REST actions here without editing
          project JSON by hand. Valid edits synchronize with the canonical
          document immediately. <strong>Test Action</strong> calls the backend
          proxy with the configured action; diagnostics highlight invalid
          definitions and broken references.
        </div>
      </div>

      <SecretManager secrets={secrets} actions={actions} onChanged={refreshSecrets} />

      {/* ══ Actions section ══════════════════════════════════════════ */}
      <div className={styles.section}>
        <div className={styles.sectionHeader}>
          <button type="button" className={styles.sectionToggle} onClick={() => toggleOpen(setOpenSections, 'actions')} aria-expanded={openSections.has('actions')} aria-label={`${openSections.has('actions') ? 'Collapse' : 'Expand'} REST Actions section`}>
            <span className={styles.chevron} aria-hidden="true">{openSections.has('actions') ? '▾' : '▸'}</span>
            <span><span className={styles.sectionTitle}>REST Actions</span><span className={styles.sectionDescription}>Requests sent through the backend proxy.</span></span>
          </button>
          <div className={styles.sectionHeaderMeta}>
            <span className={styles.sectionCount}>{actions.length}</span>
            <button
              type="button"
              className={styles.primaryButton}
              data-testid="add-rest-action"
              onClick={() => { setOpenSections((current) => new Set(current).add('actions')); handleAddAction(); }}
              disabled={editingActionIndex !== null}
              title={editingActionIndex !== null
                ? 'Finish the current action edit before adding another.'
                : undefined}
            >
              + Add REST action
            </button>
          </div>
        </div>

        <div className={styles.sectionBody} hidden={!openSections.has('actions')}>

        {/* Summary banner */}
        {actions.length > 0 && (actionWarnings + actionInfos) === 0 && (
          <div className={styles.diagSummaryOk}>
            ✓ No issues found
          </div>
        )}
        {actions.length > 0 && (actionWarnings + actionInfos) > 0 && (
          <div className={styles.diagSummary}>
            ⚠ {actionWarnings + actionInfos} issue{actionWarnings + actionInfos !== 1 ? 's' : ''} detected
          </div>
        )}

        {actions.length === 0 ? (
          <div className={styles.empty}>
            No REST actions defined. Add one to begin configuring the canonical
            project document visually.
          </div>
        ) : (
          <div className={styles.recordList}>
            {actions.map((action, actionIndex) => (
            <div className={styles.recordGroup} key={`${action.id}-${actionIndex}`}>
              <ActionCard
                action={action}
                diags={diagMap[actionDiagnosticKey(actionIndex)] ?? []}
                allComponents={allComponents}
                isEditing={editingActionIndex === actionIndex}
                actionsLocked={editingActionIndex !== null}
                expanded={openActions.has(`${action.id}-${actionIndex}`)}
                onToggle={() => toggleOpen(setOpenActions, `${action.id}-${actionIndex}`)}
                onEdit={() => { setEditingActionIndex(actionIndex); setOpenActions((current) => new Set(current).add(`${action.id}-${actionIndex}`)); }}
                onDuplicate={() => handleDuplicateAction(action)}
                onDelete={() => handleDeleteAction(actionIndex, action)}
              />
              {editingActionIndex === actionIndex && (
                <RestActionEditor
                  key={`${action.id}-${actionIndex}`}
                  action={action}
                  actions={actions}
                  components={allComponents}
                  variables={variables}
                  secrets={secrets}
                  onChange={(updatedAction) =>
                    handleUpdateAction(actionIndex, updatedAction)
                  }
                  onDone={() => setEditingActionIndex(null)}
                />
              )}
            </div>
            ))}
          </div>
        )}
        </div>
      </div>

      {/* ══ Bindings section ═════════════════════════════════════════ */}
      <div className={styles.section}>
        <div className={styles.sectionHeader}>
          <button type="button" className={styles.sectionToggle} onClick={() => toggleOpen(setOpenSections, 'bindings')} aria-expanded={openSections.has('bindings')} aria-label={`${openSections.has('bindings') ? 'Collapse' : 'Expand'} Response Bindings section`}>
            <span className={styles.chevron} aria-hidden="true">{openSections.has('bindings') ? '▾' : '▸'}</span>
            <span><span className={styles.sectionTitle}>Response Bindings</span><span className={styles.sectionDescription}>Routes successful action responses into components or runtime variables.</span></span>
          </button>
          <div className={styles.sectionHeaderMeta}>
            <span className={styles.sectionCount}>{bindings.length}</span>
            <button type="button" className={styles.primaryButton} data-testid="add-response-binding" onClick={() => { setOpenSections((current) => new Set(current).add('bindings')); handleAddBinding(); }} disabled={actions.length === 0 || targetOptions.length === 0 || editingBindingIndex !== null} title={actions.length === 0 ? 'Add a REST action first.' : targetOptions.length === 0 ? 'Add a supported target component or variable first.' : undefined}>+ Add response binding</button>
          </div>
        </div>

        <div className={styles.sectionBody} hidden={!openSections.has('bindings')}>

        {/* Summary banner */}
        {bindings.length > 0 && bindingWarnings === 0 && (
          <div className={styles.diagSummaryOk}>
            ✓ No issues found
          </div>
        )}
        {bindings.length > 0 && bindingWarnings > 0 && (
          <div className={styles.diagSummary}>
            ⚠ {bindingWarnings} issue{bindingWarnings !== 1 ? 's' : ''} detected
          </div>
        )}

        {bindings.length === 0 ? (
          <div className={styles.empty}>
            No response bindings defined. Add a REST action and a supported
            component or variable target to configure one visually.
          </div>
        ) : (
          <div className={styles.recordList}>
            {bindings.map((binding, bindingIndex) => (
            <div className={styles.recordGroup} key={`${binding.id}-${bindingIndex}`}>
              <BindingCard binding={binding} allComponents={allComponents} allActions={actions} diags={diagMap[binding.id] ?? []} expanded={openBindings.has(`${binding.id}-${bindingIndex}`)} onToggle={() => toggleOpen(setOpenBindings, `${binding.id}-${bindingIndex}`)} onEdit={() => { setEditingBindingIndex(bindingIndex); setOpenBindings((current) => new Set(current).add(`${binding.id}-${bindingIndex}`)); }} onDelete={() => {
                if (window.confirm(`Delete binding "${binding.id}"?`)) {
                  setDoc((current) => removeBindingAt(current, bindingIndex));
                  setEditingBindingIndex((current) => {
                    if (current === null || current === bindingIndex) return null;
                    return current > bindingIndex ? current - 1 : current;
                  });
                }
              }} />
              {editingBindingIndex === bindingIndex && <ResponseBindingEditor binding={binding} actions={actions} components={allComponents} variables={variables} onChange={(updated) => setDoc((current) => replaceBindingAt(current, bindingIndex, updated))} onDone={() => setEditingBindingIndex(null)} />}
            </div>
            ))}
          </div>
        )}
        </div>
      </div>

      <div className={styles.section}>
        <div className={styles.sectionHeader}>
          <button type="button" className={styles.sectionToggle} onClick={() => toggleOpen(setOpenSections, 'variables')} aria-expanded={openSections.has('variables')} aria-label={`${openSections.has('variables') ? 'Collapse' : 'Expand'} Variables section`}>
            <span className={styles.chevron} aria-hidden="true">{openSections.has('variables') ? '▾' : '▸'}</span>
            <span><span className={styles.sectionTitle}>Variables</span><span className={styles.sectionDescription}>Typed defaults copied into ephemeral Preview runtime state.</span></span>
          </button>
          <div className={styles.sectionHeaderMeta}><span className={styles.sectionCount}>{Object.keys(variables).length}</span><button type="button" className={styles.primaryButton} data-testid="add-variable" disabled={addingVariable || editingVariableName !== null} onClick={() => { setOpenSections((current) => new Set(current).add('variables')); setAddingVariable(true); }}>+ Add variable</button></div>
        </div>
        <div className={styles.sectionBody} hidden={!openSections.has('variables')}>
        {addingVariable && <VariableEditor name="" variable={{ type: 'string' }} variables={variables} isNew onSave={(name, variable) => { setDoc((current) => setVariable(current, name, variable)); setAddingVariable(false); }} onCancel={() => setAddingVariable(false)} />}
        {Object.keys(variables).length === 0 && !addingVariable ? <div className={styles.empty}>No variables declared.</div> : <div className={styles.recordList}>{Object.entries(variables).map(([name, variable]) => (
          <div className={styles.recordGroup} key={name}>
            <div className={styles.variableCard}>
              <button type="button" className={styles.cardToggle} aria-expanded={openVariables.has(name)} aria-label={`${openVariables.has(name) ? 'Collapse' : 'Expand'} variable ${name}`} onClick={() => toggleOpen(setOpenVariables, name)}>
              <span className={styles.toggleHeadingRow}><span className={styles.recordType}>Runtime variable</span><span className={styles.chevron} aria-hidden="true">{openVariables.has(name) ? '▾' : '▸'}</span></span>
              <span className={styles.bindingCardHeader}><span className={styles.variableName}>{name}</span> <span className={styles.triggerBadge}>{variable.type}</span></span>
              </button>
              {openVariables.has(name) && <div className={styles.cardDetails}>
              <div className={styles.variableDefault}><span className={styles.bindingLabel}>Default</span><code className={styles.bindingExpr}>{variable.defaultValue === undefined ? 'No default value' : JSON.stringify(variable.defaultValue)}</code></div>
              {variable.description && <span className={styles.actionDescription}>{variable.description}</span>}
              <div className={styles.actionControls}><button type="button" className={styles.smallButton} aria-label={`Edit variable ${name}`} onClick={() => { setEditingVariableName(name); setOpenVariables((current) => new Set(current).add(name)); }}>Edit</button><button type="button" className={styles.dangerButton} aria-label={`Delete variable ${name}`} onClick={() => handleDeleteVariable(name)}>Delete</button></div>
              </div>}
            </div>
            {editingVariableName === name && <VariableEditor name={name} variable={variable} variables={variables} onSave={(nextName, nextVariable) => { setDoc((current) => setVariable(current, nextName, nextVariable, name)); setEditingVariableName(null); }} onCancel={() => setEditingVariableName(null)} />}
          </div>
        ))}</div>}
        </div>
      </div>

      {/* ══ Overall summary (only shown when there are issues) ═══════ */}
      {totalIssues === 0 && (actions.length > 0 || bindings.length > 0) && (
        <div style={{ fontSize: 12, color: '#15803d' }}>
          ✓ All actions and bindings are consistent.
        </div>
      )}
    </div>
  );
}

export default ActionInspector;
