// Cross-process custom-adapter integration test (INTR-233).
//
// Proves the whole cross-process pluggability path: an operator-configured
// adapter manifest is serialized into the forked workflow child's substrate
// env, the child deserializes and re-validates it, import()s the custom
// adapter module child-side, and resolves a provider id that no built-in
// supplies. A real hub + real sidecar subprocess + real forked workflow
// child + the mock inference server exercise the exact production wiring;
// nothing here is in-process or mocked at the resolution boundary.
//
// POSITIVE: the manifest maps provider "custom-x" to an absolute-path .ts
// fixture adapter (which delegates to the Anthropic adapter so it speaks the
// mock server's wire). A one-step workflow whose source.provider is
// "custom-x" runs to completion in the child and the echoed reply carries
// the inbound body -- the run could only complete if the child resolved
// "custom-x", which is impossible without the manifest crossing the fork and
// being import()-ed child-side. The provider id is the sentinel: a
// built-in-only registry has no "custom-x".
//
// NEGATIVE (the security firewall): a second deployment names a provider the
// manifest does NOT contain. The child's registry -- built only from the
// operator manifest plus the linked-in built-ins -- rejects it, so the run
// does not complete. This proves a provider string (which deploy/tenant
// config does control) cannot conjure an adapter: only operator-supplied
// manifest specifiers can, and they are import()-ed, never the provider key.

import path from "node:path";

import { afterAll, beforeAll, describe, expect, test } from "bun:test";

import { defineAgent, createDefaultDirectorRegistry } from "@intx/agent";
import type { HarnessConfig } from "@intx/types/runtime";
import { defineWorkflow, step, type WorkflowDefinition } from "@intx/workflow";
import {
  createWorkflowDeployOrchestrator,
  deriveDeploymentAddress,
  type ApprovalSet,
  type DeploySingleStepFn,
  type LaunchSessionFn,
  type SendMultiStepDeployFn,
  type WorkflowRepoWriter,
} from "@intx/workflow-deploy";
import { deriveDeploymentId } from "@intx/sidecar-app/src/workflow-host-wiring";
import type { RepoId, WorkflowRunHubPrincipal } from "@intx/hub-sessions";
import { DEFAULT_ASSET_REF } from "@intx/hub-sessions";

import {
  SESSION_ID,
  SIDECAR_ID,
  fireMailTrigger,
  readWorkflowRunEvents,
  startDeployFlowEnv,
  waitForFirstRunId,
  waitForWorkflowRunComplete,
  type DeployFlowEnv,
} from "../hub-agent/lib/deploy-flow-env";
import { toLaunchDeployContent } from "./launch-session-bridge";

const DEPLOYMENT_DOMAIN = "integration.interchange";
const STEP_ID = "step1";

// The provider id the operator manifest maps to the fixture adapter. Not a
// built-in -- resolving it proves the manifest reached the child.
const CUSTOM_PROVIDER = "custom-x";
// A provider id no manifest entry and no built-in supplies -- the negative
// control. The child registry must reject it.
const ABSENT_PROVIDER = "custom-absent";

// Absolute specifier for the fixture adapter module. Absolute paths resolve
// identically regardless of the child's cwd, and bun imports .ts directly.
const FIXTURE_SPECIFIER = path.resolve(
  import.meta.dir,
  "fixtures/custom-inference-adapter.ts",
);

let env: DeployFlowEnv;

beforeAll(async () => {
  env = await startDeployFlowEnv({
    inferenceEchoUserMessage: true,
    sidecarEnv: {
      SIDECAR_ADAPTER_MANIFEST: JSON.stringify([
        {
          provider: CUSTOM_PROVIDER,
          specifier: FIXTURE_SPECIFIER,
          export: "makeAdapter",
        },
      ]),
    },
  });
});

afterAll(async () => {
  await env.teardown();
});

type DeployedWorkflow = {
  deploymentMailAddress: string;
  workflowRunRepoId: RepoId;
};

