import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { craft, simple, type Exchange } from "@routecraft/routecraft";
import { testContext, spy, type TestContext } from "@routecraft/testing";
import {
  agent,
  AgentDestinationAdapter,
  agentPlugin,
  llmPlugin,
  type AgentRegisteredOptions,
  type AgentResult,
} from "../src/index.ts";
import type { LlmResult } from "../src/llm/types.ts";

// Mock the LLM dispatcher so tests do not hit any provider network. Each
// happy-path test asserts on the call args and controls the response shape.
mock.module("../src/llm/providers/index.ts", () => ({
  callLlm: mock(
    async (): Promise<LlmResult> => ({
      text: "stubbed-response",
      usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
    }),
  ),
}));

import { callLlm } from "../src/llm/providers/index.ts";
const callLlmMock = callLlm as unknown as ReturnType<typeof mock>;

describe("agent() destination", () => {
  let t: TestContext | undefined;

  beforeEach(() => {
    callLlmMock.mockClear();
  });

  afterEach(async () => {
    if (t) await t.stop();
    t = undefined;
  });

  /**
   * @case agent({...}) returns an AgentDestinationAdapter with the expected adapterId
   * @preconditions Valid options
   * @expectedResult Returned destination is an AgentDestinationAdapter instance with adapterId "routecraft.adapter.agent"
   */
  test("returns AgentDestinationAdapter with stable adapterId", () => {
    const dest = agent({
      model: "anthropic:claude-opus-4-7",
      system: "You answer concisely.",
    });
    expect(dest).toBeInstanceOf(AgentDestinationAdapter);
    expect(dest.adapterId).toBe("routecraft.adapter.agent");
  });

  /**
   * @case agent() throws when system prompt is missing or blank
   * @preconditions Options without system, or with whitespace system
   * @expectedResult Construction throws and the message mentions "system"
   */
  test("throws when system is missing or blank", () => {
    expect(() =>
      agent({
        model: "anthropic:claude-opus-4-7",
      } as unknown as Parameters<typeof agent>[0]),
    ).toThrow(/system/i);
    expect(() =>
      agent({
        model: "anthropic:claude-opus-4-7",
        system: "   ",
      }),
    ).toThrow(/system/i);
  });

  /**
   * @case agent() throws when the model string is malformed
   * @preconditions Model string without a colon, or with empty halves
   * @expectedResult Construction throws and the message mentions "providerId:modelName"
   */
  test("throws on malformed model string", () => {
    const base = { system: "y" };
    expect(() => agent({ ...base, model: "no-colon-here" })).toThrow(
      /providerId:modelName/,
    );
    expect(() => agent({ ...base, model: "trailing-colon:" })).toThrow(
      /providerId:modelName/,
    );
    expect(() => agent({ ...base, model: ":no-prefix" })).toThrow(
      /providerId:modelName/,
    );
  });

  /**
   * @case agent() accepts model as an optional field at construction
   * @preconditions agent({ system }) -- no model, no defaults known yet
   * @expectedResult Construction succeeds; resolution deferred to dispatch
   */
  test("accepts agent without model at construction", () => {
    const dest = agent({ system: "ok" });
    expect(dest).toBeInstanceOf(AgentDestinationAdapter);
  });

  /**
   * @case agent({ output }) accepts a Standard Schema and validates its shape at construction
   * @preconditions agent with a Zod object output and a non-Standard-Schema output
   * @expectedResult Valid schema constructs; non-Standard value throws synchronously
   */
  test("agent({ output }) validates the schema shape at construction", async () => {
    const { z } = await import("zod");
    const schema = z.object({ ok: z.boolean() });
    const dest = agent({
      model: "anthropic:claude-opus-4-7",
      system: "Be helpful.",
      output: schema,
    });
    expect(dest).toBeInstanceOf(AgentDestinationAdapter);

    expect(() =>
      agent({
        model: "anthropic:claude-opus-4-7",
        system: "Be helpful.",
        output: { not: "a schema" } as never,
      }),
    ).toThrow(/Standard Schema/);

    // Null and non-object values must surface RC5003, not a raw TypeError
    // from `(null)["~standard"]`.
    expect(() =>
      agent({
        model: "anthropic:claude-opus-4-7",
        system: "Be helpful.",
        output: null as never,
      }),
    ).toThrow(/Standard Schema/);
    expect(() =>
      agent({
        model: "anthropic:claude-opus-4-7",
        system: "Be helpful.",
        output: "not-an-object" as never,
      }),
    ).toThrow(/Standard Schema/);
  });

  /**
   * @case agent({ principal }) accepts a boolean or a function and rejects other values at construction
   * @preconditions agent with principal set to true, a function, and a string
   * @expectedResult The boolean and function forms construct; the string throws RC5003 mentioning "principal"
   */
  test("agent({ principal }) must be a boolean or a function", () => {
    const boolForm = agent({
      model: "anthropic:claude-opus-4-7",
      system: "Be helpful.",
      principal: true,
    });
    expect(boolForm).toBeInstanceOf(AgentDestinationAdapter);

    const fnForm = agent({
      model: "anthropic:claude-opus-4-7",
      system: "Be helpful.",
      principal: (p) => (p ? `## Caller\n\n${p.subject}` : ""),
    });
    expect(fnForm).toBeInstanceOf(AgentDestinationAdapter);

    expect(() =>
      agent({
        model: "anthropic:claude-opus-4-7",
        system: "Be helpful.",
        principal: "yes" as never,
      }),
    ).toThrow(/principal/i);
  });

  /**
   * @case end-to-end: route with agent calls callLlm with system + body-derived user prompt
   * @preconditions Route from simple body, .to(agent({...})), .to(spy)
   * @expectedResult callLlm is called once with the configured system and the body as user prompt; downstream body is AgentResult
   */
  test("end-to-end: dispatches callLlm and replaces body with AgentResult", async () => {
    const sink = spy();
    t = await testContext()
      .with({
        plugins: [
          llmPlugin({ providers: { anthropic: { apiKey: "sk-test" } } }),
        ],
      })
      .routes(
        craft()
          .id("e2e-agent")
          .description("Echoes input via a stubbed agent")
          .from(simple("hello world"))
          .to(
            agent({
              model: "anthropic:claude-opus-4-7",
              system: "Be helpful.",
            }),
          )
          .to(sink),
      )
      .build();

    await t.test();

    expect(callLlmMock).toHaveBeenCalledTimes(1);
    const args = callLlmMock.mock.calls[0][0];
    expect(args.system).toBe("Be helpful.");
    expect(args.user).toBe("hello world");
    expect(args.modelId).toBe("claude-opus-4-7");

    expect(sink.received).toHaveLength(1);
    const body = sink.received[0].body as AgentResult;
    expect(body.text).toBe("stubbed-response");
    expect(body.usage).toEqual({
      inputTokens: 10,
      outputTokens: 5,
      totalTokens: 15,
    });
  });

  /**
   * @case Agent inherits defaultOptions.model when its own model is omitted
   * @preconditions agentPlugin({ defaultOptions: { model: "..." } }) and inline agent({ system }) without model
   * @expectedResult callLlm is invoked with the default model
   */
  test("agent inherits defaultOptions.model when omitted", async () => {
    const sink = spy();
    t = await testContext()
      .with({
        plugins: [
          llmPlugin({ providers: { anthropic: { apiKey: "sk-test" } } }),
          agentPlugin({
            defaultOptions: { model: "anthropic:claude-opus-4-7" },
          }),
        ],
      })
      .routes(
        craft()
          .id("default-model")
          .from(simple("hello"))
          .to(agent({ system: "Be helpful." }))
          .to(sink),
      )
      .build();

    await t.test();
    expect(callLlmMock).toHaveBeenCalledTimes(1);
    expect(callLlmMock.mock.calls[0][0].modelId).toBe("claude-opus-4-7");
  });

  /**
   * @case Agent dispatch throws when no model is available at all
   * @preconditions Inline agent without model; no defaultOptions.model on the plugin
   * @expectedResult Dispatch throws RC5003 with a clear actionable message
   */
  test("agent dispatch throws when neither instance nor default supplies a model", async () => {
    t = await testContext()
      .with({
        plugins: [
          llmPlugin({ providers: { anthropic: { apiKey: "sk-test" } } }),
        ],
      })
      .routes(
        craft()
          .id("no-model")
          .from(simple("hello"))
          .to(agent({ system: "Be helpful." })),
      )
      .build();

    await t.test();
    expect(t.errors[0]?.message).toMatch(/no "model"/i);
  });

  /**
   * @case Per-agent model wins over defaultOptions.model
   * @preconditions defaultOptions.model = anthropic; agent.model = openai
   * @expectedResult callLlm is invoked with the agent's openai model id
   */
  test("per-agent model overrides defaultOptions.model", async () => {
    const sink = spy();
    t = await testContext()
      .with({
        plugins: [
          llmPlugin({
            providers: {
              anthropic: { apiKey: "sk-test" },
              openai: { apiKey: "sk-openai" },
            },
          }),
          agentPlugin({
            defaultOptions: { model: "anthropic:claude-opus-4-7" },
          }),
        ],
      })
      .routes(
        craft()
          .id("override-model")
          .from(simple("hi"))
          .to(agent({ model: "openai:gpt-4o", system: "Be helpful." }))
          .to(sink),
      )
      .build();

    await t.test();
    expect(callLlmMock.mock.calls[0][0].modelId).toBe("gpt-4o");
  });

  /**
   * @case body objects are JSON-stringified into the user prompt by default
   * @preconditions Route from simple({ q: "..." }), .to(agent({...}))
   * @expectedResult callLlm receives the JSON form of the body as user prompt
   */
  test("default user prompt JSON-stringifies object bodies", async () => {
    t = await testContext()
      .with({
        plugins: [
          llmPlugin({ providers: { anthropic: { apiKey: "sk-test" } } }),
        ],
      })
      .routes(
        craft()
          .id("json-body")
          .from(simple({ q: "what?" }))
          .to(
            agent({
              model: "anthropic:claude-opus-4-7",
              system: "s",
            }),
          ),
      )
      .build();

    await t.test();

    expect(callLlmMock).toHaveBeenCalledTimes(1);
    expect(callLlmMock.mock.calls[0][0].user).toBe('{"q":"what?"}');
  });

  /**
   * @case custom user override is invoked instead of the default body derivation
   * @preconditions agent({ ..., user: (ex) => "custom prompt" })
   * @expectedResult callLlm receives the value returned by the user function
   */
  test("custom user override is honoured", async () => {
    t = await testContext()
      .with({
        plugins: [
          llmPlugin({ providers: { anthropic: { apiKey: "sk-test" } } }),
        ],
      })
      .routes(
        craft()
          .id("user-override")
          .from(simple({ name: "alice" }))
          .to(
            agent({
              model: "anthropic:claude-opus-4-7",
              system: "s",
              user: (ex: Exchange<unknown>) =>
                `Greet ${(ex.body as { name: string }).name}`,
            }),
          ),
      )
      .build();

    await t.test();

    expect(callLlmMock.mock.calls[0][0].user).toBe("Greet alice");
  });
});

