/**
 * Docker service management for tests.
 *
 * CouchDB start/stop/init is implemented directly using `docker` CLI commands
 * and the Fetch API, so it works on any platform where Docker (Desktop) is
 * available — including Windows — without needing bash.
 */

type DockerInvoker = {
    bin: string;
    prefix: string[];
    label: string;
};

let dockerInvokerPromise: Promise<DockerInvoker> | null = null;
const DOCKER_TEE = Deno.env.get("LIVESYNC_DOCKER_TEE") === "1" || Deno.env.get("LIVESYNC_TEST_TEE") === "1";
const trackedContainers = new Set<string>();
const CLEANUP_SIGNALS: Deno.Signal[] = ["SIGINT", "SIGTERM"];
let signalCleanupHandlersInstalled = false;
let signalCleanupInProgress = false;
const signalCleanupHandlers = new Map<Deno.Signal, () => void>();

// ---------------------------------------------------------------------------
// Low-level docker wrapper
// ---------------------------------------------------------------------------

function parseCommand(command: string): { bin: string; prefix: string[] } {
    const parts = command.trim().split(/\s+/).filter(Boolean);
    if (parts.length === 0) {
        throw new Error("LIVESYNC_DOCKER_COMMAND is empty");
    }
    return { bin: parts[0], prefix: parts.slice(1) };
}

async function collectStream(
    stream: ReadableStream<Uint8Array>,
    teeTarget: ((chunk: Uint8Array) => void) | null
): Promise<Uint8Array> {
    const reader = stream.getReader();
    const chunks: Uint8Array[] = [];
    try {
        while (true) {
            const { done, value } = await reader.read();
            if (done) break;
            if (!value) continue;
            chunks.push(value);
            if (teeTarget) {
                teeTarget(value);
            }
        }
    } finally {
        reader.releaseLock();
    }

    const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
    const out = new Uint8Array(total);
    let offset = 0;
    for (const chunk of chunks) {
        out.set(chunk, offset);
        offset += chunk.length;
    }
    return out;
}

async function runCommand(bin: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
    try {
        const child = new Deno.Command(bin, {
            args,
            stdin: "null",
            stdout: "piped",
            stderr: "piped",
        }).spawn();
        const stdoutPromise = collectStream(child.stdout, DOCKER_TEE ? (chunk) => Deno.stdout.writeSync(chunk) : null);
        const stderrPromise = collectStream(child.stderr, DOCKER_TEE ? (chunk) => Deno.stderr.writeSync(chunk) : null);
        const [status, stdout, stderr] = await Promise.all([child.status, stdoutPromise, stderrPromise]);
        const dec = new TextDecoder();
        const result = {
            code: status.code,
            stdout: dec.decode(stdout),
            stderr: dec.decode(stderr),
        };
        return result;
    } catch (err) {
        if (err instanceof Deno.errors.NotFound) {
            return {
                code: 127,
                stdout: "",
                stderr: `Command not found: ${bin}`,
            };
        }
        throw err;
    }
}

async function resolveDockerInvoker(): Promise<DockerInvoker> {
    const custom = Deno.env.get("LIVESYNC_DOCKER_COMMAND")?.trim();
    if (custom) {
        const parsed = parseCommand(custom);
        const runner: DockerInvoker = {
            ...parsed,
            label: `custom(${custom})`,
        };

        // Validate custom command eagerly so misconfiguration fails fast.
        const checkArgs = runner.prefix.length === 0 ? ["--version"] : [...runner.prefix, "docker", "--version"];
        const check = await runCommand(runner.bin, checkArgs);
        if (check.code !== 0) {
            throw new Error(`LIVESYNC_DOCKER_COMMAND is not usable: ${custom}\n${check.stderr || check.stdout}`);
        }
        return runner;
    }

    const mode = (Deno.env.get("LIVESYNC_DOCKER_MODE") ?? "auto").toLowerCase();
    const onWindows = Deno.build.os === "windows";

    const native: DockerInvoker = { bin: "docker", prefix: [], label: "docker" };
    const wsl: DockerInvoker = { bin: "wsl", prefix: [], label: "wsl docker" };

    if (mode === "native") {
        return native;
    }
    if (mode === "wsl") {
        return wsl;
    }
    if (mode !== "auto") {
        throw new Error(`Unsupported LIVESYNC_DOCKER_MODE='${mode}'. Use auto, native, or wsl.`);
    }

    // On Windows we prefer `wsl docker` first, then native docker.
    // This typically works better in setups where Docker is installed only in
    // WSL and not exposed as docker.exe on PATH.
    const candidates = onWindows ? [wsl, native] : [native, wsl];
    for (const c of candidates) {
        if (c.bin === "docker") {
            const r = await runCommand("docker", ["--version"]);
            if (r.code === 0) return c;
            continue;
        }
        const r = await runCommand("wsl", ["docker", "--version"]);
        if (r.code === 0) return c;
    }

    throw new Error(
        [
            "Docker command is not available.",
            "Set one of:",
            "- LIVESYNC_DOCKER_MODE=native",
            "- LIVESYNC_DOCKER_MODE=wsl",
            "- LIVESYNC_DOCKER_COMMAND='docker'",
            "- LIVESYNC_DOCKER_COMMAND='wsl docker'",
        ].join("\n")
    );
}

