import { execFile, spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterAll, beforeAll, describe, expect, it } from "vitest";

type EmbeddedPostgresInstance = {
  initialise(): Promise<void>;
  start(): Promise<void>;
  stop(): Promise<void>;
};

type EmbeddedPostgresCtor = new (opts: {
  databaseDir: string;
  user: string;
  password: string;
  port: number;
  persistent: boolean;
  initdbFlags?: string[];
  onLog?: (message: unknown) => void;
  onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;

const execFileAsync = promisify(execFile);
type ServerProcess = ReturnType<typeof spawn>;

async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
  const mod = await import("embedded-postgres");
  return mod.default as EmbeddedPostgresCtor;
}

async function getAvailablePort(): Promise<number> {
  return await new Promise((resolve, reject) => {
    const server = net.createServer();
    server.unref();
    server.on("error", reject);
    server.listen(0, "127.0.0.1", () => {
      const address = server.address();
      if (!address || typeof address === "string") {
        server.close(() => reject(new Error("Failed to allocate test port")));
        return;
      }
      const { port } = address;
      server.close((error) => {
        if (error) reject(error);
        else resolve(port);
      });
    });
  });
}

async function startTempDatabase() {
  const dataDir = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-db-"));
  const port = await getAvailablePort();
  const EmbeddedPostgres = await getEmbeddedPostgresCtor();
  const instance = new EmbeddedPostgres({
    databaseDir: dataDir,
    user: "paperclip",
    password: "paperclip",
    port,
    persistent: true,
    initdbFlags: ["--encoding=UTF8", "--locale=C"],
    onLog: () => {},
    onError: () => {},
  });
  await instance.initialise();
  await instance.start();

  const { applyPendingMigrations, ensurePostgresDatabase } = await import("@paperclipai/db");
  const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
  await ensurePostgresDatabase(adminConnectionString, "paperclip");
  const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
  await applyPendingMigrations(connectionString);

  return { connectionString, dataDir, instance };
}

function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) {
  const config = {
    $meta: {
      version: 1,
      updatedAt: new Date().toISOString(),
      source: "doctor",
    },
    database: {
      mode: "postgres",
      connectionString,
      embeddedPostgresDataDir: path.join(tempRoot, "embedded-db"),
      embeddedPostgresPort: 54329,
      backup: {
        enabled: false,
        intervalMinutes: 60,
        retentionDays: 30,
        dir: path.join(tempRoot, "backups"),
      },
    },
    logging: {
      mode: "file",
      logDir: path.join(tempRoot, "logs"),
    },
    server: {
      deploymentMode: "local_trusted",
      exposure: "private",
      host: "127.0.0.1",
      port,
      allowedHostnames: [],
      serveUi: false,
    },
    auth: {
      baseUrlMode: "auto",
      disableSignUp: false,
    },
    storage: {
      provider: "local_disk",
      localDisk: {
        baseDir: path.join(tempRoot, "storage"),
      },
      s3: {
        bucket: "paperclip",
        region: "us-east-1",
        prefix: "",
        forcePathStyle: false,
      },
    },
    secrets: {
      provider: "local_encrypted",
      strictMode: false,
      localEncrypted: {
        keyFilePath: path.join(tempRoot, "secrets", "master.key"),
      },
    },
  };

  mkdirSync(path.dirname(configPath), { recursive: true });
  writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
}

function createServerEnv(configPath: string, port: number, connectionString: string) {
  const env = { ...process.env };
  for (const key of Object.keys(env)) {
    if (key.startsWith("PAPERCLIP_")) {
      delete env[key];
    }
  }
  delete env.DATABASE_URL;
  delete env.PORT;
  delete env.HOST;
  delete env.SERVE_UI;
  delete env.HEARTBEAT_SCHEDULER_ENABLED;

  env.PAPERCLIP_CONFIG = configPath;
  env.DATABASE_URL = connectionString;
  env.HOST = "127.0.0.1";
  env.PORT = String(port);
  env.SERVE_UI = "false";
  env.PAPERCLIP_DB_BACKUP_ENABLED = "false";
  env.HEARTBEAT_SCHEDULER_ENABLED = "false";
  env.PAPERCLIP_MIGRATION_AUTO_APPLY = "true";
  env.PAPERCLIP_UI_DEV_MIDDLEWARE = "false";

  return env;
}

function createCliEnv() {
  const env = { ...process.env };
  for (const key of Object.keys(env)) {
    if (key.startsWith("PAPERCLIP_")) {
      delete env[key];
    }
  }
  delete env.DATABASE_URL;
  delete env.PORT;
  delete env.HOST;
  delete env.SERVE_UI;
  delete env.PAPERCLIP_DB_BACKUP_ENABLED;
  delete env.HEARTBEAT_SCHEDULER_ENABLED;
  delete env.PAPERCLIP_MIGRATION_AUTO_APPLY;
  delete env.PAPERCLIP_UI_DEV_MIDDLEWARE;
  return env;
}