describe("agent(name) by-name destination + agentPlugin", () => {
  let t: TestContext | undefined;

  beforeEach(() => {
    callLlmMock.mockClear();
  });

  afterEach(async () => {
    if (t) await t.stop();
    t = undefined;
  });

  /**
   * @case agentPlugin rejects an entry missing a description
   * @preconditions Registered agent with no description
   * @expectedResult build() rejects with a message mentioning "description"
   */
  test("agentPlugin throws when a registered agent has no description", async () => {
    await expect(
      testContext()
        .with({
          plugins: [
            agentPlugin({
              agents: {
                // description intentionally omitted to exercise validation
                summariser: {
                  model: "anthropic:claude-opus-4-7",
                  system: "y",
                } as unknown as AgentRegisteredOptions,
              },
            }),
          ],
        })
        .routes(craft().id("noop").from(simple("x")))
        .build(),
    ).rejects.toThrow(/description/i);
  });

  /**
   * @case agentPlugin throws on an empty agent id key
   * @preconditions An agents record with a blank-string key
   * @expectedResult build() rejects with a message about the agent id
   */
  test("agentPlugin throws on empty id key", async () => {
    await expect(
      testContext()
        .with({
          plugins: [
            agentPlugin({
              agents: {
                "  ": {
                  description: "x",
                  model: "anthropic:claude-opus-4-7",
                  system: "y",
                },
              },
            }),
          ],
        })
        .routes(craft().id("noop").from(simple("x")))
        .build(),
    ).rejects.toThrow(/id/i);
  });

  /**
   * @case agentPlugin merges across installs and throws on cross-install duplicate ids
   * @preconditions Two agentPlugin instances register the same id
   * @expectedResult build() rejects naming the duplicate
   */
  test("agentPlugin throws on duplicate id across installs", async () => {
    await expect(
      testContext()
        .with({
          plugins: [
            agentPlugin({
              agents: {
                dup: {
                  description: "first",
                  model: "anthropic:claude-opus-4-7",
                  system: "s",
                },
              },
            }),
            agentPlugin({
              agents: {
                dup: {
                  description: "second",
                  model: "anthropic:claude-opus-4-7",
                  system: "s",
                },
              },
            }),
          ],
        })
        .routes(craft().id("noop").from(simple("x")))
        .build(),
    ).rejects.toThrow(/dup/);
  });

  /**
   * @case agent(name) without any registered agents throws RC5004 at dispatch
   * @preconditions Route uses .to(agent("missing")), no agentPlugin installed
   * @expectedResult Sink is never reached; the framework emits context:error with rc === "RC5004"
   */
  test("agent(name) without registry throws RC5004 at dispatch", async () => {
    const sink = spy();
    const errors: Array<{ rc?: string; message?: string }> = [];
    t = await testContext()
      .with({
        plugins: [
          llmPlugin({ providers: { anthropic: { apiKey: "sk-test" } } }),
        ],
      })
      .on("context:error", (event) => {
        const { error } = (event as { details: { error: unknown } }).details;
        errors.push(error as { rc?: string; message?: string });
      })
      .routes(
        craft()
          .id("missing-registry")
          .from(simple("hi"))
          .to(agent("missing"))
          .to(sink),
      )
      .build();

    await t.test();
    expect(sink.received).toHaveLength(0);
    expect(errors.length).toBeGreaterThan(0);
    expect(errors[0].rc).toBe("RC5004");
    expect(String(errors[0].message)).toMatch(/missing/);
  });

  /**
   * @case agent(name) resolves a registered agent and dispatches its system prompt
   * @preconditions agentPlugin registers "summariser"; route .to(agent("summariser"))
   * @expectedResult callLlm is called with the registered system prompt and the body as user prompt
   */
  test("agent(name) resolves a registered agent and dispatches it", async () => {
    const sink = spy();
    t = await testContext()
      .with({
        plugins: [
          llmPlugin({ providers: { anthropic: { apiKey: "sk-test" } } }),
          agentPlugin({
            agents: {
              summariser: {
                description: "Summarises text",
                model: "anthropic:claude-opus-4-7",
                system: "Summarise the input.",
              },
            },
          }),
        ],
      })
      .routes(
        craft()
          .id("by-name")
          .from(simple("a long document"))
          .to(agent("summariser"))
          .to(sink),
      )
      .build();

    await t.test();

    expect(callLlmMock).toHaveBeenCalledTimes(1);
    expect(callLlmMock.mock.calls[0][0].system).toBe("Summarise the input.");
    expect(callLlmMock.mock.calls[0][0].user).toBe("a long document");
    expect(sink.received).toHaveLength(1);
    expect((sink.received[0].body as AgentResult).text).toBe(
      "stubbed-response",
    );
  });
});
