import fs from "fs";
import path from "path";
import { execa } from "execa";
import {
  appendFileToHash as appendFileToHashModel,
  appendInputToHash as appendInputToHashModel,
  computeTemplateFingerprint as computeTemplateFingerprintModel,
} from "./fingerprint.mjs";
import {
  buildContainerName as buildContainerNameModel,
  buildDatabaseUrl as buildDatabaseUrlModel,
  buildRuntimeDatabaseName as buildRuntimeDatabaseNameModel,
  buildTemplateDatabaseName as buildTemplateDatabaseNameModel,
  escapeIdentifier as escapeIdentifierModel,
  escapeSqlLiteral as escapeSqlLiteralModel,
  hashString as hashStringModel,
  limitIdentifier as limitIdentifierModel,
  slugSegment as slugSegmentModel,
} from "./naming.mjs";
import {
  getLocalInfraDir as getLocalInfraDirModel,
  getLocalLocksDir as getLocalLocksDirModel,
  getLocalServiceCacheDir as getLocalServiceCacheDirModel,
  hasRemainingLocalArtifacts as hasRemainingLocalArtifactsModel,
  readStateValue as readStateValueModel,
  visitDirs as visitDirsModel,
} from "./state.mjs";
import { captureTemplateSnapshot, runTemplateStage } from "./template-steps.mjs";

const LOCAL_IMAGE = "pgvector/pgvector:pg16";
const LOCAL_USER = "testkit";
const LOCAL_PASSWORD = "testkit";
const LOCAL_ADMIN_DB = "postgres";
const LOCAL_READY_TIMEOUT_MS = 60_000;
const LOCAL_POLL_INTERVAL_MS = 1_000;
const LOCAL_DROP_DATABASE_TIMEOUT_MS = 15_000;
const LOCAL_DROP_DATABASE_POLL_INTERVAL_MS = 250;

export async function prepareDatabaseRuntime(config) {
  const db = config.testkit.database;
  if (!db) return;

  fs.mkdirSync(config.stateDir, { recursive: true });
  if (db.provider === "local") {
    await prepareLocalDatabase(config);
    return;
  }

  throw new Error(`Unsupported database provider "${db.provider}"`);
}

export async function captureDatabaseTemplateSnapshot(config, outputPath) {
  if (!config.testkit.database || config.testkit.database.provider !== "local") {
    throw new Error(`Service "${config.name}" does not use a local testkit database`);
  }

  await prepareDatabaseRuntime(config);
  const cacheDir = getLocalServiceCacheDir(config.productDir, config.name);
  const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
  if (!templateDbName) {
    throw new Error(`Missing template database for service "${config.name}"`);
  }

  const infra = await loadExistingLocalContainer(config.productDir);
  if (!infra) {
    throw new Error(`Missing local database container for service "${config.name}"`);
  }

  return captureTemplateSnapshot(config, outputPath, buildDatabaseUrl(infra, templateDbName));
}

export async function destroyRuntimeDatabase({ productDir, stateDir }) {
  const backend = readStateValue(path.join(stateDir, "database_backend"));
  if (backend === "local") {
    await destroyLocalRuntimeDatabase(productDir, stateDir);
  }
}

export async function destroyServiceDatabaseCache(productDir, serviceName) {
  const cacheDir = getLocalServiceCacheDir(productDir, serviceName);
  if (!fs.existsSync(cacheDir)) return;

  const backend = readStateValue(path.join(cacheDir, "database_backend"));
  if (backend !== "local") {
    fs.rmSync(cacheDir, { recursive: true, force: true });
    return;
  }

  const lockDir = getLocalLocksDir(productDir);
  fs.mkdirSync(lockDir, { recursive: true });
  await withLock(path.join(lockDir, `template-${serviceName}.lock`), async () => {
    const infra = await loadExistingLocalContainer(productDir);
    if (!infra) {
      fs.rmSync(cacheDir, { recursive: true, force: true });
      return;
    }
    const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
    if (templateDbName) {
      await dropDatabaseIfExists(infra, templateDbName);
    }
    fs.rmSync(cacheDir, { recursive: true, force: true });
  });
}

