package io.cameleer.server.app.runtime;

import io.cameleer.server.core.runtime.ContainerRequest;
import io.cameleer.server.core.runtime.ContainerStatus;
import io.cameleer.server.core.runtime.RuntimeOrchestrator;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.model.AccessMode;
import com.github.dockerjava.api.model.Bind;
import com.github.dockerjava.api.model.Capability;
import com.github.dockerjava.api.model.Frame;
import com.github.dockerjava.api.model.HealthCheck;
import com.github.dockerjava.api.model.HostConfig;
import com.github.dockerjava.api.model.RestartPolicy;
import com.github.dockerjava.api.model.Volume;
import com.github.dockerjava.core.command.LogContainerResultCallback;
import com.github.dockerjava.core.command.WaitContainerResultCallback;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;

public class DockerRuntimeOrchestrator implements RuntimeOrchestrator {

    private static final Logger log = LoggerFactory.getLogger(DockerRuntimeOrchestrator.class);

    /** Sandboxed runtime we prefer when the daemon has it registered. */
    private static final String SANDBOX_RUNTIME = "runsc";

    /** Hard cap on processes/threads per tenant container. Spring Boot + Camel
     * + a Kafka client comfortably fits in 512; raise via daemon-wide limits if
     * a tenant legitimately needs more (and revisit the multi-tenancy threat
     * model when that happens). */
    private static final long PIDS_LIMIT = 512L;

    /** /tmp must be writeable for JVM tmpdir, JIT scratch, and JNI native lib
     * unpacking (Netty tcnative, Snappy, LZ4, Zstd all dlopen from here).
     * `noexec` would block dlopen via mmap(PROT_EXEC) — keep it off. */
    private static final String TMPFS_TMP_OPTS = "rw,nosuid,size=256m";

    /** Mount path for the per-replica JAR volume. Loader writes here RW; main
     * container mounts the same volume RO and reads /app/jars/app.jar. */
    private static final String LOADER_VOLUME_MOUNT = "/app/jars";

    /** User-namespace remap applied to BOTH loader and main containers
     * (issue #152). `host:1000:65536` maps the in-container UID range starting
     * at 1000 onto the host so root inside the container is never UID 0 on the
     * host. */
    private static final String USERNS_MODE = "host:1000:65536";

    /** How long the orchestrator will wait for the loader container to fetch
     * the JAR before giving up. */
    private static final long LOADER_WAIT_TIMEOUT_SECONDS = 120;

    /** Tail size for loader-failure diagnostics surfaced in the thrown
     * RuntimeException. wget's BusyBox failure messages are short (e.g.
     * "wget: server returned error: HTTP/1.1 401" or "Permission denied"),
     * so 50 lines is plenty without bloating exception strings. */
    private static final int LOADER_LOG_TAIL_LINES = 50;
    private static final int LOADER_LOG_MAX_CHARS = 4096;
    private static final long LOADER_LOG_FETCH_TIMEOUT_SECONDS = 5;

    private final DockerClient dockerClient;
    private final String dockerRuntime;

    private ContainerLogForwarder logForwarder;

    public DockerRuntimeOrchestrator(DockerClient dockerClient) {
        this(dockerClient, "");
    }

    public DockerRuntimeOrchestrator(DockerClient dockerClient, String runtimeOverride) {
        this.dockerClient = dockerClient;
        this.dockerRuntime = resolveRuntime(runtimeOverride);
    }

    private String resolveRuntime(String override) {
        if (override != null && !override.isBlank()) {
            log.info("Container runtime forced to '{}' via cameleer.server.runtime.dockerruntime", override);
            return override;
        }
        try {
            Map<String, ?> runtimes = dockerClient.infoCmd().exec().getRuntimes();
            if (runtimes != null && runtimes.containsKey(SANDBOX_RUNTIME)) {
                log.info("gVisor ({}) detected — sandboxed runtime will be used for tenant containers",
                        SANDBOX_RUNTIME);
                return SANDBOX_RUNTIME;
            }
        } catch (Exception e) {
            log.warn("Could not query Docker runtimes: {} — falling back to daemon default", e.getMessage());
        }
        log.info("No sandboxed runtime detected — using Docker default (runc). Install gVisor on the host "
                + "for tenant kernel isolation; see issue #152.");
        return "";
    }

    /** Visible for tests / introspection. Empty string = let Docker pick its default. */
    String getDockerRuntime() {
        return dockerRuntime;
    }

