import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { discoverProject } from "./discovery.mjs";
import { loadTestkitSetup } from "./setup-loader.mjs";

const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
const DEFAULT_LOCAL_USER = "testkit";
const DEFAULT_LOCAL_PASSWORD = "testkit";

export function parseDotenv(filePath) {
  if (!fs.existsSync(filePath)) return {};
  return parseDotenvString(fs.readFileSync(filePath, "utf8"));
}

export async function loadConfigs(opts = {}) {
  const productDir = resolveProductDir(process.cwd(), opts.dir);
  const { setup, setupFile } = await loadTestkitSetup(productDir);
  const explicitServices = setup.services || {};
  const discovery = discoverProject(productDir, explicitServices);
  const serviceNames = new Set([
    ...Object.keys(explicitServices),
    ...Object.keys(discovery.suitesByService),
  ]);

  const configs = [...serviceNames]
    .sort((left, right) => left.localeCompare(right))
    .map((name) =>
      normalizeServiceConfig({
        name,
        productDir,
        setup,
        setupFile,
        explicitService: explicitServices[name] || {},
        discoveredService: discovery.services[name] || null,
        suites: discovery.suitesByService[name] || {},
      })
    );

  validateConfigCoverage(configs);

  const filtered = opts.service
    ? configs.filter((config) => config.name === opts.service)
    : configs;

  if (opts.service && filtered.length === 0) {
    const available = configs.map((config) => config.name).join(", ");
    throw new Error(`Service "${opts.service}" not found. Available: ${available}`);
  }

  return filtered;
}

export function resolveK6Binary() {
  const override = process.env[TESTKIT_K6_BIN]?.trim();
  if (override) {
    const isPathLike =
      path.isAbsolute(override) ||
      override.includes(path.sep) ||
      override.includes(path.posix.sep) ||
      override.includes(path.win32.sep);
    const overridePath = isPathLike ? path.resolve(process.cwd(), override) : override;

    if (isPathLike && !fs.existsSync(overridePath)) {
      throw new Error(`${TESTKIT_K6_BIN} points to a missing file: ${overridePath}`);
    }

    return overridePath;
  }

  const thisFile = fileURLToPath(import.meta.url);
  const abs = path.resolve(path.dirname(thisFile), "..", "..", "vendor", "k6");
  if (!fs.existsSync(abs)) {
    throw new Error(`Bundled k6 binary not found: ${abs}`);
  }
  return abs;
}

export function resolveDalBinary() {
  return resolveK6Binary();
}

export function resolveServiceCwd(productDir, maybeRelative) {
  return path.resolve(productDir, maybeRelative || ".");
}

function normalizeServiceConfig({
  name,
  productDir,
  setup,
  setupFile,
  explicitService,
  discoveredService,
  suites,
}) {
  const local = normalizeLocalConfig(name, explicitService, discoveredService, productDir);
  const envFiles = inferEnvFiles(productDir, explicitService, local);
  const serviceEnv = loadServiceEnv(productDir, envFiles);
  const database = normalizeDatabaseConfig(explicitService, name);
  const migrate = normalizeLifecycle(explicitService.migrate);
  const seed = normalizeLifecycle(explicitService.seed);

  if (!explicitService.databaseFrom && !database && (migrate || seed)) {
    throw new Error(
      `Service "${name}" defines migrate/seed hooks but no database. Add localDatabase(...) in testkit.setup.ts.`
    );
  }

  validateServiceConfig({
    name,
    local,
    database,
    databaseFrom: explicitService.databaseFrom,
    migrate,
    seed,
    dependsOn: explicitService.dependsOn || [],
    suites,
    productDir,
  });

  return {
    name,
    productDir,
    setupFile,
    stateDir: path.join(productDir, ".testkit", name),
    telemetry: normalizeTelemetryConfig(setup.telemetry),
    suites,
    testkit: {
      dependsOn: explicitService.dependsOn || [],
      database,
      databaseFrom: explicitService.databaseFrom,
      envFiles,
      serviceEnv,
      migrate,
      seed,
      local,
    },
  };
}

