import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { sql } from "drizzle-orm";
import { createPgliteClient } from "@platform-modules/db/postgres/pglite";
import { describe, expect, it } from "vitest";
import {
  ConfigurationAuthorityService,
  I1LifecycleService,
  I2PlanningService,
  PlanningAgentService,
  type PlanningModelInvocationRequest,
  type PlanningModelProvider,
} from "@awp/application";
import { I2_CONFIGURATION_KEYS } from "@awp/config";
import {
  authorityContext,
  unsafeOpaqueId,
  type AwpId,
  type CorrelationId,
  type MutationContext,
  type OperationId,
  type PrincipalId,
  type ProjectId,
} from "@awp/contracts";
import { PostgresUnitOfWork, schema } from "@awp/persistence";

const migrationDirectory = fileURLToPath(
  new URL("../../packages/persistence/drizzle/", import.meta.url),
);

async function database() {
  const db = createPgliteClient({ schema });
  for (const name of readdirSync(migrationDirectory)
    .filter((name) => name.endsWith(".sql"))
    .sort()) {
    const migration = readFileSync(`${migrationDirectory}/${name}`, "utf8");
    for (const statement of migration.split("--> statement-breakpoint")) {
      const sqlText = statement.trim();
      if (sqlText) await db.execute(sql.raw(sqlText));
    }
  }
  return db;
}

function ids() {
  let sequence = 0;
  return {
    next<T extends AwpId>(): T {
      sequence += 1;
      return unsafeOpaqueId<T>(`planner-test-${sequence}`);
    },
  };
}

const clock = { now: () => new Date("2026-08-24T16:30:00.000Z") };

function context(projectId?: ProjectId): MutationContext {
  const principal = {
    id: unsafeOpaqueId<PrincipalId>("principal:planner-owner"),
    kind: "human" as const,
    capabilities: [],
  };
  return {
    operationId: unsafeOpaqueId<OperationId>(`operation:planner:${projectId ?? "system"}`),
    correlationId: unsafeOpaqueId<CorrelationId>(`correlation:planner:${projectId ?? "system"}`),
    idempotencyKey: `planner:${projectId ?? "system"}`,
    authority: authorityContext(principal, [], projectId),
  };
}

class FakePlanningModel implements PlanningModelProvider {
  readonly requests: PlanningModelInvocationRequest[] = [];
  output = JSON.stringify({
    reply: "The outcome is clear. Next I want to reconcile it with the durable ProjectVision.",
    disposition: {
      kind: "resolve",
      itemKey: "intent",
      answer: "Ship the real model-backed Planner interview",
      nextItemKey: "vision-alignment",
    },
  });
  beforeReturn?: (request: PlanningModelInvocationRequest) => Promise<void>;

  supportsProvider(providerId: string) {
    return providerId === "codex";
  }

  async listModels() {
    return [
      {
        model: "gpt-5.6-luna",
        displayName: "GPT-5.6-Luna",
        reasoningEfforts: [{ effort: "high" }, { effort: "medium" }, { effort: "xhigh" }],
      },
      {
        model: "planner-model",
        displayName: "Planner model",
        reasoningEfforts: [{ effort: "high" }],
      },
      {
        model: "planner-project-model",
        displayName: "Project Planner model",
        reasoningEfforts: [{ effort: "medium" }],
      },
      {
        model: "planner-session-model",
        displayName: "Session Planner model",
        reasoningEfforts: [{ effort: "xhigh" }],
      },
    ];
  }

  async invoke(request: PlanningModelInvocationRequest) {
    this.requests.push(request);
    await this.beforeReturn?.(request);
    return {
      providerId: "provider:model-gateway",
      outputText: this.output,
      invocationId: `response-${this.requests.length}`,
    };
  }
}