async function getDockerInvoker(): Promise<DockerInvoker> {
    if (!dockerInvokerPromise) {
        dockerInvokerPromise = resolveDockerInvoker().then((r) => {
            console.log(`[INFO] docker runner: ${r.label}`);
            return r;
        });
    }
    return await dockerInvokerPromise;
}

async function docker(...args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
    const invoker = await getDockerInvoker();

    // Either:
    //   docker <args>
    // Or:
    //   wsl docker <args>
    const finalArgs =
        invoker.prefix.length === 0
            ? invoker.bin === "wsl"
                ? ["docker", ...args]
                : args
            : [...invoker.prefix, ...args];

    const r = await runCommand(invoker.bin, finalArgs);
    return { code: r.code, stdout: r.stdout, stderr: r.stderr };
}

async function dockerOrFail(...args: string[]): Promise<string> {
    const r = await docker(...args);
    if (r.code !== 0) {
        throw new Error(`docker ${args[0]} failed (code ${r.code}): ${r.stderr.trim()}`);
    }
    return r.stdout;
}

async function stopAndRemoveContainer(container: string): Promise<void> {
    await docker("stop", container).catch(() => {});
    await docker("rm", container).catch(() => {});
}

async function cleanupTrackedContainers(reason: string): Promise<void> {
    const names = [...trackedContainers];
    if (names.length === 0) return;

    console.warn(`[WARN] cleaning up tracked containers on ${reason}: ${names.join(", ")}`);
    for (const container of names.reverse()) {
        await stopAndRemoveContainer(container);
        trackedContainers.delete(container);
    }
}

async function handleSignalCleanup(signal: Deno.Signal): Promise<void> {
    if (signalCleanupInProgress) return;
    signalCleanupInProgress = true;
    try {
        await cleanupTrackedContainers(`signal ${signal}`);
    } finally {
        Deno.exit(signal === "SIGINT" ? 130 : 143);
    }
}

function ensureSignalCleanupHandlers(): void {
    if (signalCleanupHandlersInstalled) return;
    signalCleanupHandlersInstalled = true;
    for (const signal of CLEANUP_SIGNALS) {
        const listener = () => {
            void handleSignalCleanup(signal);
        };
        try {
            Deno.addSignalListener(signal, listener);
            signalCleanupHandlers.set(signal, listener);
        } catch {
            // Unsupported signal on this platform.
        }
    }
}

function removeSignalCleanupHandlers(): void {
    if (!signalCleanupHandlersInstalled) return;
    for (const [signal, listener] of signalCleanupHandlers) {
        try {
            Deno.removeSignalListener(signal, listener);
        } catch {
            // Ignore if already removed or unsupported.
        }
    }
    signalCleanupHandlers.clear();
    signalCleanupHandlersInstalled = false;
}

function trackContainer(container: string): void {
    ensureSignalCleanupHandlers();
    trackedContainers.add(container);
}

function untrackContainer(container: string): void {
    trackedContainers.delete(container);
    if (trackedContainers.size === 0) {
        removeSignalCleanupHandlers();
    }
}

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

async function waitForCouchdbStable(hostname: string, user: string, password: string): Promise<void> {
    const h = hostname.replace(/\/$/, "").replace("localhost", "127.0.0.1");
    const auth = btoa(`${user}:${password}`);
    const headers = { Authorization: `Basic ${auth}` };
    let consecutive = 0;
    for (let i = 0; i < 30; i++) {
        try {
            const r = await fetch(`${h}/_up`, {
                headers,
                signal: AbortSignal.timeout(3000),
            });
            if (r.ok) {
                consecutive++;
                if (consecutive >= 3) return;
            } else {
                consecutive = 0;
            }
        } catch {
            consecutive = 0;
        }
        await sleep(500);
    }
    throw new Error("CouchDB did not become stable in time");
}

