package io.cameleer.server.app.license;

import io.cameleer.license.minter.LicenseMinter;
import io.cameleer.server.app.AbstractPostgresIT;
import io.cameleer.server.app.TestSecurityHelper;
import io.cameleer.server.core.license.LicenseGate;
import io.cameleer.license.LicenseInfo;
import io.cameleer.license.LicenseState;
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.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * End-to-end integration test for the license install / persist / revalidate / reject lifecycle.
 *
 * <p>Mints a real Ed25519-signed token via {@code cameleer-license-minter} (test scope), POSTs
 * it through {@code /api/v1/admin/license}, then verifies:
 * <ol>
 *   <li>Gate transitions ABSENT &rarr; ACTIVE.</li>
 *   <li>Row persists in the {@code license} PostgreSQL table.</li>
 *   <li>After {@code gate.clear()}, {@code revalidate()} restores ACTIVE from the persisted token.</li>
 *   <li>A token with a tampered signature is rejected (HTTP 400) and audited as FAILURE
 *       under {@code AuditCategory.LICENSE} without mutating the gate.</li>
 * </ol>
 *
 * <p>The Ed25519 keypair is generated once per JVM and the public key is published as a Spring
 * property via {@code @DynamicPropertySource} (which composes with the JDBC overrides in
 * {@link AbstractPostgresIT}). Scenario 3 from the plan
 * (revalidateAfterPublicKeyChange_marksInvalid) is intentionally skipped — it would require
 * mid-context re-binding of the validator bean, which is more complex than the value warrants.</p>
 */
class LicenseLifecycleIT extends AbstractPostgresIT {

    private static final KeyPair KEY_PAIR = generateKeyPair();

    private static KeyPair generateKeyPair() {
        try {
            return KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("Ed25519 not available", e);
        }
    }

    @DynamicPropertySource
    static void licensePublicKey(DynamicPropertyRegistry registry) {
        registry.add("cameleer.server.license.publickey", () ->
                Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded()));
    }

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private TestSecurityHelper securityHelper;

    @Autowired
    private LicenseGate gate;

    @Autowired
    private LicenseService licenseService;

    @Autowired
    private LicenseRepository licenseRepository;

    private String adminJwt;

    @BeforeEach
    void setUp() {
        adminJwt = securityHelper.adminToken();
        // Sibling ITs may have left state behind.
        gate.clear();
        licenseRepository.delete("default");
        jdbcTemplate.update("DELETE FROM audit_log WHERE category = 'LICENSE'");
    }

    @AfterEach
    void tearDown() {
        gate.clear();
        licenseRepository.delete("default");
    }

    /** Scenario 1 — install via REST, verify gate + DB, clear, revalidate, gate restored. */
    @Test
    void install_persists_andSurvivesGateClear() throws Exception {
        Instant now = Instant.now();
        UUID licenseId = UUID.randomUUID();
        LicenseInfo info = new LicenseInfo(
                licenseId,
                "default",
                "lifecycle-it",
                Map.of("max_apps", 25),
                now,
                now.plus(1, ChronoUnit.DAYS),
                0);
        String token = LicenseMinter.mint(info, KEY_PAIR.getPrivate());

        // POST to /api/v1/admin/license
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("token", token);
        ResponseEntity<String> response = restTemplate.exchange(
                "/api/v1/admin/license", HttpMethod.POST,
                new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
                String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        JsonNode payload = objectMapper.readTree(response.getBody());
        assertThat(payload.path("state").asText()).isEqualTo("ACTIVE");
        assertThat(payload.path("envelope").path("licenseId").asText()).isEqualTo(licenseId.toString());

        // Gate is ACTIVE, parsed envelope matches.
        assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE);
        assertThat(gate.getCurrent()).isNotNull();
        assertThat(gate.getCurrent().licenseId()).isEqualTo(licenseId);

        // Row persisted in PostgreSQL.
        Integer rowCount = jdbcTemplate.queryForObject(
                "SELECT COUNT(*) FROM license WHERE tenant_id = ? AND license_id = ?",
                Integer.class, "default", licenseId);
        assertThat(rowCount).isEqualTo(1);

        // Audit row written under LICENSE category.
        Integer auditCount = jdbcTemplate.queryForObject(
                "SELECT COUNT(*) FROM audit_log WHERE category = 'LICENSE' AND result = 'SUCCESS'",
                Integer.class);
        assertThat(auditCount).isGreaterThanOrEqualTo(1);

        // Now simulate a server restart effect: clear in-memory gate, then revalidate from DB.
        gate.clear();
        assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT);

        licenseService.revalidate();

        assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE);
        assertThat(gate.getCurrent()).isNotNull();
        assertThat(gate.getCurrent().licenseId()).isEqualTo(licenseId);
    }

    /** Scenario 2 — tampered signature → 400 + LICENSE/FAILURE audit row, gate stays ABSENT. */
    @Test
    void postWithBadSignature_returns400_andDoesNotMutateGate() throws Exception {
        Instant now = Instant.now();
        LicenseInfo info = new LicenseInfo(
                UUID.randomUUID(),
                "default",
                "tamper-it",
                Map.of(),
                now,
                now.plus(1, ChronoUnit.DAYS),
                0);
        String token = LicenseMinter.mint(info, KEY_PAIR.getPrivate());
        String tampered = tamperSignature(token);

        // Sanity: gate starts ABSENT.
        assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT);

        Map<String, Object> body = new LinkedHashMap<>();
        body.put("token", tampered);
        ResponseEntity<String> response = restTemplate.exchange(
                "/api/v1/admin/license", HttpMethod.POST,
                new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
                String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
        JsonNode payload = objectMapper.readTree(response.getBody());
        assertThat(payload.path("error").asText()).isNotBlank();

        // Gate moved to INVALID (LicenseService.install() calls gate.markInvalid on validation
        // failure, then re-throws — the controller converts to 400). The DB stays empty.
        assertThat(gate.getState()).isEqualTo(LicenseState.INVALID);
        assertThat(gate.getCurrent()).isNull();
        assertThat(gate.getInvalidReason()).isNotBlank();

        // No persisted row.
        assertThat(licenseRepository.findByTenantId("default")).isEmpty();

        // Exactly one audit row, LICENSE/FAILURE for action 'reject_license'.
        Integer failureCount = jdbcTemplate.queryForObject(
                "SELECT COUNT(*) FROM audit_log " +
                        "WHERE category = 'LICENSE' AND result = 'FAILURE' AND action = 'reject_license'",
                Integer.class);
        assertThat(failureCount).isEqualTo(1);
    }

    /**
     * Flips a single byte in the signature segment of a {@code base64(payload).base64(sig)} token
     * so the Ed25519 verifier fails. Stays decodable as base64 so the parse-format check passes
     * and the failure is reported as a signature-mismatch SecurityException, not a parse error.
     */
    private static String tamperSignature(String token) {
        int dot = token.indexOf('.');
        String payloadB64 = token.substring(0, dot);
        String sigB64 = token.substring(dot + 1);
        byte[] sig = Base64.getDecoder().decode(sigB64);
        sig[0] ^= 0x01;
        return payloadB64 + "." + Base64.getEncoder().encodeToString(sig);
    }
}
