package com.cameleer.server.app.runtime;

import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
import com.cameleer.server.core.runtime.ContainerStatus;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentStatus;
import com.cameleer.server.core.runtime.RuntimeOrchestrator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
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.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
 * Verifies the rolling deployment strategy: per-replica start → health → stop
 * old. Mid-rollout health failure preserves remaining un-replaced old replicas;
 * already-stopped old replicas are not restored.
 */
@TestPropertySource(properties = "cameleer.server.runtime.healthchecktimeout=2")
class RollingStrategyIT extends AbstractPostgresIT {

    @MockBean
    RuntimeOrchestrator runtimeOrchestrator;

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

    private String operatorJwt;
    private String appSlug;
    private String versionId;

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

        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 (required for 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");

        when(runtimeOrchestrator.isEnabled()).thenReturn(true);

        appSlug = "roll-" + UUID.randomUUID().toString().substring(0, 8);
        post("/api/v1/environments/default/apps", String.format("""
                {"slug": "%s", "displayName": "Rolling App"}
                """, appSlug), operatorJwt);
        put("/api/v1/environments/default/apps/" + appSlug + "/container-config", """
                {"runtimeType": "spring-boot", "appPort": 8081, "replicas": 2, "deploymentStrategy": "rolling"}
                """, operatorJwt);
        versionId = uploadJar(appSlug, ("roll-jar-" + appSlug).getBytes());
    }

    @Test
    void rolling_allHealthy_replacesOneByOne() throws Exception {
        when(runtimeOrchestrator.startContainer(any()))
                .thenReturn("old-0", "old-1", "new-0", "new-1");
        ContainerStatus healthy = new ContainerStatus("healthy", true, 0, null);
        when(runtimeOrchestrator.getContainerStatus("old-0")).thenReturn(healthy);
        when(runtimeOrchestrator.getContainerStatus("old-1")).thenReturn(healthy);
        when(runtimeOrchestrator.getContainerStatus("new-0")).thenReturn(healthy);
        when(runtimeOrchestrator.getContainerStatus("new-1")).thenReturn(healthy);

        String firstDeployId = triggerDeploy();
        awaitStatus(firstDeployId, DeploymentStatus.RUNNING);

        String secondDeployId = triggerDeploy();
        awaitStatus(secondDeployId, DeploymentStatus.RUNNING);

        // Rolling invariant: old-0 is stopped BEFORE old-1 (replicas replaced
        // one at a time, not all at once). Checking stop order is sufficient —
        // a blue-green path would have both stops adjacent at the end with no
        // interleaved starts; rolling interleaves starts between stops.
        InOrder inOrder = inOrder(runtimeOrchestrator);
        inOrder.verify(runtimeOrchestrator).stopContainer("old-0");
        inOrder.verify(runtimeOrchestrator).stopContainer("old-1");

        // Total of 4 startContainer calls: 2 for first deploy, 2 for rolling.
        verify(runtimeOrchestrator, times(4)).startContainer(any());
        // New replicas were not stopped — they're the running ones now.
        verify(runtimeOrchestrator, never()).stopContainer("new-0");
        verify(runtimeOrchestrator, never()).stopContainer("new-1");

        Deployment first = deploymentRepository.findById(UUID.fromString(firstDeployId)).orElseThrow();
        assertThat(first.status()).isEqualTo(DeploymentStatus.STOPPED);
    }

    @Test
    void rolling_failsMidRollout_preservesRemainingOld() throws Exception {
        when(runtimeOrchestrator.startContainer(any()))
                .thenReturn("old-0", "old-1", "new-0", "new-1");
        ContainerStatus healthy = new ContainerStatus("healthy", true, 0, null);
        ContainerStatus starting = new ContainerStatus("starting", true, 0, null);
        when(runtimeOrchestrator.getContainerStatus("old-0")).thenReturn(healthy);
        when(runtimeOrchestrator.getContainerStatus("old-1")).thenReturn(healthy);
        when(runtimeOrchestrator.getContainerStatus("new-0")).thenReturn(healthy);
        when(runtimeOrchestrator.getContainerStatus("new-1")).thenReturn(starting);

        String firstDeployId = triggerDeploy();
        awaitStatus(firstDeployId, DeploymentStatus.RUNNING);

        String secondDeployId = triggerDeploy();
        awaitStatus(secondDeployId, DeploymentStatus.FAILED);

        Deployment second = deploymentRepository.findById(UUID.fromString(secondDeployId)).orElseThrow();
        assertThat(second.errorMessage())
                .contains("rolling")
                .contains("replica 1");

        // old-0 was replaced before the failure; old-1 was never touched.
        verify(runtimeOrchestrator).stopContainer("old-0");
        verify(runtimeOrchestrator, never()).stopContainer("old-1");
        // Cleanup stops both new replicas started so far.
        verify(runtimeOrchestrator).stopContainer("new-0");
        verify(runtimeOrchestrator).stopContainer("new-1");
    }

    // ---- helpers (same pattern as BlueGreenStrategyIT) ----

    private String triggerDeploy() 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(30, TimeUnit.SECONDS)
                .pollInterval(500, 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();
    }
}