// Deploy a one-step workflow whose single source uses `provider`. Models the
// single-step-message-input integration test's orchestrator wiring.
async function deployCustomProviderWorkflow(
  deploymentId: string,
  provider: string,
): Promise<DeployedWorkflow> {
  const deploymentMailAddress = deriveDeploymentAddress({
    deploymentId,
    deploymentDomain: DEPLOYMENT_DOMAIN,
  });

  const sourceId = `${provider}:mock-model`;
  const agent = defineAgent({
    id: `agent-${deploymentId}`,
    systemPrompt: "You are the cross-process custom-adapter test agent.",
    tools: [],
    capabilities: [],
    inference: {
      sources: [{ provider, model: "mock-model" }],
    },
  });

  const workflow: WorkflowDefinition = defineWorkflow({
    id: `wf_${deploymentId}`,
    trigger: { type: "mail", to: deploymentMailAddress },
    steps: {
      [STEP_ID]: step({ agent }),
    },
  });

  const config: HarnessConfig = {
    sessionId: SESSION_ID,
    agentId: `ins_${deploymentId}`,
    tenantId: "tenant-1",
    principalId: "prin_integration-1",
    agentAddress: deploymentMailAddress,
    systemPrompt: "Fallback prompt (overridden per step by the orchestrator)",
    tools: [],
    grants: [],
    sources: [
      {
        id: sourceId,
        provider,
        baseURL: `http://localhost:${env.inference.server.port}`,
        apiKey: "sk-mock",
        model: "mock-model",
      },
    ],
    defaultSource: sourceId,
  };

  const operatorApprovals: ApprovalSet = new Set<string>([
    `inference.source:${sourceId}`,
    "director:@intx/agent/default",
    `mail.address:${deploymentMailAddress}`,
    `mail.send:${DEPLOYMENT_DOMAIN}`,
  ]);

  const launchSession: LaunchSessionFn = async (orchestratorParams) => {
    await env.hub.sessionService.stageWorkflowStep({
      agentAddress: orchestratorParams.agentAddress,
      agentId: orchestratorParams.agentId,
      instanceId: orchestratorParams.instanceId,
      config: orchestratorParams.config,
      deployContent: toLaunchDeployContent(orchestratorParams.deployContent),
      ...(orchestratorParams.toolPackagePins !== undefined
        ? { toolPackagePins: orchestratorParams.toolPackagePins }
        : {}),
    });
  };

  const sendMultiStepDeploy: SendMultiStepDeployFn = async (params) =>
    env.hub.router.sendAgentDeploy(params.agentAddress, params.config, {
      definition: {
        id: params.definition.id,
        triggers: [...params.definition.triggers],
        stepOrder: [...params.definition.stepOrder],
        steps: params.definition.steps as Record<string, unknown>,
        ...(params.definition.state !== undefined
          ? { state: params.definition.state }
          : {}),
      },
      sources: params.sources,
    });

  const deploySingleStepAtHead: DeploySingleStepFn = (params) =>
    env.hub.sessionService.deploySingleStepAtHead(params);

  const workflowRepo: WorkflowRepoWriter = {
    async writeWorkflowRepo(args) {
      const repoId: RepoId = { kind: "workflow", id: args.workflowRepoId };
      const principal: WorkflowRunHubPrincipal = { kind: "hub" };
      const files: Record<string, string> = {};
      for (const [k, v] of args.files) {
        files[k] = v;
      }
      await env.hub.agentRepoStore.repoStore.writeTree(
        principal,
        repoId,
        DEFAULT_ASSET_REF,
        {
          files,
          message: `cross-process custom-adapter test: write workflow repo ${args.workflowRepoId}`,
        },
      );
    },
  };

  const orchestrator = createWorkflowDeployOrchestrator({
    directorRegistry: createDefaultDirectorRegistry(),
    workflowRepo,
    launchSession,
    sendMultiStepDeploy,
    deploySingleStepAtHead,
  });

  const result = await orchestrator.deployWorkflow({
    workflow,
    config,
    deployContent: { systemPrompt: config.systemPrompt },
    operatorApprovals,
    deploymentId,
    deploymentDomain: DEPLOYMENT_DOMAIN,
    hubPublicKey: "00".repeat(32),
  });
  expect(result.publicKey).toBeTruthy();

  const workflowRunRepoId: RepoId = {
    kind: "workflow-run",
    id: deriveDeploymentId(deploymentMailAddress),
  };
  env.registerDeployment({
    deploymentId,
    workflowDefinition: workflow,
    workflowRunRepoId,
    workflowRunRef: "refs/heads/main",
    mailAddress: deploymentMailAddress,
  });

  return { deploymentMailAddress, workflowRunRepoId };
}