function normalizeLocalConfig(name, explicitService, discoveredService, productDir) {
  if (explicitService.local) {
    return {
      ...explicitService.local,
      cwd: explicitService.local.cwd || ".",
      env: explicitService.local.env || {},
    };
  }

  const inferredCwd = discoveredService?.inferredLocalCwd || ".";
  const detected = inferLocalRuntime(productDir, inferredCwd);
  if (!detected) return undefined;

  return detected;
}

function inferLocalRuntime(productDir, cwd) {
  const absoluteCwd = resolveServiceCwd(productDir, cwd);
  if (!fs.existsSync(absoluteCwd)) return undefined;

  if (detectNextApp(absoluteCwd)) {
    return {
      cwd,
      start: "exec ./node_modules/.bin/next dev -p {port}",
      port: 3000,
      baseUrl: "http://127.0.0.1:{port}",
      readyUrl: "http://127.0.0.1:{port}",
      env: {},
    };
  }

  if (fs.existsSync(path.join(absoluteCwd, "cmd", "server"))) {
    return {
      cwd,
      start: "exec go run ./cmd/server",
      port: 3000,
      baseUrl: "http://127.0.0.1:{port}",
      readyUrl: "http://127.0.0.1:{port}/health",
      env: {},
    };
  }

  if (fs.existsSync(path.join(absoluteCwd, "package.json")) && fs.existsSync(path.join(absoluteCwd, "src"))) {
    return {
      cwd,
      start: "exec ./node_modules/.bin/tsx watch src/index.ts",
      port: 3000,
      baseUrl: "http://127.0.0.1:{port}",
      readyUrl: "http://127.0.0.1:{port}/health",
      env: {},
    };
  }

  return undefined;
}

function normalizeDatabaseConfig(explicitService, serviceName) {
  if (explicitService.databaseFrom) return undefined;
  if (!explicitService.database) return undefined;

  const database =
    explicitService.database.provider === "local"
      ? explicitService.database
      : {
          provider: "local",
          ...explicitService.database,
        };

  return {
    ...database,
    provider: "local",
    selectedBackend: "local",
    reset: database.reset !== false,
    image: database.image || DEFAULT_LOCAL_IMAGE,
    user: database.user || DEFAULT_LOCAL_USER,
    password: database.password || DEFAULT_LOCAL_PASSWORD,
    template: {
      inputs: Array.isArray(database.template?.inputs) ? [...database.template.inputs] : [],
    },
    serviceName,
  };
}

function normalizeLifecycle(value) {
  if (!value) return undefined;
  if (!value.cmd && !value.testkitCmd) {
    throw new Error("Lifecycle config requires cmd or testkitCmd");
  }

  return {
    cmd: value.testkitCmd || value.cmd,
    cwd: value.testkitCwd || value.cwd,
  };
}

function inferEnvFiles(productDir, explicitService, local) {
  if (explicitService.envFile || explicitService.envFiles) {
    const files = [];
    if (explicitService.envFile) files.push(explicitService.envFile);
    if (Array.isArray(explicitService.envFiles)) files.push(...explicitService.envFiles);
    return files;
  }

  const candidates = [];
  const serviceCwd = local?.cwd || ".";
  if (serviceCwd !== ".") {
    candidates.push(path.posix.join(serviceCwd, ".env.testkit"));
    candidates.push(path.posix.join(serviceCwd, ".env"));
  }
  candidates.push(".env.testkit");
  candidates.push(".env");

  return [...new Set(candidates)]
    .map((candidate) => candidate.split(path.sep).join("/"))
    .filter((candidate) => fs.existsSync(resolveServiceCwd(productDir, candidate)));
}

function loadServiceEnv(productDir, envFiles) {
  const env = {};
  for (const envFile of envFiles) {
    Object.assign(env, parseDotenv(resolveServiceCwd(productDir, envFile)));
  }
  return env;
}