// ---------------------------------------------------------------------------
// Fetch with retry (mirrors cli_test_curl_json() retry loop)
// ---------------------------------------------------------------------------

async function fetchRetry(
    url: string,
    init: RequestInit,
    retries = 30,
    delayMs = 2000,
    allowStatus: number[] = []
): Promise<void> {
    let lastError: unknown;
    let lastStatus: number | undefined;
    for (let i = 0; i < retries; i++) {
        try {
            const r = await fetch(url, {
                signal: AbortSignal.timeout(5000),
                ...init,
            });
            lastStatus = r.status;
            await r.body?.cancel().catch(() => {});
            if (r.ok || allowStatus.includes(r.status)) return;
            lastError = `HTTP ${r.status}`;
        } catch (e) {
            lastError = e;
        }
        await sleep(delayMs);
    }
    throw new Error(
        `Could not reach ${url} after ${retries} retries: ${lastError} (last status: ${lastStatus ?? "N/A"})`
    );
}

// ---------------------------------------------------------------------------
// CouchDB
// ---------------------------------------------------------------------------
//
// TODO: these values could be configurable via environment variables.
//
const COUCHDB_CONTAINER = "couchdb-test";
const COUCHDB_IMAGE = "couchdb:3.5.0";

const MINIO_CONTAINER = "minio-test";
const MINIO_IMAGE = "minio/minio";
const MINIO_MC_IMAGE = "minio/mc";

export async function stopCouchdb(): Promise<void> {
    await stopAndRemoveContainer(COUCHDB_CONTAINER);
    untrackContainer(COUCHDB_CONTAINER);
}

/**
 * Start a CouchDB test container, initialise it, and create the test DB.
 * Mirrors cli_test_start_couchdb() from test-helpers.sh, using direct
 * docker / fetch calls instead of the bash util scripts.
 */
export async function startCouchdb(couchdbUri: string, user: string, password: string, dbname: string): Promise<void> {
    console.log("[INFO] stopping leftover CouchDB container if present");
    await stopCouchdb().catch(() => {});

    console.log("[INFO] starting CouchDB test container");
    await dockerOrFail(
        "run",
        "-d",
        "--name",
        COUCHDB_CONTAINER,
        "-p",
        // TODO: port mapping should be configurable.
        "5989:5984",
        "-e",
        `COUCHDB_USER=${user}`,
        "-e",
        `COUCHDB_PASSWORD=${password}`,
        "-e",
        "COUCHDB_SINGLE_NODE=y",
        COUCHDB_IMAGE
    );
    trackContainer(COUCHDB_CONTAINER);

    console.log("[INFO] initialising CouchDB");
    await initCouchdb(couchdbUri, user, password);

    console.log("[INFO] waiting for CouchDB to become stable");
    await waitForCouchdbStable(couchdbUri, user, password);

    console.log(`[INFO] creating test database: ${dbname}`);
    await createCouchdbDatabase(couchdbUri, user, password, dbname);
}

/**
 * Mirror couchdb-init.sh: configure single-node CouchDB via its REST API.
 */
async function initCouchdb(hostname: string, user: string, password: string, node = "_local"): Promise<void> {
    // Podman environments often resolve localhost to ::1; use 127.0.0.1 like
    // the bash script does.
    const h = hostname.replace(/\/$/, "").replace("localhost", "127.0.0.1");
    const auth = btoa(`${user}:${password}`);
    const headers = {
        "Content-Type": "application/json",
        Authorization: `Basic ${auth}`,
    };

    const calls: Array<[string, string, string]> = [
        [
            "POST",
            `${h}/_cluster_setup`,
            JSON.stringify({
                action: "enable_single_node",
                username: user,
                password,
                bind_address: "0.0.0.0",
                port: 5984,
                singlenode: true,
            }),
        ],
        ["PUT", `${h}/_node/${node}/_config/chttpd/require_valid_user`, '"true"'],
        ["PUT", `${h}/_node/${node}/_config/chttpd_auth/require_valid_user`, '"true"'],
        ["PUT", `${h}/_node/${node}/_config/httpd/WWW-Authenticate`, '"Basic realm=\\"couchdb\\""'],
        ["PUT", `${h}/_node/${node}/_config/httpd/enable_cors`, '"true"'],
        ["PUT", `${h}/_node/${node}/_config/chttpd/enable_cors`, '"true"'],
        ["PUT", `${h}/_node/${node}/_config/chttpd/max_http_request_size`, '"4294967296"'],
        ["PUT", `${h}/_node/${node}/_config/couchdb/max_document_size`, '"50000000"'],
        ["PUT", `${h}/_node/${node}/_config/cors/credentials`, '"true"'],
        ["PUT", `${h}/_node/${node}/_config/cors/origins`, '"*"'],
    ];

    for (const [method, url, body] of calls) {
        await fetchRetry(url, { method, headers, body });
    }
}

