package io.cameleer.server.app.license;

import io.cameleer.server.app.AbstractPostgresIT;
import io.cameleer.server.app.TestSecurityHelper;
import io.cameleer.server.app.storage.PostgresDeploymentRepository;
import io.cameleer.server.core.runtime.Deployment;
import io.cameleer.server.core.runtime.DeploymentStatus;
import io.cameleer.server.core.runtime.RuntimeOrchestrator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

import java.util.UUID;
import java.util.concurrent.TimeUnit;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
 * Verifies that {@code DeploymentExecutor} consults
 * {@link io.cameleer.server.app.license.LicenseEnforcer} for the three compute caps
 * ({@code max_total_cpu_millis}, {@code max_total_memory_mb}, {@code max_total_replicas})
 * during {@code PRE_FLIGHT} and that a violation marks the deployment FAILED with the cap
 * message in {@code deployments.error_message}.
 *
 * <p><b>IT design</b>: HTTP-driven (matches the sibling {@code BlueGreenStrategyIT} /
 * {@code RollingStrategyIT} pattern). We {@code @MockBean} the {@code RuntimeOrchestrator}
 * so no Docker calls happen, but we never need the mock to do anything because the cap
 * check fires <i>before</i> any orchestrator invocation — a successful rejection short-
 * circuits the executor inside the {@code try} block, the catch turns the
 * {@link LicenseCapExceededException} into a FAILED deployment, and the mock stays untouched.</p>
 *
 * <p>Scenario: install a synthetic license that lifts {@code max_apps} / {@code max_environments}
 * (so we can create the env+app), but leaves the compute caps at default
 * ({@code max_total_cpu_millis = 2000}). Configure the app's containerConfig to exceed the
 * default CPU cap (e.g. {@code cpuLimit = 3000}) and trigger a deployment via the HTTP API.
 * Poll until the deployment lands in FAILED with an error message that contains the cap key.</p>
 */
@TestPropertySource(properties = "cameleer.server.runtime.healthchecktimeout=2")
class ComputeCapEnforcementIT extends AbstractPostgresIT {

    @MockBean
    RuntimeOrchestrator runtimeOrchestrator;

    @Autowired private TestRestTemplate restTemplate;
    @Autowired private ObjectMapper objectMapper;
    @Autowired private TestSecurityHelper securityHelper;
    @Autowired private PostgresDeploymentRepository deploymentRepository;

    private String operatorJwt;

    @BeforeEach
    void setUp() {
        operatorJwt = securityHelper.operatorToken();

        // Defensive: prior IT may have left a license installed; we want defaults for compute caps.
        securityHelper.clearTestLicense();

        jdbcTemplate.update("DELETE FROM deployments");
        jdbcTemplate.update("DELETE FROM app_versions");
        jdbcTemplate.update("DELETE FROM apps");
        jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");

        // Ensure test-operator exists in users table (deployments.created_by FK).
        jdbcTemplate.update(
                "INSERT INTO users (user_id, provider, display_name) VALUES " +
                "('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");

        // Mock orchestrator stays passive — cap check rejects before any of these are called,
        // but isEnabled() is consulted by some bean wiring during context startup.
        when(runtimeOrchestrator.isEnabled()).thenReturn(true);
    }

    @AfterEach
    void tearDown() {
        securityHelper.clearTestLicense();
    }

    @Test
    void cpuMillisOverCap_marksDeploymentFailedWithCapMessage() throws Exception {
        // Default tier: max_total_cpu_millis = 2000. Configure cpuLimit = 3000 with replicas = 1
        // so the requested delta (3000) on its own already exceeds the cap.
        String appSlug = "cpucap-" + UUID.randomUUID().toString().substring(0, 8);
        post("/api/v1/environments/default/apps", String.format("""
                {"slug": "%s", "displayName": "CPU Cap App"}
                """, appSlug), operatorJwt);
        put("/api/v1/environments/default/apps/" + appSlug + "/container-config", """
                {"runtimeType": "spring-boot", "appPort": 8081,
                 "replicas": 1, "cpuLimit": 3000,
                 "deploymentStrategy": "blue-green"}
                """, operatorJwt);
        String versionId = uploadJar(appSlug, ("cpucap-jar-" + appSlug).getBytes());

        String deployId = triggerDeploy(appSlug, versionId);
        awaitStatus(deployId, DeploymentStatus.FAILED);

        Deployment d = deploymentRepository.findById(UUID.fromString(deployId)).orElseThrow();
        assertThat(d.errorMessage())
                .as("FAILED deployment should carry the cap key in its error_message")
                .contains("max_total_cpu_millis");

        // Cap rejection happens before any container start — orchestrator must never be touched.
        verify(runtimeOrchestrator, never()).startContainer(any());
    }

    @Test
    void replicasOverCap_marksDeploymentFailedWithCapMessage() throws Exception {
        // Default tier: max_total_replicas = 5. Use cpuLimit = 0 so cpu cap doesn't trip first;
        // memoryLimitMb defaults to global (~512 MB), so 6 replicas = 3072 MB — under the
        // default 2048 MB cap is FALSE, but max_total_memory_mb fires AFTER cpu and BEFORE
        // replicas so we'd hit memory. Set memoryLimitMb low (16 MB * 6 = 96 MB) so memory
        // cap stays well under 2048, isolating the replica cap.
        String appSlug = "repcap-" + UUID.randomUUID().toString().substring(0, 8);
        post("/api/v1/environments/default/apps", String.format("""
                {"slug": "%s", "displayName": "Replica Cap App"}
                """, appSlug), operatorJwt);
        put("/api/v1/environments/default/apps/" + appSlug + "/container-config", """
                {"runtimeType": "spring-boot", "appPort": 8081,
                 "replicas": 6, "memoryLimitMb": 16,
                 "deploymentStrategy": "blue-green"}
                """, operatorJwt);
        String versionId = uploadJar(appSlug, ("repcap-jar-" + appSlug).getBytes());

        String deployId = triggerDeploy(appSlug, versionId);
        awaitStatus(deployId, DeploymentStatus.FAILED);

        Deployment d = deploymentRepository.findById(UUID.fromString(deployId)).orElseThrow();
        assertThat(d.errorMessage())
                .as("FAILED deployment should carry the cap key in its error_message")
                .contains("max_total_replicas");

        verify(runtimeOrchestrator, never()).startContainer(any());
    }

