/**
 * Spec 2 Phase 3 / Task 15: integration tests for multi-backend dispatch
 * routing.
 *
 * Coverage:
 * - SC27 — tier-routed dispatch invokes the correct backend per
 *   `agent.routing` (quick-fix → local, guided-change → cloud).
 * - SC28 — `escalation.alwaysHuman` blocks dispatch (escalation gate
 *   persists across the routing refactor).
 * - SC29 — `escalation.autoExecute: []` blocks `diagnostic` regardless
 *   of routing.
 * - SC30 — mechanical grep: zero hits for legacy two-runner patterns
 *   in `packages/orchestrator/src/`.
 * - SC42 — canonical legacy config (single `agent.backend` +
 *   `agent.localBackend`) loads, dispatches `quick-fix` to synthesized
 *   `local`, `guided-change` to synthesized `primary`.
 * - SC43 — escalation governs *whether*, routing governs *where*. With
 *   identical routing, an alwaysHuman tier short-circuits dispatch
 *   entirely while a routable tier reaches the factory.
 *
 * The tests reach into the orchestrator's private `backendFactory` and
 * `dispatchIssue` so we can drive routing decisions directly without
 * standing up the full state-machine harness. SC28/SC29 escalation
 * arithmetic is covered exhaustively by `core/model-router.test.ts`;
 * here we assert the *integration* — that the orchestrator honors a
 * routing decision after escalation has already approved dispatch.
 */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { execFile, execFileSync } from 'node:child_process';
import { promisify } from 'node:util';
import { Orchestrator } from '../../src/orchestrator';
import { MockBackend } from '../../src/agent/backends/mock';
import { OrchestratorBackendFactory } from '../../src/agent/orchestrator-backend-factory';
import type {
  WorkflowConfig,
  IssueTrackerClient,
  Issue,
  RoutingUseCase,
  AgentBackend,
  RoutingConfig,
  BackendDef,
} from '@harness-engineering/types';
import { Ok } from '@harness-engineering/types';
import { noopExecFile } from '../helpers/noop-exec-file';
import { routeIssue } from '../../src/core/model-router';

const execFileAsync = promisify(execFile);

let tmpDir: string;

function makeMockTracker(): IssueTrackerClient {
  return {
    fetchCandidateIssues: vi.fn().mockResolvedValue(Ok([])),
    fetchIssuesByStates: vi.fn().mockResolvedValue(Ok([])),
    fetchIssueStatesByIds: vi.fn().mockResolvedValue(Ok(new Map())),
    markIssueComplete: vi.fn().mockResolvedValue(Ok(undefined)),
    claimIssue: vi.fn().mockResolvedValue(Ok(undefined)),
    releaseIssue: vi.fn().mockResolvedValue(Ok(undefined)),
  } as unknown as IssueTrackerClient;
}

function makeIssue(overrides: Partial<Issue> = {}): Issue {
  return {
    id: 'TEST-1',
    identifier: 'TEST-1',
    title: 'Test issue',
    description: 'desc',
    priority: 1,
    state: 'planned',
    branchName: 'test/branch',
    url: null,
    labels: [],
    blockedBy: [],
    spec: null,
    plans: [],
    createdAt: '2026-05-04T00:00:00Z',
    updatedAt: '2026-05-04T00:00:00Z',
    externalId: null,
    ...overrides,
  } as Issue;
}

function makeConfig(overrides: Partial<WorkflowConfig['agent']> = {}): WorkflowConfig {
  return {
    tracker: {
      kind: 'mock',
      activeStates: ['planned'],
      terminalStates: ['done'],
    },
    polling: { intervalMs: 1000 },
    workspace: { root: path.join(tmpDir, '.harness', 'workspaces') },
    hooks: {
      afterCreate: null,
      beforeRun: null,
      afterRun: null,
      beforeRemove: null,
      timeoutMs: 1000,
    },
    agent: {
      backend: 'mock',
      maxConcurrentAgents: 2,
      maxTurns: 3,
      maxRetryBackoffMs: 1000,
      maxRetries: 5,
      maxConcurrentAgentsByState: { planned: 1 },
      turnTimeoutMs: 5000,
      readTimeoutMs: 5000,
      stallTimeoutMs: 5000,
      ...overrides,
    },
    server: { port: null },
  } as WorkflowConfig;
}