describe("I2 model-backed Planner Agent", () => {
  it("routes through configured account/model, persists conversation provenance, and survives restart", async () => {
    const db = await database();
    const uow = new PostgresUnitOfWork(db);
    const generator = ids();
    const lifecycle = new I1LifecycleService(uow, generator, clock);
    const planning = new I2PlanningService(uow, generator, clock, lifecycle);
    const configuration = new ConfigurationAuthorityService(uow, generator, clock);
    await configuration.ensureDefinitions();
    await configuration.setOverride({
      definitionKey: I2_CONFIGURATION_KEYS.planningInterviewerProviderId,
      scopeType: "system",
      scopeId: "system",
      value: "codex",
      context: context(),
    });
    await configuration.setOverride({
      definitionKey: I2_CONFIGURATION_KEYS.planningInterviewerAccountId,
      scopeType: "system",
      scopeId: "system",
      value: "account:planner-system",
      context: context(),
    });
    await configuration.setOverride({
      definitionKey: I2_CONFIGURATION_KEYS.planningInterviewerModel,
      scopeType: "system",
      scopeId: "system",
      value: "gpt-5.6-luna",
      context: context(),
    });
    await configuration.setOverride({
      definitionKey: I2_CONFIGURATION_KEYS.planningInterviewerReasoningEffort,
      scopeType: "system",
      scopeId: "system",
      value: "high",
      context: context(),
    });

    const onboarded = await planning.onboardProject({
      name: "Planner Recovery",
      repositoryUrl: "https://github.com/platform-modules/awp.git",
      intent: "Restore the real model-backed interactive Planner",
      mode: "expert",
      context: context(),
    });
    const ownerContext = context(onboarded.projectId);
    const provider = new FakePlanningModel();
    const agent = new PlanningAgentService(planning, configuration, provider, generator);

    const first = await agent.turn({
      sessionId: onboarded.session.id,
      message: "Yes. Slice 2 must include a real interviewer, not a deterministic form.",
      context: ownerContext,
    });

    expect(provider.requests).toHaveLength(1);
    expect(provider.requests[0]).toMatchObject({
      sessionId: onboarded.session.id,
      accountId: "account:planner-system",
      model: "gpt-5.6-luna",
      reasoningEffort: "high",
    });
    expect(provider.requests[0]?.prompt).toContain("You are the AWP Planner Agent");
    expect(provider.requests[0]?.prompt).toContain("Expert mode");
    expect(first.selection).toEqual({
      providerId: "codex",
      accountId: "account:planner-system",
      model: "gpt-5.6-luna",
      reasoningEffort: "high",
      source: "system",
    });
    expect(first.session.items.find((item) => item.key === "intent")).toMatchObject({
      status: "accepted",
      answer: "Ship the real model-backed Planner interview",
    });
    expect(first.session.activeItemKey).toBe("vision-alignment");
    expect(first.session.turns).toHaveLength(2);
    expect(first.session.turns[0]).toMatchObject({
      role: "owner",
      status: "completed",
      itemKey: "intent",
      inputRevision: onboarded.session.revision,
    });
    expect(first.session.turns[1]).toMatchObject({
      role: "planner",
      status: "completed",
      providerId: "codex",
      accountId: "account:planner-system",
      model: "gpt-5.6-luna",
      reasoningEffort: "high",
      invocationId: "response-1",
      disposition: "resolved",
      itemKey: "intent",
      inputRevision: onboarded.session.revision,
    });

    await configuration.setOverride({
      definitionKey: I2_CONFIGURATION_KEYS.planningInterviewerProviderId,
      scopeType: "project",
      scopeId: String(onboarded.projectId),
      value: "claude",
      context: ownerContext,
    });
    await configuration.setOverride({
      definitionKey: I2_CONFIGURATION_KEYS.planningInterviewerAccountId,
      scopeType: "project",
      scopeId: String(onboarded.projectId),
      value: "account:planner-project",
      context: ownerContext,
    });
    await configuration.setOverride({
      definitionKey: I2_CONFIGURATION_KEYS.planningInterviewerModel,
      scopeType: "project",
      scopeId: String(onboarded.projectId),
      value: "planner-project-model",
      context: ownerContext,
    });
    await configuration.setOverride({
      definitionKey: I2_CONFIGURATION_KEYS.planningInterviewerReasoningEffort,
      scopeType: "project",
      scopeId: String(onboarded.projectId),
      value: "medium",
      context: ownerContext,
    });
    expect(await agent.effectiveSelection(first.session)).toEqual({
      providerId: "claude",
      accountId: "account:planner-project",
      model: "planner-project-model",
      reasoningEffort: "medium",
      source: "project",
    });
    await expect(
      agent.turn({
        sessionId: first.session.id,
        message: "This must not route through a provider the active gateway does not support",
        context: ownerContext,
      }),
    ).rejects.toThrow(/provider claude is not supported/u);

    const overridden = await planning.setPlannerOverride(
      first.session.id,
      {
        providerId: "codex",
        accountId: "account:planner-session",
        model: "planner-session-model",
        reasoningEffort: "xhigh",
      },
      ownerContext,
    );
    expect(await agent.effectiveSelection(overridden)).toEqual({
      providerId: "codex",
      accountId: "account:planner-session",
      model: "planner-session-model",
      reasoningEffort: "xhigh",
      source: "session",
    });

    const restartedUow = new PostgresUnitOfWork(db);
    const restartedPlanning = new I2PlanningService(
      restartedUow,
      generator,
      clock,
      new I1LifecycleService(restartedUow, generator, clock),
    );
    const restored = await restartedPlanning.get(first.session.id);
    expect(restored?.turns).toEqual(first.session.turns);
    expect(restored?.plannerOverride).toEqual({
      providerId: "codex",
      accountId: "account:planner-session",
      model: "planner-session-model",
      reasoningEffort: "xhigh",
    });

    await db.$client.close();
  }, 20_000);

  it("records malformed model output as a retryable failed turn without mutating the active item", async () => {
    const db = await database();
    const uow = new PostgresUnitOfWork(db);
    const generator = ids();
    const lifecycle = new I1LifecycleService(uow, generator, clock);
    const planning = new I2PlanningService(uow, generator, clock, lifecycle);
    const onboarded = await planning.onboardProject({
      name: "Malformed Planner",
      repositoryUrl: "https://github.com/platform-modules/awp.git",
      intent: "Keep authoritative Planning safe from malformed model output",
      context: context(),
    });
    const provider = new FakePlanningModel();
    provider.output = "not-json";
    const agent = new PlanningAgentService(
      planning,
      {
        async resolveProjectPlanningInterviewer() {
          return {
            providerId: "codex",
            accountId: "account:planner",
            model: "planner-model",
            reasoningEffort: "high",
            providerSource: { scopeType: "project", scopeId: String(onboarded.projectId) },
            accountSource: { scopeType: "project", scopeId: String(onboarded.projectId) },
            modelSource: { scopeType: "project", scopeId: String(onboarded.projectId) },
            reasoningEffortSource: { scopeType: "project", scopeId: String(onboarded.projectId) },
          };
        },
      },
      provider,
      generator,
    );

    const result = await agent.turn({
      sessionId: onboarded.session.id,
      message: "Continue planning",
      context: context(onboarded.projectId),
    });
    expect(result.session.activeItemKey).toBe(onboarded.session.activeItemKey);
    expect(result.session.items).toEqual(onboarded.session.items);
    expect(result.session.turns.at(-1)).toMatchObject({
      role: "planner",
      status: "failed",
      failureCategory: "malformed-output",
      retryable: true,
      providerId: "codex",
      accountId: "account:planner",
      model: "planner-model",
      reasoningEffort: "high",
    });
    await db.$client.close();
  }, 20_000);

  it("records provider failure as a retryable failed turn without losing structured Planning state", async () => {
    const db = await database();
    const uow = new PostgresUnitOfWork(db);
    const generator = ids();
    const lifecycle = new I1LifecycleService(uow, generator, clock);
    const planning = new I2PlanningService(uow, generator, clock, lifecycle);
    const onboarded = await planning.onboardProject({
      name: "Unavailable Planner",
      repositoryUrl: "https://github.com/platform-modules/awp.git",
      intent: "Keep Planning durable when the model provider is unavailable",
      context: context(),
    });
    const provider: PlanningModelProvider = {
      supportsProvider(providerId) {
        return providerId === "codex";
      },
      async listModels() {
        return [
          {
            model: "planner-model",
            displayName: "Planner model",
            reasoningEfforts: [{ effort: "high" }],
          },
        ];
      },
      async invoke() {
        const error = new Error("gateway unavailable") as Error & {
          category: string;
          retryable: boolean;
          safeMessage: string;
        };
        error.category = "unavailable";
        error.retryable = true;
        error.safeMessage = "Planner model gateway is unavailable";
        throw error;
      },
    };
    const agent = new PlanningAgentService(
      planning,
      {
        async resolveProjectPlanningInterviewer() {
          return {
            providerId: "codex",
            accountId: "account:planner",
            model: "planner-model",
            reasoningEffort: "high",
            providerSource: { scopeType: "project", scopeId: String(onboarded.projectId) },
            accountSource: { scopeType: "project", scopeId: String(onboarded.projectId) },
            modelSource: { scopeType: "project", scopeId: String(onboarded.projectId) },
            reasoningEffortSource: { scopeType: "project", scopeId: String(onboarded.projectId) },
          };
        },
      },
      provider,
      generator,
    );

    const result = await agent.turn({
      sessionId: onboarded.session.id,
      message: "Continue despite a temporary provider failure",
      context: context(onboarded.projectId),
    });
    expect(result.session.activeItemKey).toBe(onboarded.session.activeItemKey);
    expect(result.session.items).toEqual(onboarded.session.items);
    expect(result.session.turns.at(-1)).toMatchObject({
      role: "planner",
      status: "failed",
      content: "Planner model gateway is unavailable",
      failureCategory: "unavailable",
      retryable: true,
      providerId: "codex",
      accountId: "account:planner",
      model: "planner-model",
      reasoningEffort: "high",
    });
    await db.$client.close();
  }, 20_000);

  it("rejects a model result reasoned over a stale PlanningSession revision", async () => {
    const db = await database();
    const uow = new PostgresUnitOfWork(db);
    const generator = ids();
    const lifecycle = new I1LifecycleService(uow, generator, clock);
    const planning = new I2PlanningService(uow, generator, clock, lifecycle);
    const onboarded = await planning.onboardProject({
      name: "Stale Planner",
      repositoryUrl: "https://github.com/platform-modules/awp.git",
      intent: "Reject stale Planner mutations",
      mode: "simple",
      context: context(),
    });
    const ownerContext = context(onboarded.projectId);
    const provider = new FakePlanningModel();
    provider.beforeReturn = async () => {
      await planning.setMode(onboarded.session.id, "expert", ownerContext);
    };
    const agent = new PlanningAgentService(
      planning,
      {
        async resolveProjectPlanningInterviewer() {
          return {
            providerId: "codex",
            accountId: "account:planner",
            model: "planner-model",
            reasoningEffort: "high",
            providerSource: { scopeType: "system", scopeId: "system" },
            accountSource: { scopeType: "system", scopeId: "system" },
            modelSource: { scopeType: "system", scopeId: "system" },
            reasoningEffortSource: { scopeType: "system", scopeId: "system" },
          };
        },
      },
      provider,
      generator,
    );

    await expect(
      agent.turn({
        sessionId: onboarded.session.id,
        message: "Resolve this against stale state",
        context: ownerContext,
      }),
    ).rejects.toThrow(/revision changed during Planner turn/u);
    const current = await planning.get(onboarded.session.id);
    expect(current?.mode).toBe("expert");
    expect(current?.turns).toHaveLength(0);
    expect(current?.items.find((item) => item.key === "intent")?.status).toBe("active");
    await db.$client.close();
  }, 20_000);
});