export async function cleanupOrphanedLocalInfrastructure(productDir) {
  const infraDir = getLocalInfraDir(productDir);
  if (!fs.existsSync(infraDir)) return;

  if (hasRemainingLocalArtifacts(productDir)) return;

  const containerName = readStateValue(path.join(infraDir, "container_name"));
  if (containerName) {
    await stopAndRemoveContainer(containerName);
  }
  fs.rmSync(infraDir, { recursive: true, force: true });
}

export function isDatabaseStateDir(dir) {
  if (!fs.existsSync(dir)) return false;
  return fs.existsSync(path.join(dir, "database_backend"));
}

export function showServiceDatabaseStatus(productDir, serviceName) {
  const cacheDir = getLocalServiceCacheDir(productDir, serviceName);
  const infraDir = getLocalInfraDir(productDir);
  if (!fs.existsSync(cacheDir) && !fs.existsSync(infraDir)) return false;

  if (fs.existsSync(cacheDir)) {
    console.log("  database-cache/");
    printStateDir(cacheDir, "    ");
  }

  if (fs.existsSync(infraDir)) {
    console.log("  database-infra/");
    printStateDir(infraDir, "    ");
  }

  return true;
}

async function prepareLocalDatabase(config) {
  const db = config.testkit.database;
  const productDir = config.productDir;
  const serviceName = config.name;
  const bindingKey = resolveDatabaseBindingKey(config);
  const lockDir = getLocalLocksDir(productDir);
  const cacheDir = getLocalServiceCacheDir(productDir, serviceName);
  fs.mkdirSync(lockDir, { recursive: true });
  fs.mkdirSync(cacheDir, { recursive: true });

  const templateFingerprint = await computeTemplateFingerprint(config);
  const infra = await withLock(path.join(lockDir, "container.lock"), () =>
    ensureLocalContainer(productDir, db)
  );

  await withLock(path.join(lockDir, `template-${serviceName}.lock`), async () => {
    await ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint);
  });

  await withLock(path.join(lockDir, `runtime-${serviceName}-${hashString(bindingKey, 10)}.lock`), async () => {
    await ensureRuntimeClone(config, infra, cacheDir, templateFingerprint, bindingKey);
  });
}

async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint) {
  const serviceName = config.name;
  const existingFingerprint = readStateValue(path.join(cacheDir, "template_fingerprint"));
  const existingDbName = readStateValue(path.join(cacheDir, "template_database_name"));
  const desiredDbName = buildTemplateDatabaseName(serviceName, templateFingerprint);

  if (
    existingFingerprint === templateFingerprint &&
    existingDbName &&
    (await databaseExists(infra, existingDbName))
  ) {
    writeLocalCacheState(cacheDir, infra, existingDbName, templateFingerprint);
    return;
  }

  if (existingDbName && existingDbName !== desiredDbName) {
    await dropDatabaseIfExists(infra, existingDbName);
  }
  if (await databaseExists(infra, desiredDbName)) {
    await dropDatabaseIfExists(infra, desiredDbName);
  }

  const templateUrl = buildDatabaseUrl(infra, desiredDbName);
  await createEmptyDatabase(infra, desiredDbName);
  try {
    await runTemplateStage(config, "migrate", templateUrl);
    await runTemplateStage(config, "seed", templateUrl);
    await runTemplateStage(config, "verify", templateUrl);
  } catch (error) {
    await dropDatabaseIfExists(infra, desiredDbName);
    throw error;
  }

  writeLocalCacheState(cacheDir, infra, desiredDbName, templateFingerprint);
}