describe("cross-process custom inference adapter (INTR-233)", () => {
  test("sidecar registers with hub", () => {
    expect(env.hub.router.getConnectedSidecars()).toContain(SIDECAR_ID);
  });

  test("a manifest custom adapter resolves in the forked child", async () => {
    const deploymentId = "cross-process-custom-positive";
    const body = "Cross-process custom adapter body sentinel-7731.";
    const { deploymentMailAddress, workflowRunRepoId } =
      await deployCustomProviderWorkflow(deploymentId, CUSTOM_PROVIDER);

    const mail = await fireMailTrigger(env, deploymentMailAddress, {
      messageId: "<cross-process-custom-positive@integration.interchange>",
      content: body,
    });

    const runId = await waitForFirstRunId(env, workflowRunRepoId, {
      diagnostics: env.sidecarDiagnostics,
      timeoutMs: 20_000,
    });

    const terminal = await waitForWorkflowRunComplete(
      env,
      deploymentId,
      runId,
      { timeoutMs: 20_000, diagnostics: env.sidecarDiagnostics },
    );
    if (terminal.type !== "RunCompleted") {
      const events = await readWorkflowRunEvents(env, deploymentId, runId);
      const failed = events.find(
        (e) => e.type === "StepFailed" || e.type === "RunFailed",
      );
      throw new Error(
        `expected RunCompleted for the custom-adapter run, got ${terminal.type}: ${JSON.stringify(failed?.body)}\n${env.sidecarDiagnostics()}`,
      );
    }

    const events = await readWorkflowRunEvents(env, deploymentId, runId);
    const startedBody = events.find((e) => e.type === "RunStarted")?.body;
    if (startedBody === undefined) throw new Error("missing RunStarted");
    expect(startedBody["consumedMessageId"]).toBe(mail.messageId);

    // The echoed reply carries the inbound body, proving the custom adapter
    // ran a full inference round-trip in the child (request built, mock SSE
    // parsed) -- not merely that resolution did not throw.
    const reply = readStepReply(stepCompletedBody(events));
    expect(reply.startsWith("echo:")).toBe(true);
    expect(reply).toContain(body);
  });

  test("a provider absent from the manifest is rejected at the source gate", async () => {
    // The firewall: the operator registry holds only the built-ins plus the
    // manifest's "custom-x". A provider id that no manifest entry and no
    // built-in supplies is rejected by the sidecar deploy router's
    // source-admission gate (which reuses `canBuildSource` against that same
    // registry) before the workflow-process child is spawned -- so a
    // provider string (which deploy/tenant config controls) cannot conjure
    // an adapter, and the deploy is rejected synchronously at deploy time
    // rather than failing the first run.
    await expect(
      deployCustomProviderWorkflow(
        "cross-process-custom-negative",
        ABSENT_PROVIDER,
      ),
    ).rejects.toThrow(new RegExp(`${ABSENT_PROVIDER}.*not registered`));
  });
});

/** Find the single step's `StepCompleted` event body. */
function stepCompletedBody(
  events: { type: string; body: Record<string, unknown> }[],
): Record<string, unknown> {
  const stepCompleted = events.find(
    (e) => e.type === "StepCompleted" && e.body["stepId"] === STEP_ID,
  );
  if (stepCompleted === undefined) {
    throw new Error("missing StepCompleted for the single step");
  }
  return stepCompleted.body;
}

/**
 * Extract the agent's reply string from a `StepCompleted` event body. A small
 * `{ reply, turn }` step output inlines as `inline:<json>`.
 */
function readStepReply(body: Record<string, unknown>): string {
  const output = body["output"];
  if (typeof output !== "object" || output === null || !("ref" in output)) {
    throw new Error(
      `StepCompleted output is not a { ref } record: ${JSON.stringify(output)}`,
    );
  }
  const ref: unknown = output.ref;
  if (typeof ref !== "string") {
    throw new Error(`StepCompleted output ref is not a string: ${String(ref)}`);
  }
  const INLINE_PREFIX = "inline:";
  if (!ref.startsWith(INLINE_PREFIX)) {
    throw new Error(
      `expected an inline output ref for the small step output, got ${ref}`,
    );
  }
  const parsed: unknown = JSON.parse(ref.slice(INLINE_PREFIX.length));
  if (typeof parsed !== "object" || parsed === null || !("reply" in parsed)) {
    throw new Error(
      `step output does not carry a reply field: ${JSON.stringify(parsed)}`,
    );
  }
  const reply: unknown = parsed.reply;
  if (typeof reply !== "string") {
    throw new Error(
      `step output reply is not a string: ${JSON.stringify(parsed)}`,
    );
  }
  return reply;
}
