import { execFile as execFileCallback, spawn as spawnCallback } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";

const execFile = promisify(execFileCallback);

export const DEFAULT_CONTAINER_NAME = "truckwash-test-gateway";
export const DEFAULT_IMAGE_TAG = "truckwash-edge-agent:test-gateway";
export const DEFAULT_CONFIG_FILE_NAME = "test-gateway.json";
export const DEFAULT_HOST_API_URL = "http://localhost/api";
export const DEFAULT_CONTAINER_API_URL = "http://caddy";
export const DEFAULT_CONTAINER_BROKER_URL = "ws://edge-broker:4300";
export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent";
export const DEFAULT_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`;
export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`;
export const DEFAULT_STACK_SERVICE_NAME = "truckwash-edge-gateway-stack.service";
export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15;
export const DEFAULT_INSTALLED_VERSION = "php-agent-v1";
const DEFAULT_COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "caddy"];

function composeArgs(projectName, args) {
  return ["compose", "-p", projectName, ...args];
}

function usesWindowsPathSyntax(filePath) {
  return /^[A-Za-z]:($|[\\/])/.test(filePath) || filePath.startsWith("\\\\") || filePath.includes("\\");
}

function pathForInputs(...filePaths) {
  const hasWindowsPath = filePaths.some((filePath) => usesWindowsPathSyntax(String(filePath || "")));

  return hasWindowsPath ? path.win32 : path;
}

async function resolveRootDir(scriptPath) {
  const cwd = process.cwd();

  try {
    await fs.access(path.join(cwd, "docker-compose.yml"));
    return cwd;
  } catch {
    return path.resolve(path.dirname(scriptPath), "..");
  }
}

function printUsage() {
  process.stdout.write(`Usage:
  node scripts/test-gateway.mjs start [--install-token <token>] [--container-name <name>] [--hostname <hostname>]
  node scripts/test-gateway.mjs stop [--container-name <name>]
  node scripts/test-gateway.mjs logs [--container-name <name>] [--tail <lines>]
  node scripts/test-gateway.mjs status [--container-name <name>]

Options:
  --install-token <token>    Claim a new edge gateway before starting the container.
  --container-name <name>    Docker container name. Default: ${DEFAULT_CONTAINER_NAME}
  --hostname <hostname>      Gateway hostname reported during claim and Docker run.
  --host-api-url <url>       Host-reachable API URL for claim/health checks. Default: ${DEFAULT_HOST_API_URL}
  --api-url <url>            Container-internal API URL. Default: ${DEFAULT_CONTAINER_API_URL}
  --broker-url <url>         Container-internal broker URL. Default: ${DEFAULT_CONTAINER_BROKER_URL}
  --config-dir <dir>         Directory for generated config. Default: backend-php/.tmp/test-gateway
  --image-tag <tag>          Docker image tag. Default: ${DEFAULT_IMAGE_TAG}
  --heartbeat-seconds <n>    Agent heartbeat interval. Default: ${DEFAULT_HEARTBEAT_INTERVAL_SECONDS}
  --tail <lines>             Log lines for the logs action. Default: 200
  --skip-compose-up          Do not start the local Docker Compose stack before start.
  --skip-build               Do not rebuild the test gateway image before start.
  --copy-config              Copy generated config into the container instead of bind mounting it.
`);
}

export function resolveComposeProjectName(rootDir, env = process.env) {
  const explicit = String(env.COMPOSE_PROJECT_NAME || "").trim();
  if (explicit !== "") {
    return explicit;
  }

  return pathForInputs(rootDir).basename(rootDir);
}

export function resolveComposeNetworkName(rootDir, env = process.env) {
  return `${resolveComposeProjectName(rootDir, env)}_default`;
}

export function resolveConfigDirectory(rootDir, explicitDir = null) {
  const pathModule = pathForInputs(rootDir, explicitDir);

  if (explicitDir) {
    return pathModule.resolve(rootDir, explicitDir);
  }

  return pathModule.join(rootDir, ".tmp", "test-gateway");
}

export function shouldClaimGateway(existingConfig = {}, installToken = "") {
  if (String(installToken || "").trim() !== "") {
    return true;
  }

  return !(existingConfig.gatewayId && existingConfig.agentToken);
}