async function ensureRuntimeClone(config, infra, cacheDir, templateFingerprint, bindingKey) {
  const serviceName = config.name;
  const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
  if (!templateDbName) {
    throw new Error(`Missing template database for service "${serviceName}"`);
  }

  const desiredDbName = buildRuntimeDatabaseName(serviceName, bindingKey, templateFingerprint);
  const existingDbName = readStateValue(path.join(config.stateDir, "local_database_name"));
  const existingFingerprint = readStateValue(path.join(config.stateDir, "local_template_fingerprint"));
  const needsReset =
    config.testkit.database.reset !== false ||
    existingFingerprint !== templateFingerprint ||
    existingDbName !== desiredDbName ||
    !(await databaseExists(infra, desiredDbName));

  if (existingDbName && existingDbName !== desiredDbName) {
    await dropDatabaseIfExists(infra, existingDbName);
  }

  if (needsReset) {
    await dropDatabaseIfExists(infra, desiredDbName);
    await cloneDatabaseFromTemplate(infra, desiredDbName, templateDbName);
  }

  fs.mkdirSync(config.stateDir, { recursive: true });
  fs.writeFileSync(path.join(config.stateDir, "database_backend"), "local");
  fs.writeFileSync(path.join(config.stateDir, "database_url"), buildDatabaseUrl(infra, desiredDbName));
  fs.writeFileSync(path.join(config.stateDir, "local_database_name"), desiredDbName);
  fs.writeFileSync(path.join(config.stateDir, "local_template_fingerprint"), templateFingerprint);
  fs.writeFileSync(path.join(config.stateDir, "local_template_database_name"), templateDbName);
  fs.writeFileSync(path.join(config.stateDir, "local_container_name"), infra.containerName);
}

function resolveDatabaseBindingKey(config) {
  const binding = config.testkit.database?.binding || "per-runtime";
  if (binding === "shared") {
    return `${config.name}:shared`;
  }
  if (config.runtimeId) {
    return `${config.name}:${config.runtimeId}`;
  }
  return `${config.name}:${config.stateDir}`;
}

async function destroyLocalRuntimeDatabase(productDir, stateDir) {
  const dbName = readStateValue(path.join(stateDir, "local_database_name"));
  if (!dbName) return;

  const infra = await loadExistingLocalContainer(productDir);
  if (!infra) return;
  await dropDatabaseIfExists(infra, dbName);
}

async function ensureLocalContainer(productDir, database = {}) {
  const infraDir = getLocalInfraDir(productDir);
  fs.mkdirSync(infraDir, { recursive: true });

  const containerName =
    readStateValue(path.join(infraDir, "container_name")) || buildContainerName(productDir);
  const image = database.image || readStateValue(path.join(infraDir, "image")) || LOCAL_IMAGE;
  const user = database.user || readStateValue(path.join(infraDir, "user")) || LOCAL_USER;
  const password =
    database.password || readStateValue(path.join(infraDir, "password")) || LOCAL_PASSWORD;

  let inspect = await inspectContainer(containerName);
  if (inspect && inspect.Config?.Image !== image) {
    await stopAndRemoveContainer(containerName);
    inspect = null;
  }

  if (!inspect) {
    await execa("docker", [
      "run",
      "-d",
      "--name",
      containerName,
      "-e",
      `POSTGRES_USER=${user}`,
      "-e",
      `POSTGRES_PASSWORD=${password}`,
      "-e",
      `POSTGRES_DB=${LOCAL_ADMIN_DB}`,
      "-p",
      "127.0.0.1::5432",
      image,
    ]);
    inspect = await inspectContainer(containerName);
  } else if (!inspect.State?.Running) {
    await execa("docker", ["start", containerName]);
    inspect = await inspectContainer(containerName);
  }

  const hostPort = inspect?.NetworkSettings?.Ports?.["5432/tcp"]?.[0]?.HostPort;
  if (!hostPort) {
    throw new Error(`Could not determine published port for local database container ${containerName}`);
  }

  const infra = {
    containerName,
    containerId: inspect.Id,
    image,
    user,
    password,
    host: "127.0.0.1",
    port: Number(hostPort),
  };

  await waitForLocalContainerReady(infra);
  writeLocalInfraState(infraDir, infra);
  return infra;
}

