import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConfigService } from "@nestjs/config";
import type { PrismaService } from "../prisma/prisma.service";
import type { CryptoService } from "../crypto/crypto.service";

interface MockUserContainerRecord {
  id: string;
  userId: string;
  containerId: string | null;
  containerName: string;
  gatewayPort: number | null;
  gatewayToken: string;
  status: string;
  lastActiveAt: Date | null;
  idleTimeoutMin: number;
  config: Record<string, unknown>;
  createdAt: Date;
  updatedAt: Date;
}

const dockerMock = vi.hoisted(() => {
  interface MockDockerContainerState {
    id: string;
    name: string;
    running: boolean;
    port: number;
  }

  const containers = new Map<string, MockDockerContainerState>();
  const handles = new Map<
    string,
    {
      inspect: ReturnType<typeof vi.fn>;
      start: ReturnType<typeof vi.fn>;
      stop: ReturnType<typeof vi.fn>;
    }
  >();

  const ensureHandle = (id: string) => {
    const existing = handles.get(id);
    if (existing) {
      return existing;
    }

    const handle = {
      inspect: vi.fn(async () => {
        const container = containers.get(id);
        if (!container) {
          throw { statusCode: 404 };
        }

        return {
          Id: container.id,
          State: {
            Running: container.running,
          },
          NetworkSettings: {
            Ports: {
              "18789/tcp": [{ HostPort: String(container.port) }],
            },
          },
        };
      }),
      start: vi.fn(async () => {
        const container = containers.get(id);
        if (!container) {
          throw { statusCode: 404 };
        }
        container.running = true;
      }),
      stop: vi.fn(async () => {
        const container = containers.get(id);
        if (!container) {
          throw { statusCode: 404 };
        }
        container.running = false;
      }),
    };

    handles.set(id, handle);
    return handle;
  };

  const listContainers = vi.fn(
    async (options?: { all?: boolean; filters?: { name?: string[] } }) => {
      const nameFilter = options?.filters?.name?.[0];
      return [...containers.values()]
        .filter((container) => (nameFilter ? container.name.includes(nameFilter) : true))
        .map((container) => ({
          Id: container.id,
          Names: [`/${container.name}`],
        }));
    }
  );

  const getContainer = vi.fn((id: string) => ensureHandle(id));

  const createContainer = vi.fn(
    async (options: {
      name?: string;
      HostConfig?: { PortBindings?: Record<string, Array<{ HostPort?: string }>> };
    }) => {
      const id = `ctr-${containers.size + 1}`;
      const name = options.name ?? id;
      const hostPort = options.HostConfig?.PortBindings?.["18789/tcp"]?.[0]?.HostPort;
      const port = hostPort ? Number.parseInt(hostPort, 10) : 0;

      containers.set(id, {
        id,
        name,
        running: false,
        port,
      });

      return ensureHandle(id);
    }
  );

  const dockerInstance = {
    listContainers,
    getContainer,
    createContainer,
  };

  const constructorSpy = vi.fn();
  class DockerConstructorMock {
    constructor(options?: unknown) {
      constructorSpy(options);
      return dockerInstance;
    }
  }

  const registerContainer = (container: MockDockerContainerState) => {
    containers.set(container.id, { ...container });
    ensureHandle(container.id);
  };

  const reset = () => {
    containers.clear();
    handles.clear();
    constructorSpy.mockClear();
    listContainers.mockClear();
    getContainer.mockClear();
    createContainer.mockClear();
  };

  return {
    DockerConstructorMock,
    constructorSpy,
    createContainer,
    handles,
    registerContainer,
    reset,
  };
});

vi.mock("dockerode", () => ({
  default: dockerMock.DockerConstructorMock,
}));

import { ContainerLifecycleService } from "./container-lifecycle.service";

function createConfigMock(values: Record<string, string> = {}) {
  return {
    get: vi.fn((key: string) => values[key]),
  };
}

function createCryptoMock() {
  return {
    generateToken: vi.fn(() => "generated-token"),
    encrypt: vi.fn((value: string) => `enc:${value}`),
    decrypt: vi.fn((value: string) => value.replace(/^enc:/, "")),
    isEncrypted: vi.fn((value: string) => value.startsWith("enc:")),
  };
}