beforeEach(() => {
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-multi-backend-'));
  execFileSync(
    'sh',
    [
      '-c',
      'git init && git config user.email "test@test" && git config user.name "test" && git commit --allow-empty -m "init"',
    ],
    { cwd: tmpDir, stdio: 'ignore' }
  );
  fs.mkdirSync(path.join(tmpDir, '.harness', 'workspaces'), { recursive: true });
});

afterEach(() => {
  try {
    fs.rmSync(tmpDir, { recursive: true, force: true });
  } catch {
    /* best-effort cleanup */
  }
});

/**
 * Wrap an orchestrator's backendFactory with a spy so each
 * `forUseCase` invocation is observable. Returns the spy + a getter
 * for the recorded use-cases.
 */
function spyOnFactory(orch: Orchestrator): {
  spy: ReturnType<typeof vi.fn>;
  useCases: () => RoutingUseCase[];
} {
  const factoryHolder = orch as unknown as { backendFactory: OrchestratorBackendFactory };
  const original = factoryHolder.backendFactory;
  const recorded: RoutingUseCase[] = [];
  const spy = vi.fn((useCase: RoutingUseCase): AgentBackend => {
    recorded.push(useCase);
    return original.forUseCase(useCase);
  });
  // Spec 2 P2-I2: dispatch now also calls `resolveName(useCase)` before
  // `forUseCase`, so the spy proxy must forward both methods to keep
  // the recorder + LiveSession labelling path working.
  factoryHolder.backendFactory = {
    forUseCase: spy as unknown as OrchestratorBackendFactory['forUseCase'],
    resolveName: ((useCase: RoutingUseCase): string =>
      original.resolveName(useCase)) as unknown as OrchestratorBackendFactory['resolveName'],
  } as unknown as OrchestratorBackendFactory;
  return { spy, useCases: () => recorded };
}