async function loadExistingLocalContainer(productDir) {
  const infraDir = getLocalInfraDir(productDir);
  const containerName = readStateValue(path.join(infraDir, "container_name"));
  if (!containerName) return null;

  const inspect = await inspectContainer(containerName);
  if (!inspect) return null;

  if (!inspect.State?.Running) {
    await execa("docker", ["start", containerName]);
  }

  const nextInspect = await inspectContainer(containerName);
  const hostPort = nextInspect?.NetworkSettings?.Ports?.["5432/tcp"]?.[0]?.HostPort;
  if (!hostPort) return null;

  const infra = {
    containerName,
    containerId: nextInspect.Id,
    image: nextInspect.Config?.Image || readStateValue(path.join(infraDir, "image")) || LOCAL_IMAGE,
    user: readStateValue(path.join(infraDir, "user")) || LOCAL_USER,
    password: readStateValue(path.join(infraDir, "password")) || LOCAL_PASSWORD,
    host: "127.0.0.1",
    port: Number(hostPort),
  };
  await waitForLocalContainerReady(infra);
  return infra;
}

async function waitForLocalContainerReady(infra) {
  const startedAt = Date.now();
  while (Date.now() - startedAt < LOCAL_READY_TIMEOUT_MS) {
    try {
      await execa("docker", [
        "exec",
        infra.containerName,
        "pg_isready",
        "-U",
        infra.user,
        "-d",
        LOCAL_ADMIN_DB,
      ]);
      return;
    } catch {
      await sleep(LOCAL_POLL_INTERVAL_MS);
    }
  }

  throw new Error(`Timed out waiting for local database container ${infra.containerName}`);
}

async function inspectContainer(containerName) {
  try {
    const { stdout } = await execa("docker", ["inspect", containerName]);
    const parsed = JSON.parse(stdout);
    return parsed[0] || null;
  } catch {
    return null;
  }
}

async function stopAndRemoveContainer(containerName) {
  try {
    await execa("docker", ["rm", "-f", containerName]);
  } catch {
    // Already gone.
  }
}

async function databaseExists(infra, dbName) {
  const result = await runAdminQuery(infra, [
    "-tAc",
    `SELECT 1 FROM pg_database WHERE datname = '${escapeSqlLiteral(dbName)}'`,
  ]);
  return result.trim() === "1";
}

async function createEmptyDatabase(infra, dbName) {
  await runAdminQuery(infra, ["-c", `CREATE DATABASE "${escapeIdentifier(dbName)}"`]);
}

async function cloneDatabaseFromTemplate(infra, dbName, templateDbName) {
  await runAdminQuery(infra, [
    "-c",
    `CREATE DATABASE "${escapeIdentifier(dbName)}" TEMPLATE "${escapeIdentifier(templateDbName)}"`,
  ]);
}

async function dropDatabaseIfExists(infra, dbName) {
  await dropDatabaseWithForceOrDrain(infra, dbName);
}