function projectRecord(
  record: MockUserContainerRecord,
  select?: Record<string, boolean>
): Partial<MockUserContainerRecord> {
  if (!select) {
    return { ...record };
  }

  const projection: Partial<MockUserContainerRecord> = {};
  for (const [field, enabled] of Object.entries(select)) {
    if (enabled) {
      const key = field as keyof MockUserContainerRecord;
      projection[key] = record[key];
    }
  }

  return projection;
}

function createPrismaMock(initialRecords: MockUserContainerRecord[] = []) {
  const records = new Map<string, MockUserContainerRecord>();
  for (const record of initialRecords) {
    records.set(record.userId, { ...record });
  }

  const userContainer = {
    findUnique: vi.fn(
      async (args: {
        where: { userId?: string; id?: string };
        select?: Record<string, boolean>;
      }) => {
        let record: MockUserContainerRecord | undefined;
        if (args.where.userId) {
          record = records.get(args.where.userId);
        } else if (args.where.id) {
          record = [...records.values()].find((entry) => entry.id === args.where.id);
        }

        if (!record) {
          return null;
        }

        return projectRecord(record, args.select);
      }
    ),
    create: vi.fn(
      async (args: {
        data: Partial<MockUserContainerRecord> & {
          userId: string;
          containerName: string;
          gatewayToken: string;
        };
      }) => {
        const now = new Date();
        const next: MockUserContainerRecord = {
          id: args.data.id ?? `uc-${records.size + 1}`,
          userId: args.data.userId,
          containerId: args.data.containerId ?? null,
          containerName: args.data.containerName,
          gatewayPort: args.data.gatewayPort ?? null,
          gatewayToken: args.data.gatewayToken,
          status: args.data.status ?? "stopped",
          lastActiveAt: args.data.lastActiveAt ?? null,
          idleTimeoutMin: args.data.idleTimeoutMin ?? 30,
          config: args.data.config ?? {},
          createdAt: now,
          updatedAt: now,
        };

        records.set(next.userId, next);
        return { ...next };
      }
    ),
    update: vi.fn(
      async (args: { where: { userId: string }; data: Partial<MockUserContainerRecord> }) => {
        const record = records.get(args.where.userId);
        if (!record) {
          throw new Error(`Record ${args.where.userId} not found`);
        }

        const updated: MockUserContainerRecord = {
          ...record,
          ...args.data,
          updatedAt: new Date(),
        };
        records.set(updated.userId, updated);
        return { ...updated };
      }
    ),
    updateMany: vi.fn(
      async (args: { where: { userId: string }; data: Partial<MockUserContainerRecord> }) => {
        const record = records.get(args.where.userId);
        if (!record) {
          return { count: 0 };
        }

        const updated: MockUserContainerRecord = {
          ...record,
          ...args.data,
          updatedAt: new Date(),
        };
        records.set(updated.userId, updated);
        return { count: 1 };
      }
    ),
    findMany: vi.fn(
      async (args?: {
        where?: {
          status?: string;
          lastActiveAt?: { not: null };
          gatewayPort?: { not: null };
        };
        select?: Record<string, boolean>;
      }) => {
        let rows = [...records.values()];

        if (args?.where?.status) {
          rows = rows.filter((record) => record.status === args.where?.status);
        }

        if (args?.where?.lastActiveAt?.not === null) {
          rows = rows.filter((record) => record.lastActiveAt !== null);
        }

        if (args?.where?.gatewayPort?.not === null) {
          rows = rows.filter((record) => record.gatewayPort !== null);
        }

        return rows.map((record) => projectRecord(record, args?.select));
      }
    ),
  };

  return {
    prisma: {
      userContainer,
    },
    records,
  };
}

function createRecord(overrides: Partial<MockUserContainerRecord>): MockUserContainerRecord {
  const now = new Date();
  return {
    id: overrides.id ?? "uc-default",
    userId: overrides.userId ?? "user-default",
    containerId: overrides.containerId ?? null,
    containerName: overrides.containerName ?? "mosaic-user-user-default",
    gatewayPort: overrides.gatewayPort ?? null,
    gatewayToken: overrides.gatewayToken ?? "enc:token-default",
    status: overrides.status ?? "stopped",
    lastActiveAt: overrides.lastActiveAt ?? null,
    idleTimeoutMin: overrides.idleTimeoutMin ?? 30,
    config: overrides.config ?? {},
    createdAt: overrides.createdAt ?? now,
    updatedAt: overrides.updatedAt ?? now,
  };
}