export async function createCouchdbDatabase(
    hostname: string,
    user: string,
    password: string,
    dbname: string
): Promise<void> {
    const h = hostname.replace(/\/$/, "").replace("localhost", "127.0.0.1");
    const auth = btoa(`${user}:${password}`);
    await fetchRetry(`${h}/${dbname}`, {
        method: "PUT",
        headers: { Authorization: `Basic ${auth}` },
    });
}

/** Update a CouchDB document via PUT. Returns the updated document. */
export async function updateCouchdbDoc(
    hostname: string,
    user: string,
    password: string,
    docUrl: string,
    updater: (doc: Record<string, unknown>) => Record<string, unknown>
): Promise<void> {
    const h = hostname.replace(/\/$/, "").replace("localhost", "127.0.0.1");
    const auth = btoa(`${user}:${password}`);
    const headers = {
        "Content-Type": "application/json",
        Authorization: `Basic ${auth}`,
    };
    const getRes = await fetch(`${h}/${docUrl}`, { headers });
    const current = (await getRes.json()) as Record<string, unknown>;
    const updated = updater(current);
    await fetchRetry(`${h}/${docUrl}`, {
        method: "PUT",
        headers,
        body: JSON.stringify(updated),
    });
}

// ---------------------------------------------------------------------------
// MinIO
// ---------------------------------------------------------------------------

function shQuote(value: string): string {
    return `'${value.split("'").join(`'"'"'`)}'`;
}

export async function stopMinio(): Promise<void> {
    await stopAndRemoveContainer(MINIO_CONTAINER);
    untrackContainer(MINIO_CONTAINER);
}

async function initMinioBucket(
    minioEndpoint: string,
    accessKey: string,
    secretKey: string,
    bucket: string
): Promise<boolean> {
    const cmd =
        `mc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
        `mc mb --ignore-existing myminio/${shQuote(bucket)} >/dev/null 2>&1`;
    const r = await docker("run", "--rm", "--network", "host", "--entrypoint", "/bin/sh", MINIO_MC_IMAGE, "-c", cmd);
    return r.code === 0;
}

async function waitForMinioBucket(
    minioEndpoint: string,
    accessKey: string,
    secretKey: string,
    bucket: string
): Promise<void> {
    for (let i = 0; i < 30; i++) {
        const checkCmd =
            `mc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
            `mc ls myminio/${shQuote(bucket)} >/dev/null 2>&1`;
        const check = await docker(
            "run",
            "--rm",
            "--network",
            // Now I used host networking to access the container via localhost for some environments (Docker Desktop on Windows).
            // We need something good idea to work across all environments.
            "host",
            "--entrypoint",
            "/bin/sh",
            MINIO_MC_IMAGE,
            "-c",
            checkCmd
        );
        if (check.code === 0) {
            return;
        }
        await initMinioBucket(minioEndpoint, accessKey, secretKey, bucket);
        await sleep(2000);
    }
    throw new Error(`MinIO bucket not ready: ${bucket}`);
}