    @Test
    void withinCap_succeedsAndDeployStarts() throws Exception {
        // Default tier: max_total_cpu_millis = 2000, max_total_memory_mb = 2048,
        // max_total_replicas = 5. A single replica with cpuLimit = 1000, memoryLimitMb = 512
        // is well within all three.
        String appSlug = "okcap-" + UUID.randomUUID().toString().substring(0, 8);
        post("/api/v1/environments/default/apps", String.format("""
                {"slug": "%s", "displayName": "OK Cap App"}
                """, appSlug), operatorJwt);
        put("/api/v1/environments/default/apps/" + appSlug + "/container-config", """
                {"runtimeType": "spring-boot", "appPort": 8081,
                 "replicas": 1, "cpuLimit": 1000, "memoryLimitMb": 512,
                 "deploymentStrategy": "blue-green"}
                """, operatorJwt);
        String versionId = uploadJar(appSlug, ("okcap-jar-" + appSlug).getBytes());

        // Mock the orchestrator to make the deploy reach a terminal state quickly.
        // We don't care which state — only that the cap check did NOT short-circuit.
        when(runtimeOrchestrator.startContainer(any())).thenReturn("c-0");
        // getContainerStatus default returns null -> NPE in waitForAllHealthy; stub a starting state
        // so the health check times out (healthchecktimeout=2s) and the deploy lands in FAILED for
        // a different reason (health-check failure, not cap rejection).
        when(runtimeOrchestrator.getContainerStatus(any()))
                .thenReturn(new io.cameleer.server.core.runtime.ContainerStatus("starting", true, 0, null));

        String deployId = triggerDeploy(appSlug, versionId);
        // Either RUNNING (mock made it healthy somehow) or FAILED for a NON-cap reason.
        await().atMost(20, TimeUnit.SECONDS)
                .pollInterval(500, TimeUnit.MILLISECONDS)
                .untilAsserted(() -> {
                    Deployment d = deploymentRepository.findById(UUID.fromString(deployId)).orElseThrow();
                    assertThat(d.status()).isIn(DeploymentStatus.RUNNING, DeploymentStatus.FAILED);
                });

        Deployment d = deploymentRepository.findById(UUID.fromString(deployId)).orElseThrow();
        // Whatever terminal state we hit, the cap check did not flag any compute key.
        if (d.errorMessage() != null) {
            assertThat(d.errorMessage()).doesNotContain("max_total_cpu_millis");
            assertThat(d.errorMessage()).doesNotContain("max_total_memory_mb");
            assertThat(d.errorMessage()).doesNotContain("max_total_replicas");
        }
        // And the orchestrator was actually invoked — proving cap check did not short-circuit.
        verify(runtimeOrchestrator).startContainer(any());
    }

    // ---- helpers (cribbed from BlueGreenStrategyIT) ----

    private String triggerDeploy(String appSlug, String versionId) throws Exception {
        JsonNode deployResponse = post(
                "/api/v1/environments/default/apps/" + appSlug + "/deployments",
                String.format("{\"appVersionId\": \"%s\"}", versionId), operatorJwt);
        return deployResponse.path("id").asText();
    }

    private void awaitStatus(String deployId, DeploymentStatus expected) {
        await().atMost(15, TimeUnit.SECONDS)
                .pollInterval(250, TimeUnit.MILLISECONDS)
                .untilAsserted(() -> {
                    Deployment d = deploymentRepository.findById(UUID.fromString(deployId))
                            .orElseThrow(() -> new AssertionError("Deployment not found: " + deployId));
                    assertThat(d.status()).isEqualTo(expected);
                });
    }

    private JsonNode post(String path, String json, String jwt) throws Exception {
        HttpHeaders headers = securityHelper.authHeaders(jwt);
        var response = restTemplate.exchange(path, HttpMethod.POST,
                new HttpEntity<>(json, headers), String.class);
        return objectMapper.readTree(response.getBody());
    }

    private void put(String path, String json, String jwt) {
        HttpHeaders headers = securityHelper.authHeaders(jwt);
        restTemplate.exchange(path, HttpMethod.PUT,
                new HttpEntity<>(json, headers), String.class);
    }

    private String uploadJar(String appSlug, byte[] content) throws Exception {
        ByteArrayResource resource = new ByteArrayResource(content) {
            @Override public String getFilename() { return "app.jar"; }
        };
        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
        body.add("file", resource);

        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + operatorJwt);
        headers.set("X-Cameleer-Protocol-Version", "1");
        headers.setContentType(MediaType.MULTIPART_FORM_DATA);

        var response = restTemplate.exchange(
                "/api/v1/environments/default/apps/" + appSlug + "/versions",
                HttpMethod.POST, new HttpEntity<>(body, headers), String.class);
        JsonNode versionNode = objectMapper.readTree(response.getBody());
        return versionNode.path("id").asText();
    }
}