describe('Multi-backend dispatch routing (Spec 2 Phase 3)', () => {
  describe('SC27 — tier-routed dispatch invokes routed backend', () => {
    it('quick-fix tier resolves to the local backend per routing', async () => {
      const config = makeConfig({
        backend: 'mock',
        backends: {
          cloud: { type: 'mock' },
          local: { type: 'mock' },
        },
        routing: { default: 'cloud', 'quick-fix': 'local' },
      });
      const orch = new Orchestrator(config, 'Prompt', {
        tracker: makeMockTracker(),
        execFileFn: noopExecFile,
      });
      const { useCases } = spyOnFactory(orch);

      // Drive dispatchIssue directly via reflection (bypass state machine).
      // detectScopeTier on a no-spec/no-plan issue with priority 1 returns
      // 'guided-change' by default; quick-fix is signaled by passing
      // backend='local' explicitly through the legacy parameter.
      const issue = makeIssue({ id: 'q-1', identifier: 'Q-1' });
      await (
        orch as unknown as {
          dispatchIssue: (i: Issue, attempt: number, backend: 'local' | 'primary') => Promise<void>;
        }
      ).dispatchIssue(issue, 1, 'local');

      expect(useCases()).toContainEqual({ kind: 'tier', tier: 'quick-fix' });
    });

    it('non-local dispatch resolves to detected scope tier (guided-change when spec exists)', async () => {
      const config = makeConfig({
        backend: 'mock',
        backends: {
          cloud: { type: 'mock' },
          local: { type: 'mock' },
        },
        routing: { default: 'cloud', 'quick-fix': 'local' },
      });
      const orch = new Orchestrator(config, 'Prompt', {
        tracker: makeMockTracker(),
        execFileFn: noopExecFile,
      });
      const { useCases } = spyOnFactory(orch);

      // detectScopeTier rules: hasSpec OR hasPlans → 'guided-change';
      // else → 'full-exploration'. Provide a spec to land on
      // guided-change, which routes to 'cloud' per routing.default.
      const issue = makeIssue({ id: 'g-1', identifier: 'G-1', spec: 'docs/spec.md' });
      await (
        orch as unknown as {
          dispatchIssue: (
            i: Issue,
            attempt: number,
            backend?: 'local' | 'primary'
          ) => Promise<void>;
        }
      ).dispatchIssue(issue, 1);

      const got = useCases();
      expect(got).toHaveLength(1);
      expect(got[0]).toEqual({ kind: 'tier', tier: 'guided-change' });
    });

    it('non-local dispatch with no artifacts resolves to full-exploration', async () => {
      const config = makeConfig({
        backend: 'mock',
        backends: {
          cloud: { type: 'mock' },
          local: { type: 'mock' },
        },
        routing: { default: 'cloud', 'quick-fix': 'local' },
      });
      const orch = new Orchestrator(config, 'Prompt', {
        tracker: makeMockTracker(),
        execFileFn: noopExecFile,
      });
      const { useCases } = spyOnFactory(orch);

      // No spec, no plans — detectScopeTier returns 'full-exploration'.
      const issue = makeIssue({ id: 'fe-1', identifier: 'FE-1' });
      await (
        orch as unknown as {
          dispatchIssue: (
            i: Issue,
            attempt: number,
            backend?: 'local' | 'primary'
          ) => Promise<void>;
        }
      ).dispatchIssue(issue, 1);

      const got = useCases();
      expect(got).toHaveLength(1);
      expect(got[0]).toEqual({ kind: 'tier', tier: 'full-exploration' });
    });
  });

  describe('SC28 — escalation.alwaysHuman blocks dispatch', () => {
    it('full-exploration in alwaysHuman returns a human escalation, not a dispatch', () => {
      // Arithmetic at the routing layer: routeIssue is what the state
      // machine consults before emitting a DispatchEffect. When it
      // returns action='needs-human', no DispatchEffect is emitted, so
      // the orchestrator never reaches the factory.
      const decision = routeIssue('full-exploration', [], {
        alwaysHuman: ['full-exploration'],
        primaryExecute: [],
        autoExecute: ['quick-fix'],
        signalGated: [],
      });
      expect(decision.action).toBe('needs-human');
    });

    it('a tier NOT in alwaysHuman yields an actionable dispatch decision', () => {
      const decision = routeIssue('quick-fix', [], {
        alwaysHuman: ['full-exploration'],
        primaryExecute: [],
        autoExecute: ['quick-fix'],
        signalGated: [],
      });
      expect(decision.action).toBe('dispatch-local');
    });
  });

  describe('SC29 — escalation.signalGated with signals blocks dispatch', () => {
    it('signalGated tier with concern signals returns needs-human', () => {
      // SC29 in spec semantics: when concern signals (e.g., security
      // smells, scope creep) accompany an issue, escalation should
      // gate dispatch even if routing has a target. Use signalGated
      // to express this; concern signals tip the decision to human.
      const decision = routeIssue(
        'guided-change',
        [{ name: 'sec', reason: 'suspicious pattern' }],
        {
          alwaysHuman: [],
          primaryExecute: [],
          autoExecute: [],
          signalGated: ['guided-change'],
        }
      );
      expect(decision.action).toBe('needs-human');
    });
  });

  describe('SC30 — legacy two-runner patterns gone from src', () => {
    it('git grep -n "backend === \'local\'" returns zero hits', async () => {
      const cwd = path.resolve(__dirname, '..', '..', '..', '..');
      let stdout = '';
      try {
        const result = await execFileAsync(
          'git',
          ['grep', '-n', "backend === 'local'", '--', 'packages/orchestrator/src/'],
          { cwd }
        );
        stdout = result.stdout;
      } catch (err) {
        // git grep exits non-zero when there are no matches — that's
        // exactly what we want.
        const e = err as { code?: number; stdout?: string };
        if (e.code !== 1) throw err;
        stdout = e.stdout ?? '';
      }
      expect(stdout.trim(), `expected zero hits; got: ${stdout}`).toBe('');
    });

    it('git grep -n "this\\.localRunner" returns zero hits', async () => {
      const cwd = path.resolve(__dirname, '..', '..', '..', '..');
      let stdout = '';
      try {
        const result = await execFileAsync(
          'git',
          ['grep', '-n', 'this\\.localRunner', '--', 'packages/orchestrator/src/'],
          { cwd }
        );
        stdout = result.stdout;
      } catch (err) {
        const e = err as { code?: number; stdout?: string };
        if (e.code !== 1) throw err;
        stdout = e.stdout ?? '';
      }
      expect(stdout.trim(), `expected zero hits; got: ${stdout}`).toBe('');
    });
  });

  describe('SC42 — canonical legacy config end-to-end', () => {
    it('legacy single-backend config synthesizes backends.primary + backends.local', () => {
      const config = makeConfig({
        backend: 'mock',
        localBackend: 'openai-compatible',
        localEndpoint: 'http://localhost:11434/v1',
        localModel: 'gemma-4-e4b',
        // autoExecute: ['quick-fix'] in escalation routes quick-fix → local
        escalation: {
          alwaysHuman: [],
          primaryExecute: [],
          autoExecute: ['quick-fix'],
          signalGated: [],
        },
      });
      const orch = new Orchestrator(config, 'Prompt', {
        tracker: makeMockTracker(),
        execFileFn: noopExecFile,
      });

      // After migration: backends.primary={type:'mock'} +
      // backends.local={type:'local',...}; routing.default='primary'
      // routing['quick-fix']='local' (synthesized from autoExecute).
      const migrated = (orch as unknown as { config: WorkflowConfig }).config;
      expect(migrated.agent.backends).toBeDefined();
      expect(migrated.agent.backends?.primary).toEqual({ type: 'mock' });
      expect(migrated.agent.backends?.local).toMatchObject({
        type: 'local',
        endpoint: 'http://localhost:11434/v1',
      });
      expect(migrated.agent.routing).toMatchObject({
        default: 'primary',
        'quick-fix': 'local',
      });
    });

    it('legacy config dispatches quick-fix to synthesized local, guided-change to primary', async () => {
      const config = makeConfig({
        backend: 'mock',
        localBackend: 'openai-compatible',
        localEndpoint: 'http://localhost:11434/v1',
        localModel: 'gemma-4-e4b',
        escalation: {
          alwaysHuman: [],
          primaryExecute: [],
          autoExecute: ['quick-fix'],
          signalGated: [],
        },
      });
      const orch = new Orchestrator(config, 'Prompt', {
        tracker: makeMockTracker(),
        execFileFn: noopExecFile,
      });
      const { useCases } = spyOnFactory(orch);

      // Stub local resolver fetchModels so factory's local construction
      // succeeds without hitting the network.
      const map = (
        orch as unknown as {
          localResolvers: Map<
            string,
            import('../../src/agent/local-model-resolver').LocalModelResolver
          >;
        }
      ).localResolvers;
      for (const resolver of map.values()) {
        (
          resolver as unknown as {
            fetchModels: (e: string, k?: string) => Promise<string[]>;
          }
        ).fetchModels = vi.fn().mockResolvedValue(['gemma-4-e4b']);
      }

      const localIssue = makeIssue({ id: 'l-1', identifier: 'L-1' });
      const cloudIssue = makeIssue({ id: 'c-1', identifier: 'C-1', spec: 'docs/spec.md' });
      await (
        orch as unknown as {
          dispatchIssue: (
            i: Issue,
            attempt: number,
            backend?: 'local' | 'primary'
          ) => Promise<void>;
        }
      ).dispatchIssue(localIssue, 1, 'local');
      await (
        orch as unknown as {
          dispatchIssue: (
            i: Issue,
            attempt: number,
            backend?: 'local' | 'primary'
          ) => Promise<void>;
        }
      ).dispatchIssue(cloudIssue, 1);

      const got = useCases();
      expect(got).toContainEqual({ kind: 'tier', tier: 'quick-fix' });
      expect(got).toContainEqual({ kind: 'tier', tier: 'guided-change' });
    });
  });

  // Spec 2 P2-I2 fixup: post-migration, three call sites read legacy
  // `agent.backend` to label the LiveSession + recorder. For pure-modern
  // configs that omit `agent.backend`, those reads return `undefined`.
  // The dispatch site already resolves a backend NAME via the router —
  // thread that name to the recorder so the dashboard's backend column
  // and stream-recording metadata never carry `undefined` strings.
  describe('P2-I2 — pure-modern config threads routed backend name to LiveSession + recorder', () => {
    it('quick-fix dispatch records backend="local" (routed name) for pure-modern config without agent.backend', async () => {
      // Pure-modern config: only `agent.backends` + `agent.routing`,
      // intentionally NO `agent.backend` and NO `agent.localBackend`.
      // Cast through unknown because makeConfig types `backend` as
      // required string, but the migration tolerates absence (SC15).
      const config = makeConfig({
        backends: {
          local: { type: 'mock' },
          primary: { type: 'mock' },
        },
        routing: { default: 'primary', 'quick-fix': 'local' },
      } as Partial<WorkflowConfig['agent']>);
      // Strip the makeConfig-injected `backend: 'mock'` to land a true
      // pure-modern config, exercising the regression path.
      delete (config.agent as Partial<WorkflowConfig['agent']>).backend;

      const orch = new Orchestrator(config, 'Prompt', {
        tracker: makeMockTracker(),
        execFileFn: noopExecFile,
      });

      // Capture all calls to recorder.startRecording so we can read the
      // 4th argument (backend name).
      const recorder = (orch as unknown as { recorder: { startRecording: Function } }).recorder;
      const startSpy = vi.fn();
      const original = recorder.startRecording.bind(recorder);
      recorder.startRecording = (
        ...args: [string, number | string | null, string, string, number, string?]
      ) => {
        startSpy(...args);
        return original(...args);
      };

      const localIssue = makeIssue({ id: 'pm-1', identifier: 'PM-1' });
      await (
        orch as unknown as {
          dispatchIssue: (
            i: Issue,
            attempt: number,
            backend?: 'local' | 'primary'
          ) => Promise<void>;
        }
      ).dispatchIssue(localIssue, 1, 'local');

      expect(startSpy).toHaveBeenCalled();
      const call = startSpy.mock.calls[0];
      // Args: [issueId, externalId, identifier, backend, attempt, title?]
      expect(call[3]).toBe('local');
      // Critical: must not be undefined nor the literal string 'undefined'.
      expect(call[3]).not.toBeUndefined();
      expect(call[3]).not.toBe('undefined');
    });

    it('guided-change dispatch records backend="primary" (routing.default) for pure-modern config', async () => {
      const config = makeConfig({
        backends: {
          local: { type: 'mock' },
          primary: { type: 'mock' },
        },
        routing: { default: 'primary', 'quick-fix': 'local' },
      } as Partial<WorkflowConfig['agent']>);
      delete (config.agent as Partial<WorkflowConfig['agent']>).backend;

      const orch = new Orchestrator(config, 'Prompt', {
        tracker: makeMockTracker(),
        execFileFn: noopExecFile,
      });

      const recorder = (orch as unknown as { recorder: { startRecording: Function } }).recorder;
      const startSpy = vi.fn();
      const original = recorder.startRecording.bind(recorder);
      recorder.startRecording = (
        ...args: [string, number | string | null, string, string, number, string?]
      ) => {
        startSpy(...args);
        return original(...args);
      };

      // Provide a spec so detectScopeTier returns 'guided-change' →
      // routing.default = 'primary'.
      const cloudIssue = makeIssue({ id: 'pm-2', identifier: 'PM-2', spec: 'docs/spec.md' });
      await (
        orch as unknown as {
          dispatchIssue: (
            i: Issue,
            attempt: number,
            backend?: 'local' | 'primary'
          ) => Promise<void>;
        }
      ).dispatchIssue(cloudIssue, 1);

      expect(startSpy).toHaveBeenCalled();
      const call = startSpy.mock.calls[0];
      expect(call[3]).toBe('primary');
      expect(call[3]).not.toBeUndefined();
      expect(call[3]).not.toBe('undefined');
    });

    it('LiveSession.backendName matches routed name (not legacy agent.backend) for pure-modern config', async () => {
      const config = makeConfig({
        backends: {
          local: { type: 'mock' },
          primary: { type: 'mock' },
        },
        routing: { default: 'primary', 'quick-fix': 'local' },
      } as Partial<WorkflowConfig['agent']>);
      delete (config.agent as Partial<WorkflowConfig['agent']>).backend;

      const orch = new Orchestrator(config, 'Prompt', {
        tracker: makeMockTracker(),
        execFileFn: noopExecFile,
      });

      // Seed state.running with a placeholder so dispatchIssue's
      // `state.running.set(issue.id, { ...entry, session })` saves the
      // LiveSession we want to inspect.
      const state = (orch as unknown as { state: { running: Map<string, unknown> } }).state;
      const issue = makeIssue({ id: 'pm-3', identifier: 'PM-3' });
      state.running.set(issue.id, {
        issue,
        attempt: 1,
        phase: 'PreparingWorkspace',
        workspacePath: null,
        session: null,
        cooldownUntilMs: null,
      });

      await (
        orch as unknown as {
          dispatchIssue: (
            i: Issue,
            attempt: number,
            backend?: 'local' | 'primary'
          ) => Promise<void>;
        }
      ).dispatchIssue(issue, 1, 'local');

      const entry = state.running.get(issue.id) as { session: { backendName: string } | null };
      expect(entry).toBeDefined();
      expect(entry.session).not.toBeNull();
      expect(entry.session?.backendName).toBe('local');
      expect(entry.session?.backendName).not.toBeUndefined();
    });
  });

  describe('SC43 — escalation governs whether, routing governs where', () => {
    it('escalation gate runs BEFORE routing — alwaysHuman tier never reaches factory', () => {
      // Verify the precedence model: even with a routing entry for the
      // tier, the escalation gate (model-router.routeIssue) returns a
      // human escalation, so the state machine never emits a
      // DispatchEffect for it. This confirms the layering: escalation
      // is consulted first; if it says "human", routing is irrelevant.
      const decision = routeIssue('full-exploration', [], {
        alwaysHuman: ['full-exploration'],
        primaryExecute: [],
        autoExecute: [],
        signalGated: [],
      });
      expect(decision.action).toBe('needs-human');

      // And for a tier NOT in alwaysHuman, the escalation gate yields
      // an actionable action, leaving routing free to choose the
      // backend.
      const allowed = routeIssue('quick-fix', [], {
        alwaysHuman: ['full-exploration'],
        primaryExecute: [],
        autoExecute: ['quick-fix'],
        signalGated: [],
      });
      expect(allowed.action).not.toBe('needs-human');
    });

    it('orchestrator routing does not override escalation: a quick-fix-routed-cloud issue still routes per agent.routing', async () => {
      // With quick-fix routed to 'cloud' (not 'local'), dispatching a
      // local-flagged issue still uses the routed-default factory entry
      // — confirming routing is consulted at dispatch-time without
      // re-checking escalation.
      const config = makeConfig({
        backend: 'mock',
        backends: {
          primary: { type: 'mock' },
          local: { type: 'mock' },
          // No 'cloud' — routing.default points to 'primary' instead.
        } satisfies Record<string, BackendDef>,
        routing: {
          default: 'primary',
          'quick-fix': 'local',
        } satisfies RoutingConfig,
      });
      const orch = new Orchestrator(config, 'Prompt', {
        tracker: makeMockTracker(),
        execFileFn: noopExecFile,
      });
      const { useCases } = spyOnFactory(orch);

      const issue = makeIssue({ id: 'r-1', identifier: 'R-1' });
      await (
        orch as unknown as {
          dispatchIssue: (i: Issue, attempt: number, backend: 'local' | 'primary') => Promise<void>;
        }
      ).dispatchIssue(issue, 1, 'local');

      expect(useCases()).toContainEqual({ kind: 'tier', tier: 'quick-fix' });
    });
  });
});