export async function startMinio(
    minioEndpoint: string,
    accessKey: string,
    secretKey: string,
    bucket: string
): Promise<void> {
    console.log("[INFO] stopping leftover MinIO container if present");
    await stopMinio().catch(() => {});

    console.log("[INFO] starting MinIO test container");
    await dockerOrFail(
        "run",
        "-d",
        "--name",
        MINIO_CONTAINER,
        // TODO: Ports should be configurable.
        "-p",
        "9000:9000",
        "-p",
        "9001:9001",
        "-e",
        `MINIO_ROOT_USER=${accessKey}`,
        "-e",
        `MINIO_ROOT_PASSWORD=${secretKey}`,
        "-e",
        `MINIO_SERVER_URL=${minioEndpoint}`,
        MINIO_IMAGE,
        "server",
        "/data",
        "--console-address",
        ":9001"
    );
    trackContainer(MINIO_CONTAINER);

    console.log(`[INFO] initialising MinIO test bucket: ${bucket}`);
    let initialised = false;
    for (let i = 0; i < 5; i++) {
        if (await initMinioBucket(minioEndpoint, accessKey, secretKey, bucket)) {
            initialised = true;
            break;
        }
        await sleep(2000);
    }
    if (!initialised) {
        throw new Error(`Could not initialise MinIO bucket after retries: ${bucket}`);
    }

    await waitForMinioBucket(minioEndpoint, accessKey, secretKey, bucket);
}

// ---------------------------------------------------------------------------
// P2P relay (strfry)
// ---------------------------------------------------------------------------
// TODO: these values could be configurable via environment variables.
const P2P_RELAY_CONTAINER = "relay-test";
const P2P_RELAY_IMAGE = "ghcr.io/hoytech/strfry:latest";
const STRFRY_BOOTSTRAP_SH = String.raw`cat > /tmp/strfry.conf <<"EOF"
db = "./strfry-db/"

relay {
  bind = "0.0.0.0"
  port = 7777
  nofiles = 100000

  info {
    name = "livesync test relay"
    description = "local relay for livesync p2p tests"
  }

  maxWebsocketPayloadSize = 131072
  autoPingSeconds = 55

  writePolicy {
    plugin = ""
  }
}
EOF
exec /app/strfry --config /tmp/strfry.conf relay`;

export async function stopP2pRelay(): Promise<void> {
    await stopAndRemoveContainer(P2P_RELAY_CONTAINER);
    untrackContainer(P2P_RELAY_CONTAINER);
}

/**
 * Start the local P2P relay container through the same docker runner used
 * by CouchDB helpers. This keeps process ownership consistent across
 * start/stop on Windows, WSL, and native Linux/macOS.
 */
export async function startP2pRelay(): Promise<void> {
    console.log("[INFO] stopping leftover P2P relay container if present");
    await stopP2pRelay().catch(() => {});

    console.log("[INFO] starting local P2P relay container");
    await dockerOrFail(
        "run",
        "-d",
        "--name",
        P2P_RELAY_CONTAINER,
        "-p",
        //TODO: port mapping should be configurable.
        "4000:7777",
        "--tmpfs",
        "/app/strfry-db:rw,size=256m",
        "--entrypoint",
        "sh",
        P2P_RELAY_IMAGE,
        "-lc",
        STRFRY_BOOTSTRAP_SH
    );
    trackContainer(P2P_RELAY_CONTAINER);
}

export function isLocalP2pRelay(relayUrl: string): boolean {
    return relayUrl.includes("localhost") || relayUrl.includes("127.0.0.1") || relayUrl.includes("[::1]");
}

// ---------------------------------------------------------------------------
// Coturn (STUN/TURN)
// ---------------------------------------------------------------------------
const COTURN_CONTAINER = "coturn-test";
const COTURN_IMAGE = "coturn/coturn:latest";

export async function stopCoturn(): Promise<void> {
    await stopAndRemoveContainer(COTURN_CONTAINER);
    untrackContainer(COTURN_CONTAINER);
}

export async function startCoturn(
    port = 3478,
    user = "testuser",
    pass = "testpass",
    realm = "livesync.test"
): Promise<void> {
    console.log("[INFO] stopping leftover Coturn container if present");
    await stopCoturn().catch(() => {});

    const { getOptimalLoopbackIp } = await import("./net.ts");
    const externalIp = await getOptimalLoopbackIp();

    console.log(`[INFO] starting local Coturn container with external-ip ${externalIp}`);
    await dockerOrFail(
        "run",
        "-d",
        "--name",
        COTURN_CONTAINER,
        "-p",
        `${port}:${port}`,
        "-p",
        `${port}:${port}/udp`,
        COTURN_IMAGE,
        "--log-file=stdout",
        `--external-ip=${externalIp}`,
        `--user=${user}:${pass}`,
        `--realm=${realm}`
    );
    trackContainer(COTURN_CONTAINER);
}