export function buildGatewayConfig({
  existingConfig = {},
  claim = null,
  apiUrl = DEFAULT_CONTAINER_API_URL,
  brokerUrl = DEFAULT_CONTAINER_BROKER_URL,
  hostname = DEFAULT_CONTAINER_NAME,
  heartbeatIntervalSeconds = DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
} = {}) {
  const installedVersion = String(existingConfig.installedVersion || DEFAULT_INSTALLED_VERSION);
  const targetVersion = String(existingConfig.targetVersion || installedVersion);

  return {
    apiUrl,
    brokerUrl,
    gatewayId: claim?.gateway?.id ?? existingConfig.gatewayId ?? null,
    agentToken: claim?.agent_token ?? existingConfig.agentToken ?? null,
    hostname,
    releaseChannel: claim?.release_channel ?? existingConfig.releaseChannel ?? "stable",
    installedVersion,
    targetVersion,
    installDir: DEFAULT_INSTALL_DIR,
    runtimeDir: DEFAULT_RUNTIME_DIR,
    stateDatabasePath: DEFAULT_STATE_DATABASE_PATH,
    agentPath: `${DEFAULT_INSTALL_DIR}/agent.php`,
    lanWorkerPath: `${DEFAULT_INSTALL_DIR}/lan-worker.php`,
    serviceUnitPath: `${DEFAULT_INSTALL_DIR}/truckwash-edge-agent.service`,
    stackServiceUnitPath: `${DEFAULT_INSTALL_DIR}/${DEFAULT_STACK_SERVICE_NAME}`,
    serviceName: String(existingConfig.serviceName || hostname || DEFAULT_CONTAINER_NAME),
    stackServiceName: String(existingConfig.stackServiceName || DEFAULT_STACK_SERVICE_NAME),
    composeFileName: String(existingConfig.composeFileName || "docker-compose.gateway.yml"),
    composeProjectName: String(existingConfig.composeProjectName || "truckwash-edge-gateway"),
    launcherScriptName: String(existingConfig.launcherScriptName || "gateway-launcher.sh"),
    workerBaseUrl: String(existingConfig.workerBaseUrl || "http://lan-worker:8090"),
    updateWindow: String(existingConfig.updateWindow || "02:00-04:00"),
    runtimeMode: String(existingConfig.runtimeMode || "compose"),
    restartMode: "spawn",
    heartbeatIntervalSeconds,
    commandPollTimeoutSeconds: Number(existingConfig.commandPollTimeoutSeconds || 20),
    commandPollRetryDelayMs: Number(existingConfig.commandPollRetryDelayMs || 1000),
    brokerReconnectDelayMs: Number(existingConfig.brokerReconnectDelayMs || 1500),
  };
}

export function parseArgs(argv = process.argv.slice(2)) {
  const knownActions = new Set(["start", "stop", "logs", "status"]);
  const [first = "", ...remaining] = argv;
  const action = knownActions.has(first) ? first : "start";
  const rest = knownActions.has(first) ? remaining : argv;
  const options = {
    action,
    containerName: DEFAULT_CONTAINER_NAME,
    imageTag: DEFAULT_IMAGE_TAG,
    hostApiUrl: DEFAULT_HOST_API_URL,
    apiUrl: DEFAULT_CONTAINER_API_URL,
    brokerUrl: DEFAULT_CONTAINER_BROKER_URL,
    hostname: DEFAULT_CONTAINER_NAME,
    configDir: null,
    installToken: "",
    heartbeatSeconds: DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
    tail: "200",
    skipComposeUp: false,
    skipBuild: false,
    copyConfig: false,
  };

  for (let index = 0; index < rest.length; index += 1) {
    const arg = rest[index];
    const next = rest[index + 1];

    switch (arg) {
      case "--container-name":
        options.containerName = String(next || "").trim() || DEFAULT_CONTAINER_NAME;
        options.hostname = options.containerName;
        index += 1;
        break;
      case "--hostname":
        options.hostname = String(next || "").trim() || options.hostname;
        index += 1;
        break;
      case "--image-tag":
        options.imageTag = String(next || "").trim() || DEFAULT_IMAGE_TAG;
        index += 1;
        break;
      case "--host-api-url":
        options.hostApiUrl = String(next || "").trim() || DEFAULT_HOST_API_URL;
        index += 1;
        break;
      case "--api-url":
        options.apiUrl = String(next || "").trim() || DEFAULT_CONTAINER_API_URL;
        index += 1;
        break;
      case "--broker-url":
        options.brokerUrl = String(next || "").trim() || DEFAULT_CONTAINER_BROKER_URL;
        index += 1;
        break;
      case "--config-dir":
        options.configDir = String(next || "").trim() || null;
        index += 1;
        break;
      case "--install-token":
        options.installToken = String(next || "").trim();
        index += 1;
        break;
      case "--heartbeat-seconds":
        options.heartbeatSeconds = Number(next || DEFAULT_HEARTBEAT_INTERVAL_SECONDS);
        index += 1;
        break;
      case "--tail":
        options.tail = String(next || "200").trim() || "200";
        index += 1;
        break;
      case "--skip-compose-up":
        options.skipComposeUp = true;
        break;
      case "--skip-build":
        options.skipBuild = true;
        break;
      case "--copy-config":
        options.copyConfig = true;
        break;
      case "--help":
      case "-h":
        options.help = true;
        break;
      default:
        throw new Error(`Unknown argument: ${arg}`);
    }
  }

  return options;
}