export async function dropDatabaseWithForceOrDrain(infra, dbName, hooks = {}) {
  const runAdminQueryFn = hooks.runAdminQuery || runAdminQuery;
  const databaseExistsFn = hooks.databaseExists || databaseExists;
  const sleepFn = hooks.sleep || sleep;

  try {
    await runAdminQueryFn(infra, [
      "-c",
      `DROP DATABASE IF EXISTS "${escapeIdentifier(dbName)}" WITH (FORCE)`,
    ]);
    return;
  } catch (error) {
    if (!isUnsupportedForceDropError(error)) {
      throw error;
    }
  }

  if (!(await databaseExistsFn(infra, dbName))) {
    return;
  }

  let restoreConnections = false;
  try {
    await runAdminQueryFn(infra, [
      "-c",
      `ALTER DATABASE "${escapeIdentifier(dbName)}" WITH ALLOW_CONNECTIONS false`,
    ]);
    restoreConnections = true;
    await waitForDatabaseConnectionsToDrain(infra, dbName, {
      runAdminQuery: runAdminQueryFn,
      sleep: sleepFn,
    });
    await runAdminQueryFn(infra, [
      "-c",
      `DROP DATABASE IF EXISTS "${escapeIdentifier(dbName)}"`,
    ]);
    restoreConnections = false;
  } finally {
    if (restoreConnections && (await databaseExistsFn(infra, dbName))) {
      await runAdminQueryFn(infra, [
        "-c",
        `ALTER DATABASE "${escapeIdentifier(dbName)}" WITH ALLOW_CONNECTIONS true`,
      ]).catch(() => {
        // Best-effort restoration for failed fallback drops.
      });
    }
  }
}

export async function waitForDatabaseConnectionsToDrain(infra, dbName, hooks = {}) {
  const runAdminQueryFn = hooks.runAdminQuery || runAdminQuery;
  const sleepFn = hooks.sleep || sleep;
  const now = hooks.now || Date.now;
  const timeoutMs = hooks.timeoutMs ?? LOCAL_DROP_DATABASE_TIMEOUT_MS;
  const deadline = now() + timeoutMs;

  while (true) {
    await terminateDatabaseConnections(infra, dbName, runAdminQueryFn);
    const remainingConnections = await countDatabaseConnections(infra, dbName, runAdminQueryFn);
    if (remainingConnections === 0) {
      return;
    }
    if (now() >= deadline) {
      throw new Error(
        `Timed out waiting for database "${dbName}" connections to close (${remainingConnections} remaining)`
      );
    }
    await sleepFn(LOCAL_DROP_DATABASE_POLL_INTERVAL_MS);
  }
}

async function runAdminQuery(infra, args) {
  const commandArgs = [
    "exec",
    "-e",
    `PGPASSWORD=${infra.password}`,
    infra.containerName,
    "psql",
    "-v",
    "ON_ERROR_STOP=1",
    "-U",
    infra.user,
    "-d",
    LOCAL_ADMIN_DB,
    ...args,
  ];
  const { stdout } = await execa("docker", commandArgs);
  return stdout;
}

async function terminateDatabaseConnections(infra, dbName, runAdminQueryFn) {
  await runAdminQueryFn(infra, [
    "-c",
    [
      "SELECT pg_terminate_backend(pid)",
      "FROM pg_stat_activity",
      `WHERE datname = '${escapeSqlLiteral(dbName)}' AND pid <> pg_backend_pid();`,
    ].join(" "),
  ]);
}

async function countDatabaseConnections(infra, dbName, runAdminQueryFn) {
  const result = await runAdminQueryFn(infra, [
    "-tAc",
    [
      "SELECT COUNT(*)",
      "FROM pg_stat_activity",
      `WHERE datname = '${escapeSqlLiteral(dbName)}' AND pid <> pg_backend_pid();`,
    ].join(" "),
  ]);
  return Number.parseInt(result.trim(), 10) || 0;
}

function isUnsupportedForceDropError(error) {
  const text = `${error?.shortMessage || ""}\n${error?.stderr || ""}\n${error?.stdout || ""}\n${error?.message || ""}`;
  return (
    text.includes('syntax error at or near "WITH"') ||
    text.includes('option "force"')
  );
}

async function computeTemplateFingerprint(config) {
  return computeTemplateFingerprintModel(config);
}

function appendInputToHash(hash, productDir, input) {
  return appendInputToHashModel(hash, productDir, input);
}

function appendFileToHash(hash, productDir, absPath) {
  return appendFileToHashModel(hash, productDir, absPath);
}

function buildDatabaseUrl(infra, dbName) {
  return buildDatabaseUrlModel(infra, dbName);
}