function writeUint16(target: Uint8Array, offset: number, value: number) {
  target[offset] = value & 0xff;
  target[offset + 1] = (value >>> 8) & 0xff;
}

function writeUint32(target: Uint8Array, offset: number, value: number) {
  target[offset] = value & 0xff;
  target[offset + 1] = (value >>> 8) & 0xff;
  target[offset + 2] = (value >>> 16) & 0xff;
  target[offset + 3] = (value >>> 24) & 0xff;
}

function crc32(bytes: Uint8Array) {
  let crc = 0xffffffff;
  for (const byte of bytes) {
    crc ^= byte;
    for (let bit = 0; bit < 8; bit += 1) {
      crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
    }
  }
  return (crc ^ 0xffffffff) >>> 0;
}

function collectTextFiles(root: string, current: string, files: Record<string, string>) {
  for (const entry of readdirSync(current, { withFileTypes: true })) {
    const absolutePath = path.join(current, entry.name);
    if (entry.isDirectory()) {
      collectTextFiles(root, absolutePath, files);
      continue;
    }
    if (!entry.isFile()) continue;
    const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
    files[relativePath] = readFileSync(absolutePath, "utf8");
  }
}

function createStoredZipArchive(files: Record<string, string>, rootPath: string) {
  const encoder = new TextEncoder();
  const localChunks: Uint8Array[] = [];
  const centralChunks: Uint8Array[] = [];
  let localOffset = 0;
  let entryCount = 0;

  for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
    const fileName = encoder.encode(`${rootPath}/${relativePath}`);
    const body = encoder.encode(content);
    const checksum = crc32(body);

    const localHeader = new Uint8Array(30 + fileName.length);
    writeUint32(localHeader, 0, 0x04034b50);
    writeUint16(localHeader, 4, 20);
    writeUint16(localHeader, 6, 0x0800);
    writeUint16(localHeader, 8, 0);
    writeUint32(localHeader, 14, checksum);
    writeUint32(localHeader, 18, body.length);
    writeUint32(localHeader, 22, body.length);
    writeUint16(localHeader, 26, fileName.length);
    localHeader.set(fileName, 30);

    const centralHeader = new Uint8Array(46 + fileName.length);
    writeUint32(centralHeader, 0, 0x02014b50);
    writeUint16(centralHeader, 4, 20);
    writeUint16(centralHeader, 6, 20);
    writeUint16(centralHeader, 8, 0x0800);
    writeUint16(centralHeader, 10, 0);
    writeUint32(centralHeader, 16, checksum);
    writeUint32(centralHeader, 20, body.length);
    writeUint32(centralHeader, 24, body.length);
    writeUint16(centralHeader, 28, fileName.length);
    writeUint32(centralHeader, 42, localOffset);
    centralHeader.set(fileName, 46);

    localChunks.push(localHeader, body);
    centralChunks.push(centralHeader);
    localOffset += localHeader.length + body.length;
    entryCount += 1;
  }

  const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
  const archive = new Uint8Array(
    localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
  );
  let offset = 0;
  for (const chunk of localChunks) {
    archive.set(chunk, offset);
    offset += chunk.length;
  }
  const centralDirectoryOffset = offset;
  for (const chunk of centralChunks) {
    archive.set(chunk, offset);
    offset += chunk.length;
  }
  writeUint32(archive, offset, 0x06054b50);
  writeUint16(archive, offset + 8, entryCount);
  writeUint16(archive, offset + 10, entryCount);
  writeUint32(archive, offset + 12, centralDirectoryLength);
  writeUint32(archive, offset + 16, centralDirectoryOffset);

  return archive;
}

async function stopServerProcess(child: ServerProcess | null) {
  if (!child || child.exitCode !== null) return;
  child.kill("SIGTERM");
  await new Promise<void>((resolve) => {
    child.once("exit", () => resolve());
    setTimeout(() => {
      if (child.exitCode === null) {
        child.kill("SIGKILL");
      }
    }, 5_000);
  });
}

async function api<T>(baseUrl: string, pathname: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`${baseUrl}${pathname}`, init);
  const text = await res.text();
  if (!res.ok) {
    throw new Error(`Request failed ${res.status} ${pathname}: ${text}`);
  }
  return text ? JSON.parse(text) as T : (null as T);
}