    public void setLogForwarder(ContainerLogForwarder logForwarder) {
        this.logForwarder = logForwarder;
    }

    @PreDestroy
    public void close() throws IOException {
        if (dockerClient != null) {
            dockerClient.close();
        }
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public void pullImage(String image) {
        try {
            log.info("Pulling image {}", image);
            dockerClient.pullImageCmd(image).start().awaitCompletion();
            log.info("Image pulled: {}", image);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.warn("Image pull interrupted for {}", image);
        } catch (Exception e) {
            log.warn("Failed to pull image {} (will use local cache if available): {}", image, e.getMessage());
        }
    }

    @Override
    public String startContainer(ContainerRequest request) {
        // Per-replica named volume — shared between loader (RW) and main (RO).
        // Naming convention pins it to the replica's container name so volume
        // cleanup in removeContainer() can derive it deterministically.
        String volumeName = "cameleer-jars-" + request.containerName();
        dockerClient.createVolumeCmd().withName(volumeName).exec();

        // Phase 1: Loader container fetches the JAR from the signed URL into
        // the shared volume. Hardened identically to the main container, plus
        // RW bind on /app/jars and the artifact env vars the loader entrypoint
        // expects. We block on its exit code before bringing the main up.
        String loaderId;
        try {
            loaderId = createAndStartLoader(request, volumeName);
        } catch (Exception e) {
            // Volume created but loader never reached the wait/cleanup paths — clean up here.
            cleanupVolume(volumeName);
            throw new RuntimeException("Loader create/start failed for " + request.containerName(), e);
        }

        int exitCode;
        String loaderLogs = "";
        try {
            exitCode = dockerClient.waitContainerCmd(loaderId)
                    .exec(new WaitContainerResultCallback())
                    .awaitStatusCode(LOADER_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
            if (exitCode != 0) {
                loaderLogs = captureLoaderLogs(loaderId);
            }
        } catch (Exception e) {
            loaderLogs = captureLoaderLogs(loaderId);
            cleanup(loaderId, volumeName);
            throw new RuntimeException("Loader wait failed for " + request.containerName()
                    + appendLogs(loaderLogs) + ": " + e.getMessage(), e);
        } finally {
            try {
                dockerClient.removeContainerCmd(loaderId).withForce(true).exec();
            } catch (Exception e) {
                log.warn("Failed to remove loader {}: {}", loaderId, e.getMessage());
            }
        }
        if (exitCode != 0) {
            cleanupVolume(volumeName);
            throw new RuntimeException("Loader exited " + exitCode + " for " + request.containerName()
                    + appendLogs(loaderLogs));
        }

        // Phase 2: Main container — RO on the shared volume. Wrap in try/catch
        // so a main-create failure cleans up the volume too (loader already gone).
        try {
            return createAndStartMain(request, volumeName);
        } catch (Exception e) {
            cleanupVolume(volumeName);
            throw new RuntimeException("Main container create/start failed for " + request.containerName(), e);
        }
    }

    private String createAndStartLoader(ContainerRequest request, String volumeName) {
        HostConfig hc = baseHardenedHostConfig()
                .withBinds(new Bind(volumeName, new Volume(LOADER_VOLUME_MOUNT), AccessMode.rw))
                .withUsernsMode(USERNS_MODE)
                .withNetworkMode(request.network());
        if (!dockerRuntime.isBlank()) {
            hc.withRuntime(dockerRuntime);
        }

        var create = dockerClient.createContainerCmd(request.loaderImage())
                .withName(request.containerName() + "-loader")
                .withEnv(List.of(
                        "ARTIFACT_URL=" + request.artifactDownloadUrl(),
                        "ARTIFACT_EXPECTED_SIZE=" + request.artifactExpectedSize()))
                .withHostConfig(hc);
        String id = create.exec().getId();
        dockerClient.startContainerCmd(id).exec();
        return id;
    }

    private String createAndStartMain(ContainerRequest request, String volumeName) {
        List<String> envList = request.envVars().entrySet().stream()
                .map(e -> e.getKey() + "=" + e.getValue()).toList();

        // Tenant containers run untrusted user JVMs — every tenant JAR can call
        // Runtime.exec, reflective bean dispatch, MVEL/Groovy templating. Java 17
        // has no SecurityManager, so isolation MUST live below the JVM.
        // See issue #152 for the full threat model. Defaults are fail-closed:
        // - cap_drop ALL: outbound TCP still works (no caps needed); raw sockets,
        //   ptrace, mounts, and bind <1024 are all denied.
        // - no-new-privileges: setuid binaries cannot escalate.
        // - apparmor=docker-default: Docker's stock MAC profile.
        //   Daemon's default seccomp profile is applied implicitly when no
        //   `seccomp=` override is set — no need to declare it.
        // - readonly rootfs + /tmp tmpfs: persistence-via-write defeated; apps
        //   needing durable state declare writeableVolumes (issue #153).
        // - pids-limit: fork bombs cannot exhaust the host PID namespace.
        // - userns_mode host:1000:65536: container root is never UID 0 on the host.
        HostConfig hc = baseHardenedHostConfig()
                .withMemory(request.memoryLimitBytes())
                .withMemorySwap(request.memoryLimitBytes())
                .withCpuShares(request.cpuShares())
                .withNetworkMode(request.network())
                .withRestartPolicy(RestartPolicy.onFailureRestart(request.restartPolicyMaxRetries()))
                .withUsernsMode(USERNS_MODE)
                .withBinds(new Bind(volumeName, new Volume(LOADER_VOLUME_MOUNT), AccessMode.ro));
        if (!dockerRuntime.isBlank()) {
            hc.withRuntime(dockerRuntime);
        }
        if (request.memoryReserveBytes() != null) {
            hc.withMemoryReservation(request.memoryReserveBytes());
        }
        if (request.cpuQuota() != null) {
            hc.withCpuQuota(request.cpuQuota());
        }

        String appJarPath = LOADER_VOLUME_MOUNT + "/app.jar";
        String customArgs = request.customArgs() != null && !request.customArgs().isBlank()
                ? " " + request.customArgs() : "";
        String entrypoint = switch (request.runtimeType()) {
            case "plain-java" -> "exec java -javaagent:/app/agent.jar" + customArgs +
                    " -cp " + appJarPath + " " + request.mainClass();
            case "native" -> "exec " + appJarPath + customArgs;
            default -> // spring-boot, quarkus, and others all use -jar
                    "exec java -javaagent:/app/agent.jar" + customArgs + " -jar " + appJarPath;
        };

        var createCmd = dockerClient.createContainerCmd(request.baseImage())
                .withName(request.containerName())
                .withEnv(envList)
                .withLabels(request.labels() != null ? request.labels() : Map.of())
                .withHostConfig(hc)
                .withHealthcheck(new HealthCheck()
                        .withTest(List.of("CMD-SHELL",
                                "wget -qO- http://localhost:" + request.healthCheckPort() + "/cameleer/health || exit 1"))
                        .withInterval(10_000_000_000L)
                        .withTimeout(5_000_000_000L)
                        .withRetries(3)
                        .withStartPeriod(30_000_000_000L))
                .withEntrypoint("sh", "-c", entrypoint);

        if (request.exposedPorts() != null && !request.exposedPorts().isEmpty()) {
            var ports = request.exposedPorts().stream()
                    .map(com.github.dockerjava.api.model.ExposedPort::tcp)
                    .toArray(com.github.dockerjava.api.model.ExposedPort[]::new);
            createCmd.withExposedPorts(ports);
        }

        String id = createCmd.exec().getId();
        dockerClient.startContainerCmd(id).exec();
        log.info("Started container {} ({})", request.containerName(), id);
        return id;
    }

    /** Capture the loader's last stdout/stderr so the failure exception explains
     * itself instead of just "Loader exited N". Best-effort: a docker-side
     * failure to stream logs must not mask the original loader exit. */
    private String captureLoaderLogs(String loaderId) {
        try {
            StringBuilder buf = new StringBuilder();
            dockerClient.logContainerCmd(loaderId)
                    .withStdOut(true)
                    .withStdErr(true)
                    .withTail(LOADER_LOG_TAIL_LINES)
                    .exec(new LogContainerResultCallback() {
                        @Override
                        public void onNext(Frame frame) {
                            if (buf.length() < LOADER_LOG_MAX_CHARS) {
                                buf.append(new String(frame.getPayload(), StandardCharsets.UTF_8));
                            }
                        }
                    })
                    .awaitCompletion(LOADER_LOG_FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS);
            String text = buf.toString().trim();
            if (text.length() > LOADER_LOG_MAX_CHARS) {
                text = text.substring(text.length() - LOADER_LOG_MAX_CHARS);
            }
            return text;
        } catch (Exception e) {
            log.warn("Failed to capture loader logs for {}: {}", loaderId, e.getMessage());
            return "";
        }
    }

    private static String appendLogs(String logs) {
        return logs == null || logs.isEmpty() ? "" : ". loader output: " + logs;
    }

    /** Hardening contract from issue #152 — applied uniformly to loader + main. */
    private HostConfig baseHardenedHostConfig() {
        return HostConfig.newHostConfig()
                .withCapDrop(Capability.values())
                .withSecurityOpts(List.of("no-new-privileges:true", "apparmor=docker-default"))
                .withReadonlyRootfs(true)
                .withPidsLimit(PIDS_LIMIT)
                .withTmpFs(Map.of("/tmp", TMPFS_TMP_OPTS));
    }

    private void cleanup(String loaderId, String volumeName) {
        try {
            dockerClient.removeContainerCmd(loaderId).withForce(true).exec();
        } catch (Exception ignored) { /* best effort */ }
        cleanupVolume(volumeName);
    }

    private void cleanupVolume(String volumeName) {
        try {
            dockerClient.removeVolumeCmd(volumeName).exec();
        } catch (Exception ignored) { /* best effort */ }
    }

    public DockerClient getDockerClient() {
        return dockerClient;
    }

    @Override
    public void stopContainer(String containerId) {
        try {
            dockerClient.stopContainerCmd(containerId).withTimeout(30).exec();
            log.info("Stopped container {}", containerId);
        } catch (Exception e) {
            log.warn("Failed to stop container {}: {}", containerId, e.getMessage());
        }
    }

    @Override
    public void removeContainer(String containerId) {
        // Look up the container name first so we can derive the per-replica
        // volume name. Do this before removing the container — afterward the
        // inspect would 404. Volume removal happens after container removal so
        // no in-flight bind keeps the volume busy.
        String volumeName = null;
        try {
            String name = dockerClient.inspectContainerCmd(containerId).exec().getName();
            if (name != null) {
                // Docker prefixes inspected names with '/'.
                volumeName = "cameleer-jars-" + name.replaceFirst("^/", "");
            }
        } catch (Exception e) {
            log.warn("Could not inspect {} for volume name: {}", containerId, e.getMessage());
        }
        try {
            dockerClient.removeContainerCmd(containerId).withForce(true).exec();
            log.info("Removed container {}", containerId);
        } catch (Exception e) {
            log.warn("Failed to remove container {}: {}", containerId, e.getMessage());
        }
        if (volumeName != null) {
            cleanupVolume(volumeName);
        }
    }

    @Override
    public ContainerStatus getContainerStatus(String containerId) {
        try {
            var inspection = dockerClient.inspectContainerCmd(containerId).exec();
            var state = inspection.getState();
            var health = state.getHealth();
            var healthStatus = health != null ? health.getStatus() : null;
            // Use health status if available, otherwise fall back to container state
            var effectiveState = healthStatus != null ? healthStatus : state.getStatus();
            return new ContainerStatus(
                    effectiveState,
                    Boolean.TRUE.equals(state.getRunning()),
                    state.getExitCodeLong() != null ? state.getExitCodeLong().intValue() : 0,
                    state.getError());
        } catch (Exception e) {
            return new ContainerStatus("not_found", false, -1, e.getMessage());
        }
    }

    @Override
    public Stream<String> getLogs(String containerId, int tailLines) {
        List<String> logLines = new ArrayList<>();
        try {
            dockerClient.logContainerCmd(containerId)
                    .withStdOut(true)
                    .withStdErr(true)
                    .withTail(tailLines)
                    .withTimestamps(true)
                    .exec(new ResultCallback.Adapter<Frame>() {
                        @Override
                        public void onNext(Frame frame) {
                            logLines.add(new String(frame.getPayload()).trim());
                        }
                    }).awaitCompletion();
        } catch (Exception e) {
            log.warn("Failed to get logs for container {}: {}", containerId, e.getMessage());
        }
        return logLines.stream();
    }

    @Override
    public void startLogCapture(String containerId, String instanceId, String appSlug, String envSlug, String tenantId) {
        if (logForwarder != null) {
            logForwarder.startCapture(containerId, instanceId, appSlug, envSlug, tenantId);
        }
    }

    @Override
    public void stopLogCapture(String containerId) {
        if (logForwarder != null) {
            logForwarder.stopCapture(containerId);
        }
    }
}