function buildContainerName(productDir) {
  return buildContainerNameModel(productDir);
}

function buildTemplateDatabaseName(serviceName, fingerprint) {
  return buildTemplateDatabaseNameModel(serviceName, fingerprint);
}

function buildRuntimeDatabaseName(serviceName, bindingKey, fingerprint) {
  return buildRuntimeDatabaseNameModel(serviceName, bindingKey, fingerprint);
}

function writeLocalInfraState(infraDir, infra) {
  fs.writeFileSync(path.join(infraDir, "database_backend"), "local");
  fs.writeFileSync(path.join(infraDir, "container_name"), infra.containerName);
  fs.writeFileSync(path.join(infraDir, "container_id"), infra.containerId);
  fs.writeFileSync(path.join(infraDir, "image"), infra.image);
  fs.writeFileSync(path.join(infraDir, "user"), infra.user);
  fs.writeFileSync(path.join(infraDir, "password"), infra.password);
  fs.writeFileSync(path.join(infraDir, "host"), infra.host);
  fs.writeFileSync(path.join(infraDir, "port"), String(infra.port));
}

function writeLocalCacheState(cacheDir, infra, templateDbName, fingerprint) {
  fs.writeFileSync(path.join(cacheDir, "database_backend"), "local");
  fs.writeFileSync(path.join(cacheDir, "template_database_name"), templateDbName);
  fs.writeFileSync(path.join(cacheDir, "template_fingerprint"), fingerprint);
  fs.writeFileSync(path.join(cacheDir, "container_name"), infra.containerName);
}

function getLocalInfraDir(productDir) {
  return getLocalInfraDirModel(productDir);
}

function getLocalLocksDir(productDir) {
  return getLocalLocksDirModel(productDir);
}

function getLocalServiceCacheDir(productDir, serviceName) {
  return getLocalServiceCacheDirModel(productDir, serviceName);
}

function hasRemainingLocalArtifacts(productDir) {
  return hasRemainingLocalArtifactsModel(productDir, readStateValue);
}

function visitDirs(root, visitor) {
  return visitDirsModel(root, visitor);
}

function printStateDir(dir, indent) {
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const filePath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      console.log(`${indent}${entry.name}/`);
      printStateDir(filePath, `${indent}  `);
      continue;
    }
    const value =
      entry.name === "password" ? "********" : fs.readFileSync(filePath, "utf8").trim();
    console.log(`${indent}${entry.name}: ${value}`);
  }
}

async function withLock(lockPath, fn) {
  const lockDir = `${lockPath}.dir`;
  const timeoutMs = 60_000;
  const startedAt = Date.now();

  while (true) {
    try {
      fs.mkdirSync(lockDir, { recursive: false });
      break;
    } catch (error) {
      if (error.code !== "EEXIST") throw error;
      if (Date.now() - startedAt > timeoutMs) {
        throw new Error(`Timed out waiting for lock ${path.basename(lockPath)}`);
      }
      await sleep(200);
    }
  }

  try {
    return await fn();
  } finally {
    fs.rmSync(lockDir, { recursive: true, force: true });
  }
}

function hashString(value, length = 12) {
  return hashStringModel(value, length);
}

function slugSegment(value) {
  return slugSegmentModel(value);
}

function limitIdentifier(value, maxLength) {
  return limitIdentifierModel(value, maxLength);
}

function escapeIdentifier(value) {
  return escapeIdentifierModel(value);
}

function escapeSqlLiteral(value) {
  return escapeSqlLiteralModel(value);
}

function readStateValue(filePath) {
  return readStateValueModel(filePath);
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function removeLocalRuntimeState(stateDir) {
  for (const file of [
    "local_database_name",
    "local_template_fingerprint",
    "local_template_database_name",
    "local_container_name",
  ]) {
    fs.rmSync(path.join(stateDir, file), { force: true });
  }
}