describe("ContainerLifecycleService", () => {
  beforeEach(() => {
    dockerMock.reset();
  });

  it("ensureRunning creates container when none exists", async () => {
    const { prisma, records } = createPrismaMock();
    const crypto = createCryptoMock();
    const config = createConfigMock();
    const service = new ContainerLifecycleService(
      prisma as unknown as PrismaService,
      crypto as unknown as CryptoService,
      config as unknown as ConfigService
    );

    const result = await service.ensureRunning("user-1");

    expect(result).toEqual({
      url: "http://mosaic-user-user-1:19000",
      token: "generated-token",
    });

    const updatedRecord = records.get("user-1");
    expect(updatedRecord?.status).toBe("running");
    expect(updatedRecord?.containerId).toBe("ctr-1");
    expect(updatedRecord?.gatewayPort).toBe(19000);
    expect(updatedRecord?.gatewayToken).toBe("enc:generated-token");

    expect(dockerMock.createContainer).toHaveBeenCalledTimes(1);
    const [createCall] = dockerMock.createContainer.mock.calls[0] as [
      {
        name: string;
        Image: string;
        Env: string[];
        HostConfig: { Binds: string[]; NetworkMode: string };
      },
    ];
    expect(createCall.name).toBe("mosaic-user-user-1");
    expect(createCall.Image).toBe("alpine/openclaw:latest");
    expect(createCall.HostConfig.Binds).toEqual(["mosaic-user-user-1-state:/home/node/.openclaw"]);
    expect(createCall.HostConfig.NetworkMode).toBe("mosaic-internal");
    expect(createCall.Env).toContain("AGENT_TOKEN=generated-token");
  });

  it("ensureRunning starts existing stopped container", async () => {
    const { prisma, records } = createPrismaMock([
      createRecord({
        id: "uc-1",
        userId: "user-2",
        containerId: "ctr-stopped",
        containerName: "mosaic-user-user-2",
        gatewayToken: "enc:existing-token",
        status: "stopped",
      }),
    ]);
    const crypto = createCryptoMock();
    const config = createConfigMock();
    const service = new ContainerLifecycleService(
      prisma as unknown as PrismaService,
      crypto as unknown as CryptoService,
      config as unknown as ConfigService
    );

    dockerMock.registerContainer({
      id: "ctr-stopped",
      name: "mosaic-user-user-2",
      running: false,
      port: 19042,
    });

    const result = await service.ensureRunning("user-2");

    expect(result).toEqual({
      url: "http://mosaic-user-user-2:19042",
      token: "existing-token",
    });

    const handle = dockerMock.handles.get("ctr-stopped");
    expect(handle?.start).toHaveBeenCalledTimes(1);
    expect(records.get("user-2")?.status).toBe("running");
    expect(records.get("user-2")?.gatewayPort).toBe(19042);
  });

  it("ensureRunning returns existing running container", async () => {
    const { prisma } = createPrismaMock([
      createRecord({
        id: "uc-2",
        userId: "user-3",
        containerId: "ctr-running",
        containerName: "mosaic-user-user-3",
        gatewayPort: 19043,
        gatewayToken: "enc:running-token",
        status: "running",
      }),
    ]);
    const crypto = createCryptoMock();
    const config = createConfigMock();
    const service = new ContainerLifecycleService(
      prisma as unknown as PrismaService,
      crypto as unknown as CryptoService,
      config as unknown as ConfigService
    );

    dockerMock.registerContainer({
      id: "ctr-running",
      name: "mosaic-user-user-3",
      running: true,
      port: 19043,
    });

    const result = await service.ensureRunning("user-3");

    expect(result).toEqual({
      url: "http://mosaic-user-user-3:19043",
      token: "running-token",
    });

    expect(dockerMock.createContainer).not.toHaveBeenCalled();
    const handle = dockerMock.handles.get("ctr-running");
    expect(handle?.start).not.toHaveBeenCalled();
  });

  it("stop gracefully stops container and updates DB", async () => {
    const { prisma, records } = createPrismaMock([
      createRecord({
        id: "uc-stop",
        userId: "user-stop",
        containerId: "ctr-stop",
        containerName: "mosaic-user-user-stop",
        gatewayPort: 19044,
        status: "running",
      }),
    ]);
    const crypto = createCryptoMock();
    const config = createConfigMock();
    const service = new ContainerLifecycleService(
      prisma as unknown as PrismaService,
      crypto as unknown as CryptoService,
      config as unknown as ConfigService
    );

    dockerMock.registerContainer({
      id: "ctr-stop",
      name: "mosaic-user-user-stop",
      running: true,
      port: 19044,
    });

    await service.stop("user-stop");

    const handle = dockerMock.handles.get("ctr-stop");
    expect(handle?.stop).toHaveBeenCalledWith({ t: 10 });

    const updatedRecord = records.get("user-stop");
    expect(updatedRecord?.status).toBe("stopped");
    expect(updatedRecord?.containerId).toBeNull();
    expect(updatedRecord?.gatewayPort).toBeNull();
  });

  it("reapIdle stops only containers past their idle timeout", async () => {
    const now = Date.now();
    const { prisma, records } = createPrismaMock([
      createRecord({
        id: "uc-old",
        userId: "user-old",
        containerId: "ctr-old",
        containerName: "mosaic-user-user-old",
        gatewayPort: 19045,
        status: "running",
        lastActiveAt: new Date(now - 60 * 60 * 1000),
        idleTimeoutMin: 30,
      }),
      createRecord({
        id: "uc-fresh",
        userId: "user-fresh",
        containerId: "ctr-fresh",
        containerName: "mosaic-user-user-fresh",
        gatewayPort: 19046,
        status: "running",
        lastActiveAt: new Date(now - 5 * 60 * 1000),
        idleTimeoutMin: 30,
      }),
    ]);
    const crypto = createCryptoMock();
    const config = createConfigMock();
    const service = new ContainerLifecycleService(
      prisma as unknown as PrismaService,
      crypto as unknown as CryptoService,
      config as unknown as ConfigService
    );

    dockerMock.registerContainer({
      id: "ctr-old",
      name: "mosaic-user-user-old",
      running: true,
      port: 19045,
    });
    dockerMock.registerContainer({
      id: "ctr-fresh",
      name: "mosaic-user-user-fresh",
      running: true,
      port: 19046,
    });

    const result = await service.reapIdle();

    expect(result).toEqual({
      stopped: ["user-old"],
    });

    expect(records.get("user-old")?.status).toBe("stopped");
    expect(records.get("user-fresh")?.status).toBe("running");

    const oldHandle = dockerMock.handles.get("ctr-old");
    const freshHandle = dockerMock.handles.get("ctr-fresh");
    expect(oldHandle?.stop).toHaveBeenCalledTimes(1);
    expect(freshHandle?.stop).not.toHaveBeenCalled();
  });

  it("touch updates lastActiveAt", async () => {
    const { prisma, records } = createPrismaMock([
      createRecord({
        id: "uc-touch",
        userId: "user-touch",
        containerName: "mosaic-user-user-touch",
        lastActiveAt: null,
      }),
    ]);
    const crypto = createCryptoMock();
    const config = createConfigMock();
    const service = new ContainerLifecycleService(
      prisma as unknown as PrismaService,
      crypto as unknown as CryptoService,
      config as unknown as ConfigService
    );

    await service.touch("user-touch");

    const updatedRecord = records.get("user-touch");
    expect(updatedRecord?.lastActiveAt).toBeInstanceOf(Date);
  });

  it("getStatus returns null for unknown user", async () => {
    const { prisma } = createPrismaMock();
    const crypto = createCryptoMock();
    const config = createConfigMock();
    const service = new ContainerLifecycleService(
      prisma as unknown as PrismaService,
      crypto as unknown as CryptoService,
      config as unknown as ConfigService
    );

    const status = await service.getStatus("missing-user");

    expect(status).toBeNull();
  });
});