function validateConfigCoverage(configs) {
  const names = new Set(configs.map((config) => config.name));
  for (const config of configs) {
    for (const depName of config.testkit.dependsOn || []) {
      if (!names.has(depName)) {
        throw new Error(`Service "${config.name}" depends on "${depName}", but ${depName} is not defined`);
      }
    }
    const databaseFrom = config.testkit.databaseFrom;
    if (databaseFrom && !names.has(databaseFrom)) {
      throw new Error(
        `Service "${config.name}" databaseFrom "${databaseFrom}" is not defined`
      );
    }
  }
}

function validateServiceConfig({
  name,
  local,
  database,
  databaseFrom,
  migrate,
  seed,
  dependsOn,
  suites,
  productDir,
}) {
  const usesLocalExecution = Object.entries(suites).some(([suiteType, discoveredSuites]) =>
    discoveredSuites.some(
      (suite) => (suite.framework && suite.framework !== "k6") || suiteType !== "dal"
    )
  );

  if (usesLocalExecution && !local) {
    throw new Error(
      `Service "${name}" defines non-DAL suites but no local runtime could be resolved. Add it in testkit.setup.ts.`
    );
  }

  if (database && databaseFrom) {
    throw new Error(`Service "${name}" cannot define both database and databaseFrom`);
  }

  for (const depName of dependsOn || []) {
    if (depName === name) {
      throw new Error(`Service "${name}" cannot depend on itself`);
    }
  }

  if (local?.cwd) {
    ensureExistingPath(productDir, local.cwd, `Service "${name}" local.cwd`);
  }
  if (migrate?.cwd) {
    ensureExistingPath(productDir, migrate.cwd, `Service "${name}" migrate.cwd`);
  }
  if (seed?.cwd) {
    ensureExistingPath(productDir, seed.cwd, `Service "${name}" seed.cwd`);
  }
}

function ensureExistingPath(productDir, relativePath, label) {
  const absolute = resolveServiceCwd(productDir, relativePath);
  if (!fs.existsSync(absolute)) {
    throw new Error(`${label} does not exist: ${relativePath}`);
  }
}

function normalizeTelemetryConfig(telemetry) {
  if (!telemetry) return null;
  if (telemetry.endpoint) {
    let parsed;
    try {
      parsed = new URL(telemetry.endpoint);
    } catch {
      throw new Error("testkit.setup telemetry.endpoint must be a valid URL");
    }
    if (!["http:", "https:"].includes(parsed.protocol)) {
      throw new Error("testkit.setup telemetry.endpoint must use http or https");
    }
  }
  if (telemetry.enabled === true) {
    if (!telemetry.endpoint) {
      throw new Error("testkit.setup telemetry.endpoint is required when telemetry.enabled is true");
    }
    if (!telemetry.tokenEnv) {
      throw new Error("testkit.setup telemetry.tokenEnv is required when telemetry.enabled is true");
    }
  }
  return {
    enabled: telemetry.enabled === true,
    endpoint: telemetry.endpoint,
    tokenEnv: telemetry.tokenEnv,
    timeoutMs: telemetry.timeoutMs || 3000,
  };
}

function resolveProductDir(cwd, explicitDir) {
  const dir = explicitDir ? path.resolve(cwd, explicitDir) : cwd;
  if (!fs.existsSync(dir)) {
    throw new Error(`Product directory does not exist: ${dir}`);
  }
  return dir;
}

function parseDotenvString(source) {
  const env = {};
  for (const line of String(source).split("\n")) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith("#")) continue;
    const eq = trimmed.indexOf("=");
    if (eq === -1) continue;
    const key = trimmed.slice(0, eq).trim();
    let val = trimmed.slice(eq + 1).trim();
    if (
      (val.startsWith("'") && val.endsWith("'")) ||
      (val.startsWith("\"") && val.endsWith("\""))
    ) {
      val = val.slice(1, -1);
    }
    env[key] = val;
  }
  return env;
}

function detectNextApp(cwd) {
  return (
    fs.existsSync(path.join(cwd, "next.config.js")) ||
    fs.existsSync(path.join(cwd, "next.config.mjs")) ||
    fs.existsSync(path.join(cwd, "next.config.ts"))
  );
}