async function runCliJson<T>(args: string[], opts: { apiBase: string; configPath: string }) {
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
  const result = await execFileAsync(
    "pnpm",
    ["--silent", "paperclipai", ...args, "--api-base", opts.apiBase, "--config", opts.configPath, "--json"],
    {
      cwd: repoRoot,
      env: createCliEnv(),
      maxBuffer: 10 * 1024 * 1024,
    },
  );
  const stdout = result.stdout.trim();
  const jsonStart = stdout.search(/[\[{]/);
  if (jsonStart === -1) {
    throw new Error(`CLI did not emit JSON.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
  }
  return JSON.parse(stdout.slice(jsonStart)) as T;
}

async function waitForServer(
  apiBase: string,
  child: ServerProcess,
  output: { stdout: string[]; stderr: string[] },
) {
  const startedAt = Date.now();
  while (Date.now() - startedAt < 30_000) {
    if (child.exitCode !== null) {
      throw new Error(
        `paperclipai run exited before healthcheck succeeded.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`,
      );
    }

    try {
      const res = await fetch(`${apiBase}/api/health`);
      if (res.ok) return;
    } catch {
      // Server is still starting.
    }

    await new Promise((resolve) => setTimeout(resolve, 250));
  }

  throw new Error(
    `Timed out waiting for ${apiBase}/api/health.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`,
  );
}

describe("paperclipai company import/export e2e", () => {
  let tempRoot = "";
  let configPath = "";
  let exportDir = "";
  let apiBase = "";
  let serverProcess: ServerProcess | null = null;
  let dbDataDir = "";
  let dbInstance: EmbeddedPostgresInstance | null = null;

  beforeAll(async () => {
    tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-"));
    configPath = path.join(tempRoot, "config", "config.json");
    exportDir = path.join(tempRoot, "exported-company");

    const db = await startTempDatabase();
    dbDataDir = db.dataDir;
    dbInstance = db.instance;

    const port = await getAvailablePort();
    writeTestConfig(configPath, tempRoot, port, db.connectionString);
    apiBase = `http://127.0.0.1:${port}`;

    const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
    const output = { stdout: [] as string[], stderr: [] as string[] };
    const child = spawn(
      "pnpm",
      ["paperclipai", "run", "--config", configPath],
      {
        cwd: repoRoot,
        env: createServerEnv(configPath, port, db.connectionString),
        stdio: ["ignore", "pipe", "pipe"],
      },
    );
    serverProcess = child;
    child.stdout?.on("data", (chunk) => {
      output.stdout.push(String(chunk));
    });
    child.stderr?.on("data", (chunk) => {
      output.stderr.push(String(chunk));
    });

    await waitForServer(apiBase, child, output);
  }, 60_000);

  afterAll(async () => {
    await stopServerProcess(serverProcess);
    await dbInstance?.stop();
    if (dbDataDir) {
      rmSync(dbDataDir, { recursive: true, force: true });
    }
    if (tempRoot) {
      rmSync(tempRoot, { recursive: true, force: true });
    }
  });

  it("exports a company package and imports it into new and existing companies", async () => {
    expect(serverProcess).not.toBeNull();

    const sourceCompany = await api<{ id: string; name: string; issuePrefix: string }>(apiBase, "/api/companies", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ name: `CLI Export Source ${Date.now()}` }),
    });

    const sourceAgent = await api<{ id: string; name: string }>(
      apiBase,
      `/api/companies/${sourceCompany.id}/agents`,
      {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          name: "Export Engineer",
          role: "engineer",
          adapterType: "claude_local",
          adapterConfig: {
            promptTemplate: "You verify company portability.",
          },
        }),
      },
    );

    const sourceProject = await api<{ id: string; name: string }>(
      apiBase,
      `/api/companies/${sourceCompany.id}/projects`,
      {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          name: "Portability Verification",
          status: "in_progress",
        }),
      },
    );

    const largeIssueDescription = `Round-trip the company package through the CLI.\n\n${"portable-data ".repeat(12_000)}`;

    const sourceIssue = await api<{ id: string; title: string; identifier: string }>(
      apiBase,
      `/api/companies/${sourceCompany.id}/issues`,
      {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          title: "Validate company import/export",
          description: largeIssueDescription,
          status: "todo",
          projectId: sourceProject.id,
          assigneeAgentId: sourceAgent.id,
        }),
      },
    );

    const exportResult = await runCliJson<{
      ok: boolean;
      out: string;
      filesWritten: number;
    }>(
      [
        "company",
        "export",
        sourceCompany.id,
        "--out",
        exportDir,
        "--include",
        "company,agents,projects,issues",
      ],
      { apiBase, configPath },
    );

    expect(exportResult.ok).toBe(true);
    expect(exportResult.filesWritten).toBeGreaterThan(0);
    expect(readFileSync(path.join(exportDir, "COMPANY.md"), "utf8")).toContain(sourceCompany.name);
    expect(readFileSync(path.join(exportDir, ".paperclip.yaml"), "utf8")).toContain('schema: "paperclip/v1"');

    const importedNew = await runCliJson<{
      company: { id: string; name: string; action: string };
      agents: Array<{ id: string | null; action: string; name: string }>;
    }>(
      [
        "company",
        "import",
        exportDir,
        "--target",
        "new",
        "--new-company-name",
        `Imported ${sourceCompany.name}`,
        "--include",
        "company,agents,projects,issues",
        "--yes",
      ],
      { apiBase, configPath },
    );

    expect(importedNew.company.action).toBe("created");
    expect(importedNew.agents).toHaveLength(1);
    expect(importedNew.agents[0]?.action).toBe("created");

    const importedAgents = await api<Array<{ id: string; name: string }>>(
      apiBase,
      `/api/companies/${importedNew.company.id}/agents`,
    );
    const importedProjects = await api<Array<{ id: string; name: string }>>(
      apiBase,
      `/api/companies/${importedNew.company.id}/projects`,
    );
    const importedIssues = await api<Array<{ id: string; title: string; identifier: string }>>(
      apiBase,
      `/api/companies/${importedNew.company.id}/issues`,
    );

    expect(importedAgents.map((agent) => agent.name)).toContain(sourceAgent.name);
    expect(importedProjects.map((project) => project.name)).toContain(sourceProject.name);
    expect(importedIssues.map((issue) => issue.title)).toContain(sourceIssue.title);

    const previewExisting = await runCliJson<{
      errors: string[];
      plan: {
        companyAction: string;
        agentPlans: Array<{ action: string }>;
        projectPlans: Array<{ action: string }>;
        issuePlans: Array<{ action: string }>;
      };
    }>(
      [
        "company",
        "import",
        exportDir,
        "--target",
        "existing",
        "--company-id",
        importedNew.company.id,
        "--include",
        "company,agents,projects,issues",
        "--collision",
        "rename",
        "--dry-run",
      ],
      { apiBase, configPath },
    );

    expect(previewExisting.errors).toEqual([]);
    expect(previewExisting.plan.companyAction).toBe("none");
    expect(previewExisting.plan.agentPlans.some((plan) => plan.action === "create")).toBe(true);
    expect(previewExisting.plan.projectPlans.some((plan) => plan.action === "create")).toBe(true);
    expect(previewExisting.plan.issuePlans.some((plan) => plan.action === "create")).toBe(true);

    const importedExisting = await runCliJson<{
      company: { id: string; action: string };
      agents: Array<{ id: string | null; action: string; name: string }>;
    }>(
      [
        "company",
        "import",
        exportDir,
        "--target",
        "existing",
        "--company-id",
        importedNew.company.id,
        "--include",
        "company,agents,projects,issues",
        "--collision",
        "rename",
        "--yes",
      ],
      { apiBase, configPath },
    );

    expect(importedExisting.company.action).toBe("unchanged");
    expect(importedExisting.agents.some((agent) => agent.action === "created")).toBe(true);

    const twiceImportedAgents = await api<Array<{ id: string; name: string }>>(
      apiBase,
      `/api/companies/${importedNew.company.id}/agents`,
    );
    const twiceImportedProjects = await api<Array<{ id: string; name: string }>>(
      apiBase,
      `/api/companies/${importedNew.company.id}/projects`,
    );
    const twiceImportedIssues = await api<Array<{ id: string; title: string; identifier: string }>>(
      apiBase,
      `/api/companies/${importedNew.company.id}/issues`,
    );

    expect(twiceImportedAgents).toHaveLength(2);
    expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2);
    expect(twiceImportedProjects).toHaveLength(2);
    expect(twiceImportedIssues).toHaveLength(2);

    const zipPath = path.join(tempRoot, "exported-company.zip");
    const portableFiles: Record<string, string> = {};
    collectTextFiles(exportDir, exportDir, portableFiles);
    writeFileSync(zipPath, createStoredZipArchive(portableFiles, "paperclip-demo"));

    const importedFromZip = await runCliJson<{
      company: { id: string; name: string; action: string };
      agents: Array<{ id: string | null; action: string; name: string }>;
    }>(
      [
        "company",
        "import",
        zipPath,
        "--target",
        "new",
        "--new-company-name",
        `Zip Imported ${sourceCompany.name}`,
        "--include",
        "company,agents,projects,issues",
        "--yes",
      ],
      { apiBase, configPath },
    );

    expect(importedFromZip.company.action).toBe("created");
    expect(importedFromZip.agents.some((agent) => agent.action === "created")).toBe(true);
  }, 60_000);
});