async function runCommand(command, args, { cwd, allowFailure = false, stdio = "pipe" } = {}) {
  if (stdio === "inherit") {
    await new Promise((resolve, reject) => {
      const child = spawnCallback(command, args, {
        cwd,
        stdio: "inherit",
        windowsHide: true,
      });

      child.on("exit", (code) => {
        if (code === 0 || allowFailure) {
          resolve();
          return;
        }
        reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code}`));
      });
      child.on("error", reject);
    });
    return { stdout: "", stderr: "" };
  }

  try {
    return await execFile(command, args, {
      cwd,
      windowsHide: true,
      encoding: "utf8",
    });
  } catch (error) {
    if (allowFailure) {
      return {
        stdout: error.stdout || "",
        stderr: error.stderr || "",
        code: error.code || 1,
      };
    }
    throw error;
  }
}

async function ensureComposeServices(rootDir, skipComposeUp) {
  if (skipComposeUp) {
    return;
  }

  await runCommand("docker", composeArgs(resolveComposeProjectName(rootDir), ["up", "-d", ...DEFAULT_COMPOSE_SERVICES]), {
    cwd: rootDir,
    stdio: "inherit",
  });
}

function normalizeBaseUrl(url) {
  return String(url || "").replace(/\/+$/, "");
}

async function waitForApiReady(hostApiUrl, attempts = 60) {
  const baseUrl = normalizeBaseUrl(hostApiUrl);
  let lastError = null;

  for (let attempt = 0; attempt < attempts; attempt += 1) {
    try {
      const response = await fetch(`${baseUrl}/ping`);
      if (response.ok) {
        return;
      }
      lastError = new Error(`HTTP ${response.status}`);
    } catch (error) {
      lastError = error;
    }

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

  throw new Error(`API did not become ready at ${baseUrl}/ping: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}

async function claimGateway(hostApiUrl, installToken, hostname) {
  const response = await fetch(`${normalizeBaseUrl(hostApiUrl)}/edge-agent/claim`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
    },
    body: JSON.stringify({
      token: installToken,
      hostname,
      installed_version: DEFAULT_INSTALLED_VERSION,
      metadata: {
        source: "docker-test-gateway-script",
      },
    }),
  });

  const json = await response.json();
  if (!response.ok) {
    throw new Error(json?.data?.message || json?.message || `HTTP ${response.status}`);
  }

  return json.data ?? json;
}

async function readConfig(configFilePath) {
  try {
    const raw = await fs.readFile(configFilePath, "utf8");
    return JSON.parse(raw);
  } catch (error) {
    if (error?.code === "ENOENT") {
      return {};
    }
    throw error;
  }
}

async function writeConfig(configFilePath, config) {
  await fs.mkdir(path.dirname(configFilePath), { recursive: true });
  await fs.writeFile(configFilePath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
}

function toDockerMountPath(hostPath) {
  return hostPath.replace(/\\/g, "/");
}

async function ensureImage(rootDir, imageTag, skipBuild) {
  if (skipBuild) {
    return;
  }

  await runCommand(
    "docker",
    ["build", "-t", imageTag, "-f", "services/edge-agent/Dockerfile.test-gateway", "."],
    {
      cwd: rootDir,
      stdio: "inherit",
    }
  );
}

async function removeContainer(containerName) {
  await runCommand("docker", ["rm", "-f", containerName], { allowFailure: true });
}

async function startContainer({
  rootDir,
  imageTag,
  containerName,
  hostname,
  configDir,
  copyConfig,
}) {
  const networkName = resolveComposeNetworkName(rootDir);
  const mountedConfigDir = toDockerMountPath(configDir);
  const containerConfigPath = copyConfig
    ? `/tmp/${DEFAULT_CONFIG_FILE_NAME}`
    : `/config/${DEFAULT_CONFIG_FILE_NAME}`;
  const createArgs = [
    copyConfig ? "create" : "run",
    ...(copyConfig ? [] : ["-d"]),
    "--name",
    containerName,
    "--hostname",
    hostname,
    "--restart",
    "unless-stopped",
    "--network",
    networkName,
    ...(copyConfig ? [] : ["-v", `${mountedConfigDir}:/config`]),
    imageTag,
    "--config",
    containerConfigPath,
  ];

  await removeContainer(containerName);
  await runCommand("docker", createArgs, {
    cwd: rootDir,
    stdio: "inherit",
  });

  if (copyConfig) {
    const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
    await fs.chmod(configFilePath, 0o666).catch(() => {});
    try {
      await runCommand("docker", [
        "cp",
        configFilePath,
        `${containerName}:${containerConfigPath}`,
      ], {
        cwd: rootDir,
        stdio: "inherit",
      });
    } finally {
      await fs.chmod(configFilePath, 0o600).catch(() => {});
    }

    await runCommand("docker", ["start", containerName], {
      cwd: rootDir,
      stdio: "inherit",
    });
  }
}

async function printStatus({ imageTag, configDir, containerName }) {
  const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
  const config = await readConfig(configFilePath);
  const inspection = await runCommand("docker", ["inspect", containerName], {
    allowFailure: true,
  });

  let container = null;
  if (inspection.stdout && String(inspection.stdout).trim() !== "") {
    try {
      const decoded = JSON.parse(inspection.stdout);
      container = Array.isArray(decoded) ? decoded[0] ?? null : null;
    } catch {
      container = null;
    }
  }

  process.stdout.write(`${JSON.stringify({
    containerName,
    configFilePath,
    gatewayId: config.gatewayId ?? null,
    installDir: config.installDir ?? null,
    runtimeDir: config.runtimeDir ?? null,
    agentPath: config.agentPath ?? null,
    serviceUnitPath: config.serviceUnitPath ?? null,
    stackServiceUnitPath: config.stackServiceUnitPath ?? null,
    containerStatus: container?.State?.Status ?? "missing",
    running: Boolean(container?.State?.Running),
    image: container?.Config?.Image ?? imageTag,
  }, null, 2)}\n`);
}

async function main() {
  const options = parseArgs();
  if (options.help) {
    printUsage();
    return;
  }

  const scriptPath = fileURLToPath(import.meta.url);
  const rootDir = await resolveRootDir(scriptPath);
  const configDir = resolveConfigDirectory(rootDir, options.configDir);
  const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);

  switch (options.action) {
    case "start": {
      await ensureComposeServices(rootDir, options.skipComposeUp);
      await waitForApiReady(options.hostApiUrl);
      await ensureImage(rootDir, options.imageTag, options.skipBuild);

      const existingConfig = await readConfig(configFilePath);
      let claim = null;
      if (shouldClaimGateway(existingConfig, options.installToken)) {
        if (String(options.installToken || "").trim() === "") {
          throw new Error(`An install token is required to create the first test gateway config at ${configFilePath}`);
        }
        claim = await claimGateway(options.hostApiUrl, options.installToken, options.hostname);
      }

      const config = buildGatewayConfig({
        existingConfig,
        claim,
        apiUrl: options.apiUrl,
        brokerUrl: options.brokerUrl,
        hostname: options.hostname,
        heartbeatIntervalSeconds: Number.isFinite(options.heartbeatSeconds) && options.heartbeatSeconds > 0
          ? Math.round(options.heartbeatSeconds)
          : DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
      });

      await writeConfig(configFilePath, config);
      await startContainer({
        rootDir,
        imageTag: options.imageTag,
        containerName: options.containerName,
        hostname: options.hostname,
        configDir,
        copyConfig: options.copyConfig,
      });

      process.stdout.write(`Test gateway container started.
Container: ${options.containerName}
Config: ${configFilePath}
Gateway ID: ${config.gatewayId ?? "unclaimed"}
`);
      return;
    }
    case "stop":
      await removeContainer(options.containerName);
      process.stdout.write(`Removed container ${options.containerName}\n`);
      return;
    case "logs":
      await runCommand("docker", ["logs", "-f", "--tail", options.tail, options.containerName], {
        stdio: "inherit",
      });
      return;
    case "status":
      await printStatus({
        imageTag: options.imageTag,
        configDir,
        containerName: options.containerName,
      });
      return;
    default:
      throw new Error(`Unsupported action: ${options.action}`);
  }
}

const currentFilePath = fileURLToPath(import.meta.url);
const currentRealPath = await fs.realpath(currentFilePath).catch(() => currentFilePath);
const invokedScript = process.argv[1] ? path.resolve(process.argv[1]) : "";
const invokedRealPath = invokedScript !== ""
  ? await fs.realpath(invokedScript).catch(() => invokedScript)
  : "";
if (invokedRealPath === currentRealPath) {
  main().catch((error) => {
    process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
    process.exitCode = 1;
  });
}
