package com.lingniu.ingest.historyapp;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.core.io.FileSystemResource;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Properties;

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

class PortainerComposeResourceLimitsTest {

    @Test
    void portainerStackDocumentsDefaultProductionSurfaceOnly() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));

        assertThat(compose)
                .contains("Default production stack: GB32960, JT808, Yutong MQTT, history, analytics.")
                .contains("Legacy and optional services stay outside this compose file.")
                .doesNotContain("KAFKA_ENABLED")
                .doesNotContain("\n  xinda-push-app:\n")
                .doesNotContain("\n  command-gateway:\n")
                .doesNotContain("\n  vehicle-state-service:\n")
                .doesNotContain("\n  raw-archive-store:\n")
                .doesNotContain("\n  event-file-store:\n");
    }

    @Test
    void productionServicesHaveOverridableMemoryLimits() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));

        assertServiceMemoryLimit(compose, "gb32960-ingest-app", "GB32960_MEM_LIMIT", "768m");
        assertServiceMemoryLimit(compose, "jt808-ingest-app", "JT808_MEM_LIMIT", "768m");
        assertServiceMemoryLimit(compose, "yutong-mqtt-app", "YUTONG_MQTT_MEM_LIMIT", "512m");
        assertServiceMemoryLimit(compose, "vehicle-history-app", "VEHICLE_HISTORY_MEM_LIMIT", "1536m");
        assertServiceMemoryLimit(compose, "vehicle-analytics-app", "VEHICLE_ANALYTICS_MEM_LIMIT", "1024m");
    }

    @Test
    void productionImagesShareOverridableRegistryAndNamespace() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));
        String registry = "${LINGNIU_IMAGE_REGISTRY:-crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com}";
        String namespace = "${LINGNIU_IMAGE_NAMESPACE:-oneos}";

        for (String serviceName : List.of(
                "gb32960-ingest-app",
                "jt808-ingest-app",
                "yutong-mqtt-app",
                "vehicle-history-app",
                "vehicle-analytics-app")) {
            assertThat(serviceBlock(compose, serviceName))
                    .contains("image: " + registry + "/" + namespace + "/" + serviceName
                            + ":${LINGNIU_IMAGE_VERSION:?set LINGNIU_IMAGE_VERSION}");
        }
        assertThat(countOccurrences(compose, "image: crpi-85r4m0ackrm3qpje"))
                .isZero();
        assertThat(countOccurrences(compose, "/oneos/")).isZero();
    }

    @Test
    void portainerServicesUseContainerReadinessHealthchecks() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));
        String dockerfile = Files.readString(repositoryRoot().resolve("Dockerfile"));

        assertThat(dockerfile).contains("apt-get install -y --no-install-recommends curl");
        assertThat(compose)
                .contains("x-readiness-healthcheck: &readiness-healthcheck\n"
                        + "  interval: 30s\n"
                        + "  timeout: 5s\n"
                        + "  retries: 3\n"
                        + "  start_period: 60s");
        assertServiceReadinessHealthcheck(compose, "gb32960-ingest-app", "20100");
        assertServiceReadinessHealthcheck(compose, "jt808-ingest-app", "20400");
        assertServiceReadinessHealthcheck(compose, "yutong-mqtt-app", "20500");
        assertServiceReadinessHealthcheck(compose, "vehicle-history-app", "20200");
        assertServiceReadinessHealthcheck(compose, "vehicle-analytics-app", "20300");
    }

    @Test
    void containerDefaultsIncludeJava25UnsafeCompatibilityFlag() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));
        String dockerfile = Files.readString(repositoryRoot().resolve("Dockerfile"));

        assertThat(dockerfile)
                .contains("ENV JAVA_OPTS=\"--sun-misc-unsafe-memory-access=allow\"");
        assertThat(commonEnvBlock(compose))
                .contains("JAVA_OPTS: ${JAVA_OPTS:---sun-misc-unsafe-memory-access=allow -XX:+UseZGC -XX:MaxRAMPercentage=75}");
    }

    @Test
    void activeAppsExposeHealthProbeGroupsByDefault() {
        Path root = repositoryRoot();
        List<Path> activeAppConfigs = List.of(
                root.resolve("modules/apps/gb32960-ingest-app/src/main/resources/application.yml"),
                root.resolve("modules/apps/jt808-ingest-app/src/main/resources/application.yml"),
                root.resolve("modules/apps/yutong-mqtt-app/src/main/resources/application.yml"),
                root.resolve("modules/apps/vehicle-history-app/src/main/resources/application.yml"),
                root.resolve("modules/apps/vehicle-analytics-app/src/main/resources/application.yml"));

        for (Path config : activeAppConfigs) {
            assertThat(yamlProperties(config))
                    .as(config.toString())
                    .containsEntry("management.endpoint.health.probes.enabled",
                            "${MANAGEMENT_HEALTH_PROBES_ENABLED:true}");
        }
    }

    @Test
    void productionRunbookChecksLivenessAndReadinessForActiveApps() throws IOException {
        String runbook = Files.readString(repositoryRoot()
                .resolve("docs/operations/gb32960-service-split-runbook.md"));

        for (String httpPort : List.of("20100", "20400", "20500", "20200", "20300")) {
            assertThat(runbook)
                    .contains("curl -sS http://127.0.0.1:" + httpPort + "/actuator/health/liveness")
                    .contains("curl -sS http://127.0.0.1:" + httpPort + "/actuator/health/readiness");
        }
    }

    @Test
    void localLaunchctlReadmeChecksLivenessAndReadinessForManagedApps() throws IOException {
        String readme = Files.readString(repositoryRoot()
                .resolve("deploy/local/launchctl/README.md"));

        for (String httpPort : List.of("20100", "20400", "20500", "20200", "20300")) {
            assertThat(readme)
                    .contains("curl -sS http://127.0.0.1:" + httpPort + "/actuator/health/liveness")
                    .contains("curl -sS http://127.0.0.1:" + httpPort + "/actuator/health/readiness");
        }
    }

    @Test
    void localLaunchctlReadmeDocumentsYutongMqttProductionTuning() throws IOException {
        String readme = Files.readString(repositoryRoot()
                .resolve("deploy/local/launchctl/README.md"));

        assertThat(readme)
                .contains("export YUTONG_MQTT_KEEP_ALIVE_SECONDS='20'")
                .contains("export YUTONG_MQTT_CONNECTION_TIMEOUT_SECONDS='10'")
                .contains("export YUTONG_MQTT_TLS_CA_PEM=''")
                .contains("export YUTONG_MQTT_TLS_CLIENT_PEM=''")
                .contains("export YUTONG_MQTT_TLS_CLIENT_KEY=''")
                .contains("export YUTONG_MQTT_TLS_HOSTNAME_VERIFICATION_ENABLED='true'")
                .contains("keepAlive、连接超时和 TLS PEM 参数");
    }

    @Test
    void localLaunchctlTemplatesCoverActiveProductionApps() throws IOException {
        Path launchctl = repositoryRoot().resolve("deploy/local/launchctl");
        String readme = Files.readString(launchctl.resolve("README.md"));

        assertThat(readme)
                .contains("python3 deploy/local/launchctl/render.py")
                .contains("gb32960 jt808 yutong-mqtt vehicle-history vehicle-analytics")
                .contains("mkdir -p /tmp/lingniu-gb32960-live /tmp/lingniu-jt808-live /tmp/lingniu-yutong-mqtt-live /tmp/lingniu-vehicle-history-live /tmp/lingniu-vehicle-analytics-live")
                .contains("launchctl bootstrap \"gui/$(id -u)\" \"$HOME/Library/LaunchAgents/com.lingniu.yutong-mqtt.plist\"")
                .contains("launchctl bootstrap \"gui/$(id -u)\" \"$HOME/Library/LaunchAgents/com.lingniu.vehicle-analytics.plist\"")
                .contains("launchctl bootout \"gui/$(id -u)\" \"$HOME/Library/LaunchAgents/com.lingniu.yutong-mqtt.plist\" || true")
                .contains("launchctl bootout \"gui/$(id -u)\" \"$HOME/Library/LaunchAgents/com.lingniu.vehicle-analytics.plist\" || true")
                .contains("launchctl list | rg 'com.lingniu.(gb32960|jt808|yutong-mqtt|vehicle-history|vehicle-analytics)'")
                .contains("Yutong MQTT ingest health: `http://127.0.0.1:20500/actuator/health`")
                .contains("Analytics metrics: `http://127.0.0.1:20300/swagger-ui/index.html`")
                .doesNotContain("Yutong MQTT ingest: `http://127.0.0.1:20500/swagger-ui/index.html`")
                .doesNotContain("sed \\")
                .doesNotContain("三个服务");

        assertLaunchctlTemplate(launchctl, "gb32960", "gb32960-ingest-app", "20100");
        assertLaunchctlTemplate(launchctl, "jt808", "jt808-ingest-app", "20400");
        assertLaunchctlTemplate(launchctl, "yutong-mqtt", "yutong-mqtt-app", "20500");
        assertLaunchctlTemplate(launchctl, "vehicle-history", "vehicle-history-app", "20200");
        assertLaunchctlTemplate(launchctl, "vehicle-analytics", "vehicle-analytics-app", "20300");
    }

    @Test
    void localLaunchctlRendererPreservesSpecialCharactersSafely() throws Exception {
        Path root = repositoryRoot();
        Path launchctl = root.resolve("deploy/local/launchctl");
        Path home = Files.createTempDirectory("lingniu-launchctl-home");

        ProcessBuilder processBuilder = new ProcessBuilder(
                "python3",
                launchctl.resolve("render.py").toString());
        processBuilder.directory(root.toFile());
        processBuilder.environment().put("HOME", home.toString());
        processBuilder.environment().put("PROJECT_ROOT", "/tmp/lingniu project & live");
        processBuilder.environment().put("JAVA_BIN", "/usr/bin/java");
        processBuilder.environment().put("INGEST_ARCHIVE_PATH", "/tmp/archive&live__v1");
        processBuilder.environment().put("TDENGINE_JDBC_URL", "jdbc:TAOS-WS://host:6041/vehicle_ts");
        processBuilder.environment().put("TDENGINE_USERNAME", "root");
        processBuilder.environment().put("TDENGINE_PASSWORD", "td&engine#pwd");
        processBuilder.environment().put("VEHICLE_IDENTITY_MYSQL_JDBC_URL",
                "jdbc:mysql://host:3306/vehicle_ingest?useSSL=false&serverTimezone=Asia/Shanghai");
        processBuilder.environment().put("VEHICLE_IDENTITY_MYSQL_USERNAME", "lingniu_vehicle");
        processBuilder.environment().put("VEHICLE_IDENTITY_MYSQL_PASSWORD", "id&pwd#1");
        processBuilder.environment().put("MYSQL_JDBC_URL",
                "jdbc:mysql://host:3306/vehicle_stat?useSSL=false&serverTimezone=Asia/Shanghai");
        processBuilder.environment().put("MYSQL_USERNAME", "stat_user");
        processBuilder.environment().put("MYSQL_PASSWORD", "stat&pwd#2");
        processBuilder.environment().put("YUTONG_MQTT_ENABLED", "true");
        processBuilder.environment().put("YUTONG_MQTT_URI", "ssl://mqtt.example.com:8883");
        processBuilder.environment().put("YUTONG_MQTT_TOPIC", "#");
        processBuilder.environment().put("YUTONG_MQTT_USERNAME", "mqtt_user");
        processBuilder.environment().put("YUTONG_MQTT_PASSWORD", "mqtt&pwd#3");
        processBuilder.environment().put("YUTONG_MQTT_KEEP_ALIVE_SECONDS", "45");
        processBuilder.environment().put("YUTONG_MQTT_CONNECTION_TIMEOUT_SECONDS", "12");
        processBuilder.environment().put("YUTONG_MQTT_TLS_CA_PEM", "ca&pem");
        processBuilder.environment().put("YUTONG_MQTT_TLS_CLIENT_PEM", "client<pem>");
        processBuilder.environment().put("YUTONG_MQTT_TLS_CLIENT_KEY", "key&value");
        processBuilder.environment().put("YUTONG_MQTT_TLS_HOSTNAME_VERIFICATION_ENABLED", "false");

        Process process = processBuilder.start();
        String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8)
                + new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);

        assertThat(process.waitFor()).as(output).isZero();

        Path launchAgents = home.resolve("Library/LaunchAgents");
        String mqtt = Files.readString(launchAgents.resolve("com.lingniu.yutong-mqtt.plist"));
        String analytics = Files.readString(launchAgents.resolve("com.lingniu.vehicle-analytics.plist"));

        assertThat(mqtt)
                .contains("<string>#</string>")
                .contains("<string>mqtt&amp;pwd#3</string>")
                .contains("<key>YUTONG_MQTT_KEEP_ALIVE_SECONDS</key>\n    <string>45</string>")
                .contains("<key>YUTONG_MQTT_CONNECTION_TIMEOUT_SECONDS</key>\n    <string>12</string>")
                .contains("<key>YUTONG_MQTT_TLS_CA_PEM</key>\n    <string>ca&amp;pem</string>")
                .contains("<key>YUTONG_MQTT_TLS_CLIENT_PEM</key>\n    <string>client&lt;pem&gt;</string>")
                .contains("<key>YUTONG_MQTT_TLS_CLIENT_KEY</key>\n    <string>key&amp;value</string>")
                .contains("<key>YUTONG_MQTT_TLS_HOSTNAME_VERIFICATION_ENABLED</key>\n    <string>false</string>")
                .contains("<string>/tmp/archive&amp;live__v1</string>")
                .doesNotContain("__YUTONG_MQTT_TOPIC__")
                .doesNotContain("__YUTONG_MQTT_PASSWORD__")
                .doesNotContain("__YUTONG_MQTT_TLS");
        assertThat(analytics)
                .contains("<string>stat&amp;pwd#2</string>")
                .doesNotContain("__MYSQL_PASSWORD__");
        assertThat(Files.list(launchAgents)
                        .filter(path -> path.getFileName().toString().endsWith(".plist"))
                        .count())
                .isEqualTo(5);
    }

    @Test
    void tdengineVerificationRunbookChecksLivenessAndReadinessForManagedApps() throws IOException {
        String runbook = Files.readString(repositoryRoot()
                .resolve("docs/operations/vehicle-ingest-tdengine-verification.md"));

        for (String httpPort : List.of("20100", "20400", "20500", "20200", "20300")) {
            assertThat(runbook)
                    .contains("curl -sS http://127.0.0.1:" + httpPort + "/actuator/health/liveness")
                    .contains("curl -sS http://127.0.0.1:" + httpPort + "/actuator/health/readiness");
        }
    }

    @Test
    void operationRunbooksUseBuildFlagThatSkipsTestCompilation() throws IOException {
        Path operations = repositoryRoot().resolve("docs/operations");
        String splitRunbook = Files.readString(operations.resolve("gb32960-service-split-runbook.md"));
        String tdengineVerification = Files.readString(operations.resolve("vehicle-ingest-tdengine-verification.md"));

        assertThat(splitRunbook)
                .contains("-Dmaven.test.skip=true")
                .doesNotContain("-DskipTests");
        assertThat(tdengineVerification)
                .contains("-Dmaven.test.skip=true")
                .doesNotContain("-DskipTests");
    }

    @Test
    void dockerEntrypointExecsJavaForSignalHandling() throws IOException {
        String dockerfile = Files.readString(repositoryRoot().resolve("Dockerfile"));

        assertThat(dockerfile)
                .contains("ENTRYPOINT [\"sh\", \"-c\", \"exec java $JAVA_OPTS -jar /app/app.jar\"]")
                .doesNotContain("\"java $JAVA_OPTS -jar /app/app.jar\"");
    }

    @Test
    void gb32960ComposeExposesBothPlatformAccountsWithoutSecrets() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));
        String gb32960Service = serviceBlock(compose, "gb32960-ingest-app");

        assertThat(gb32960Service)
                .contains("GB32960_PLATFORM_USER_HYUNDAI: ${GB32960_PLATFORM_USER_HYUNDAI:-Hyundai}")
                .contains("GB32960_PLATFORM_PWD_HYUNDAI: ${GB32960_PLATFORM_PWD_HYUNDAI:-}")
                .contains("GB32960_PLATFORM_IP_HYUNDAI: ${GB32960_PLATFORM_IP_HYUNDAI:-}")
                .contains("GB32960_PLATFORM_USER_YUEJIN: ${GB32960_PLATFORM_USER_YUEJIN:-YueJin}")
                .contains("GB32960_PLATFORM_PWD_YUEJIN: ${GB32960_PLATFORM_PWD_YUEJIN:-}")
                .contains("GB32960_PLATFORM_IP_YUEJIN: ${GB32960_PLATFORM_IP_YUEJIN:-}");
    }

    @Test
    void vehicleHistoryOnlyExposesBaseConsumerGroupEnvironmentName() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));
        String historyService = serviceBlock(compose, "vehicle-history-app");

        assertThat(historyService)
                .contains("KAFKA_GROUP_HISTORY: ${KAFKA_GROUP_HISTORY:-vehicle-history}")
                .doesNotContain("KAFKA_GROUP_HISTORY_GB32960_EVENT")
                .doesNotContain("KAFKA_GROUP_HISTORY_JT808_EVENT")
                .doesNotContain("KAFKA_GROUP_HISTORY_YUTONG_MQTT_EVENT")
                .doesNotContain("KAFKA_GROUP_HISTORY_GB32960_RAW")
                .doesNotContain("KAFKA_GROUP_HISTORY_JT808_RAW")
                .doesNotContain("KAFKA_GROUP_HISTORY_YUTONG_MQTT_RAW");
    }

    @Test
    void kafkaConsumerEnvironmentIsScopedToConsumerAppsOnly() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));
        String commonEnv = commonEnvBlock(compose);
        String ingestServices = serviceBlock(compose, "gb32960-ingest-app")
                + serviceBlock(compose, "jt808-ingest-app")
                + serviceBlock(compose, "yutong-mqtt-app");
        String historyService = serviceBlock(compose, "vehicle-history-app");
        String analyticsService = serviceBlock(compose, "vehicle-analytics-app");

        assertThat(commonEnv)
                .doesNotContain("KAFKA_CONSUMER_ENABLED")
                .doesNotContain("KAFKA_CONSUMER_MAX_POLL_INTERVAL");
        assertThat(ingestServices)
                .doesNotContain("KAFKA_CONSUMER_");
        assertThat(historyService)
                .contains("KAFKA_CONSUMER_ENABLED: ${KAFKA_CONSUMER_ENABLED_HISTORY:-true}")
                .contains("KAFKA_CONSUMER_MAX_POLL_INTERVAL_MILLIS: ${KAFKA_CONSUMER_MAX_POLL_INTERVAL_MILLIS_HISTORY:-1800000}")
                .doesNotContain("KAFKA_CONSUMER_MAX_POLL_INTERVAL_MS");
        assertThat(analyticsService)
                .contains("KAFKA_CONSUMER_ENABLED: ${KAFKA_CONSUMER_ENABLED_ANALYTICS:-true}")
                .contains("KAFKA_CONSUMER_MAX_POLL_INTERVAL_MILLIS: ${KAFKA_CONSUMER_MAX_POLL_INTERVAL_MILLIS_ANALYTICS:-900000}");
    }

    @Test
    void kafkaTopicEnvironmentIsScopedToServicesThatUseIt() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));
        String commonEnv = commonEnvBlock(compose);
        String gb32960Service = serviceBlock(compose, "gb32960-ingest-app");
        String jt808Service = serviceBlock(compose, "jt808-ingest-app");
        String mqttService = serviceBlock(compose, "yutong-mqtt-app");
        String historyService = serviceBlock(compose, "vehicle-history-app");
        String analyticsService = serviceBlock(compose, "vehicle-analytics-app");

        assertThat(commonEnv).doesNotContain("KAFKA_TOPIC_");
        assertThat(gb32960Service)
                .contains("KAFKA_TOPIC_GB32960_EVENT: ${KAFKA_TOPIC_GB32960_EVENT:-vehicle.event.gb32960.v1}")
                .contains("KAFKA_TOPIC_GB32960_RAW: ${KAFKA_TOPIC_GB32960_RAW:-vehicle.raw.gb32960.v1}")
                .contains("KAFKA_TOPIC_GB32960_DLQ: ${KAFKA_TOPIC_GB32960_DLQ:-vehicle.dlq.gb32960.v1}")
                .doesNotContain("KAFKA_TOPIC_JT808")
                .doesNotContain("KAFKA_TOPIC_YUTONG_MQTT");
        assertThat(jt808Service)
                .contains("KAFKA_TOPIC_JT808_EVENT: ${KAFKA_TOPIC_JT808_EVENT:-vehicle.event.jt808.v1}")
                .contains("KAFKA_TOPIC_JT808_RAW: ${KAFKA_TOPIC_JT808_RAW:-vehicle.raw.jt808.v1}")
                .contains("KAFKA_TOPIC_JT808_DLQ: ${KAFKA_TOPIC_JT808_DLQ:-vehicle.dlq.jt808.v1}")
                .doesNotContain("KAFKA_TOPIC_GB32960")
                .doesNotContain("KAFKA_TOPIC_YUTONG_MQTT");
        assertThat(mqttService)
                .contains("KAFKA_TOPIC_YUTONG_MQTT_EVENT: ${KAFKA_TOPIC_YUTONG_MQTT_EVENT:-vehicle.event.mqtt-yutong.v1}")
                .contains("KAFKA_TOPIC_YUTONG_MQTT_RAW: ${KAFKA_TOPIC_YUTONG_MQTT_RAW:-vehicle.raw.mqtt-yutong.v1}")
                .contains("KAFKA_TOPIC_YUTONG_MQTT_DLQ: ${KAFKA_TOPIC_YUTONG_MQTT_DLQ:-vehicle.dlq.mqtt-yutong.v1}")
                .doesNotContain("KAFKA_TOPIC_GB32960")
                .doesNotContain("KAFKA_TOPIC_JT808");
        assertThat(historyService)
                .contains("KAFKA_TOPIC_GB32960_EVENT: ${KAFKA_TOPIC_GB32960_EVENT:-vehicle.event.gb32960.v1}")
                .contains("KAFKA_TOPIC_GB32960_RAW: ${KAFKA_TOPIC_GB32960_RAW:-vehicle.raw.gb32960.v1}")
                .contains("KAFKA_TOPIC_JT808_EVENT: ${KAFKA_TOPIC_JT808_EVENT:-vehicle.event.jt808.v1}")
                .contains("KAFKA_TOPIC_JT808_RAW: ${KAFKA_TOPIC_JT808_RAW:-vehicle.raw.jt808.v1}")
                .contains("KAFKA_TOPIC_YUTONG_MQTT_EVENT: ${KAFKA_TOPIC_YUTONG_MQTT_EVENT:-vehicle.event.mqtt-yutong.v1}")
                .contains("KAFKA_TOPIC_YUTONG_MQTT_RAW: ${KAFKA_TOPIC_YUTONG_MQTT_RAW:-vehicle.raw.mqtt-yutong.v1}")
                .doesNotContain("KAFKA_TOPIC_GB32960_DLQ")
                .doesNotContain("KAFKA_TOPIC_JT808_DLQ")
                .doesNotContain("KAFKA_TOPIC_YUTONG_MQTT_DLQ");
        assertThat(analyticsService)
                .contains("KAFKA_TOPIC_JT808_EVENT: ${KAFKA_TOPIC_JT808_EVENT:-vehicle.event.jt808.v1}")
                .contains("KAFKA_TOPIC_JT808_DLQ: ${KAFKA_TOPIC_JT808_DLQ:-vehicle.dlq.jt808.v1}")
                .doesNotContain("KAFKA_TOPIC_GB32960")
                .doesNotContain("KAFKA_TOPIC_YUTONG_MQTT")
                .doesNotContain("KAFKA_TOPIC_JT808_RAW");
    }

    @Test
    void vehicleHistoryUsesTdengineHistoryEnvironmentNames() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));

        assertThat(compose)
                .contains("TDENGINE_HISTORY_ENABLED: ${TDENGINE_HISTORY_ENABLED:-true}")
                .contains("TDENGINE_HISTORY_DATABASE: ${TDENGINE_HISTORY_DATABASE:-vehicle_ts}")
                .doesNotContain("TDENGINE_TELEMETRY_FIELDS_ENABLED")
                .doesNotContain("\n      TDENGINE_ENABLED:")
                .doesNotContain("\n      TDENGINE_DATABASE:");
    }

    @Test
    void vehicleHistoryDoesNotExposeUnusedArchiveSinkSettings() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));
        String historyService = serviceBlock(compose, "vehicle-history-app");

        assertThat(historyService)
                .doesNotContain("SINK_ARCHIVE_ENABLED")
                .doesNotContain("SINK_ARCHIVE_PATH")
                .doesNotContain("/archive");
        assertThat(compose)
                .doesNotContain("vehicle-history-archive:");
    }

    @Test
    void portainerIngestAppsWriteColdArchiveToMountedDataVolume() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));
        String gb32960Service = serviceBlock(compose, "gb32960-ingest-app");
        String jt808Service = serviceBlock(compose, "jt808-ingest-app");
        String mqttService = serviceBlock(compose, "yutong-mqtt-app");

        assertThat(gb32960Service)
                .contains("SINK_ARCHIVE_PATH: ${GB32960_ARCHIVE_PATH:-/data/archive}")
                .contains("- gb32960-ingest-data:/data");
        assertThat(jt808Service)
                .contains("SINK_ARCHIVE_PATH: ${JT808_ARCHIVE_PATH:-/data/archive}")
                .contains("- jt808-ingest-data:/data");
        assertThat(mqttService)
                .contains("SINK_ARCHIVE_PATH: ${YUTONG_MQTT_ARCHIVE_PATH:-/data/archive}")
                .contains("- yutong-mqtt-data:/data");
        assertThat(compose).contains("\n  yutong-mqtt-data:\n");
    }

    @Test
    void localLaunchctlHistoryRuntimeDoesNotRequireSharedArchiveRoot() throws IOException {
        Path launchctl = repositoryRoot().resolve("deploy/local/launchctl");
        String historyTemplate = Files.readString(launchctl.resolve("com.lingniu.vehicle-history.plist.template"));
        String readme = Files.readString(launchctl.resolve("README.md"));

        assertThat(historyTemplate)
                .doesNotContain("SINK_ARCHIVE_PATH")
                .doesNotContain("__SHARED_ARCHIVE_PATH__");
        assertThat(readme)
                .doesNotContain("三个服务必须使用同一个 `SINK_ARCHIVE_PATH`")
                .doesNotContain("history 服务按同一个 archive root 读取原始帧")
                .doesNotContain("EventFileStore")
                .doesNotContain("/api/event-history/records");
    }

    @Test
    void localLaunchctlJt808UsesCurrentHttpPort() throws IOException {
        Path launchctl = repositoryRoot().resolve("deploy/local/launchctl");
        String jt808Template = Files.readString(launchctl.resolve("com.lingniu.jt808.plist.template"));
        String readme = Files.readString(launchctl.resolve("README.md"));

        assertThat(jt808Template)
                .contains("<key>HTTP_PORT</key>\n    <string>20400</string>")
                .doesNotContain("20482");
        assertThat(readme)
                .contains("`jt808-ingest-app`: TCP `808`，HTTP `20400`")
                .contains("curl -sS http://127.0.0.1:20400/actuator/health")
                .contains("JT808 ingest health: `http://127.0.0.1:20400/actuator/health`")
                .doesNotContain("JT808 ingest: `http://127.0.0.1:20400/swagger-ui/index.html`")
                .doesNotContain("20482");
    }

    @Test
    void operationRunbooksDescribeTdengineRawJsonHotPath() throws IOException {
        Path operations = repositoryRoot().resolve("docs/operations");
        String tdengineVerification = Files.readString(operations.resolve("vehicle-ingest-tdengine-verification.md"));
        String splitRunbook = Files.readString(operations.resolve("gb32960-service-split-runbook.md"));

        assertThat(tdengineVerification)
                .contains("TDengine `raw_frames`")
                .doesNotContain("20482")
                .doesNotContain("三个服务必须共用同一个 `SINK_ARCHIVE_PATH`")
                .doesNotContain("再回读共享 archive")
                .doesNotContain("读取共享 archive 中的原始 `.bin`")
                .doesNotContain("EVENT_FILE_STORE_ENABLED")
                .doesNotContain("EVENT_FILE_STORE_PATH")
                .doesNotContain("TDENGINE_TELEMETRY_FIELDS_ENABLED")
                .doesNotContain("/api/event-history/telemetry/fields' 会存在")
                .doesNotContain("/api/event-history/records");
        assertThat(splitRunbook)
                .contains("TDengine `raw_frames`")
                .doesNotContain("must share the same `SINK_ARCHIVE_PATH`")
                .doesNotContain("history can only read raw frames")
                .doesNotContain("shared volume mounted to all three services");
    }

    @Test
    void splitRunbookDocumentsAllActiveProductionAppsAndTopics() throws IOException {
        String splitRunbook = Files.readString(repositoryRoot()
                .resolve("docs/operations/gb32960-service-split-runbook.md"));
        String activeBuildCommand =
                "mvn -pl :gb32960-ingest-app,:jt808-ingest-app,:yutong-mqtt-app,:vehicle-history-app,:vehicle-analytics-app -am package -Dmaven.test.skip=true";

        assertThat(splitRunbook)
                .contains("| Yutong MQTT ingest | `:yutong-mqtt-app`")
                .contains("`vehicle.raw.mqtt-yutong.v1`")
                .contains("`vehicle.event.mqtt-yutong.v1`")
                .contains("`vehicle.dlq.mqtt-yutong.v1`")
                .contains(activeBuildCommand)
                .contains("modules/apps/yutong-mqtt-app/target/yutong-mqtt-app.jar")
                .doesNotContain("20482")
                .doesNotContain("mvn -pl :gb32960-ingest-app,:vehicle-history-app,:vehicle-analytics-app")
                .doesNotContain("mvn -pl :gb32960-ingest-app,:jt808-ingest-app,:vehicle-history-app,:vehicle-analytics-app")
                .doesNotContain("repackaged all three app jars");
    }

    @Test
    void tdengineVerificationRunbookDocumentsAllActiveProductionApps() throws IOException {
        String runbook = Files.readString(repositoryRoot()
                .resolve("docs/operations/vehicle-ingest-tdengine-verification.md"));
        String activeBuildCommand =
                "mvn -pl :gb32960-ingest-app,:jt808-ingest-app,:yutong-mqtt-app,:vehicle-history-app,:vehicle-analytics-app -am package -Dmaven.test.skip=true";

        assertThat(runbook)
                .contains(activeBuildCommand)
                .contains("modules/apps/gb32960-ingest-app/target/gb32960-ingest-app.jar")
                .contains("modules/apps/jt808-ingest-app/target/jt808-ingest-app.jar")
                .contains("modules/apps/yutong-mqtt-app/target/yutong-mqtt-app.jar")
                .contains("modules/apps/vehicle-history-app/target/vehicle-history-app.jar")
                .contains("modules/apps/vehicle-analytics-app/target/vehicle-analytics-app.jar")
                .contains("vehicle.raw.mqtt-yutong.v1")
                .contains("vehicle.event.mqtt-yutong.v1")
                .contains("vehicle-history-yutong-mqtt-event")
                .contains("vehicle-history-yutong-mqtt-raw")
                .contains("vehicle-analytics-app")
                .doesNotContain("mvn -pl modules/apps/jt808-ingest-app,modules/apps/gb32960-ingest-app,modules/apps/vehicle-history-app")
                .doesNotContain("健康检查已确认三个服务");
    }

    @Test
    void architectureDocsNameKafkaInsteadOfGenericMq() throws IOException {
        String decisions = Files.readString(repositoryRoot().resolve("DECISIONS.md"));
        String dataFlow = Files.readString(repositoryRoot().resolve("docs/module-data-flow.html"));

        assertThat(decisions)
                .contains("生产链路只支持 Kafka")
                .doesNotContain("MQ 选型")
                .doesNotContain("RocketMQ");
        assertThat(dataFlow)
                .contains("participant Kafka as sink-kafka")
                .doesNotContain("participant MQ")
                .doesNotContain("->>MQ")
                .doesNotContain("MQ->>");
    }

    @Test
    void activeSpringBootAppsDoNotScanWholeIngestPackage() throws IOException {
        Path root = repositoryRoot();
        List<Path> activeApps = List.of(
                root.resolve("modules/apps/gb32960-ingest-app/src/main/java/com/lingniu/ingest/gb32960app/Gb32960IngestApplication.java"),
                root.resolve("modules/apps/jt808-ingest-app/src/main/java/com/lingniu/ingest/jt808app/Jt808IngestApplication.java"),
                root.resolve("modules/apps/yutong-mqtt-app/src/main/java/com/lingniu/ingest/yutongmqttapp/YutongMqttApplication.java"),
                root.resolve("modules/apps/vehicle-history-app/src/main/java/com/lingniu/ingest/historyapp/VehicleHistoryApplication.java"),
                root.resolve("modules/apps/vehicle-analytics-app/src/main/java/com/lingniu/ingest/analyticsapp/VehicleAnalyticsApplication.java"));

        for (Path app : activeApps) {
            assertThat(Files.readString(app))
                    .contains("@SpringBootApplication")
                    .doesNotContain("scanBasePackages = \"com.lingniu.ingest\"");
        }
    }

    @Test
    void splitRunbookKeepsAnalyticsAsJt808MetricRuntimeOnly() throws IOException {
        String splitRunbook = Files.readString(repositoryRoot()
                .resolve("docs/operations/gb32960-service-split-runbook.md"));

        assertThat(splitRunbook)
                .contains("Consume JT808 event Kafka records")
                .contains("vehicle_stat_metric")
                .doesNotContain("Analytics state:")
                .doesNotContain("VEHICLE_STATE_ENABLED")
                .doesNotContain("latest-state API in analytics")
                .doesNotContain("Vehicle state can also be enabled from this runtime");
    }

    @Test
    void splitRunbookUsesTdengineHistoryVerificationInsteadOfLegacyRecordApi() throws IOException {
        String splitRunbook = Files.readString(repositoryRoot()
                .resolve("docs/operations/gb32960-service-split-runbook.md"));

        assertThat(splitRunbook)
                .contains("TDENGINE_HISTORY_ENABLED=true")
                .contains("SELECT COUNT(*) FROM raw_frames")
                .contains("SELECT COUNT(*) FROM vehicle_locations")
                .doesNotContain("EVENT_FILE_STORE_PATH")
                .doesNotContain("target/split-event-store")
                .doesNotContain("event-store files")
                .doesNotContain("/api/event-history/records?");
    }

    @Test
    void vehicleAnalyticsComposeOnlyExposesStatConsumerEnvironment() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));
        String analyticsService = serviceBlock(compose, "vehicle-analytics-app");

        assertThat(analyticsService)
                .contains("KAFKA_GROUP_STAT: ${KAFKA_GROUP_STAT:-vehicle-stat}")
                .contains("VEHICLE_STAT_ENABLED: ${VEHICLE_STAT_ENABLED:-true}")
                .contains("VEHICLE_STAT_JT808_MILEAGE_ENABLED: ${VEHICLE_STAT_JT808_MILEAGE_ENABLED:-true}")
                .doesNotContain("KAFKA_GROUP_STATE")
                .doesNotContain("VEHICLE_STATE_ENABLED");
    }

    @Test
    void vehicleAnalyticsComposeDependsOnlyOnJt808Ingest() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));
        String analyticsService = serviceBlock(compose, "vehicle-analytics-app");

        assertThat(analyticsService)
                .contains("    depends_on:\n      - jt808-ingest-app")
                .doesNotContain("- gb32960-ingest-app")
                .doesNotContain("- yutong-mqtt-app");
    }

    @Test
    void vehicleAnalyticsDoesNotInheritRedisStateEnvironment() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));
        String commonEnv = commonEnvBlock(compose);
        String analyticsService = serviceBlock(compose, "vehicle-analytics-app");

        assertThat(analyticsService).contains("<<: *common-env");
        assertThat(commonEnv)
                .doesNotContain("REDIS_HOST")
                .doesNotContain("REDIS_PORT")
                .doesNotContain("REDIS_DATABASE")
                .doesNotContain("REDIS_USERNAME")
                .doesNotContain("REDIS_PASSWORD");
    }

    @Test
    void mysqlEnvironmentIsScopedToIdentityAndAnalyticsOnly() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));
        String commonEnv = commonEnvBlock(compose);
        String identityEnv = blockBetween(compose, "x-identity-mysql-env: &identity-mysql-env\n",
                "\nx-redis-session-env:");
        String ingestServices = serviceBlock(compose, "gb32960-ingest-app")
                + serviceBlock(compose, "jt808-ingest-app")
                + serviceBlock(compose, "yutong-mqtt-app");
        String analyticsService = serviceBlock(compose, "vehicle-analytics-app");

        assertThat(commonEnv)
                .doesNotContain("MYSQL_JDBC_URL")
                .doesNotContain("MYSQL_USERNAME")
                .doesNotContain("MYSQL_PASSWORD");
        assertThat(identityEnv)
                .contains("VEHICLE_IDENTITY_MYSQL_JDBC_URL: ${MYSQL_JDBC_URL:-}")
                .contains("VEHICLE_IDENTITY_MYSQL_USERNAME: ${MYSQL_USERNAME:-}")
                .contains("VEHICLE_IDENTITY_MYSQL_PASSWORD: ${MYSQL_PASSWORD:-}");
        assertThat(ingestServices)
                .doesNotContain("\n      MYSQL_JDBC_URL:")
                .doesNotContain("\n      MYSQL_USERNAME:")
                .doesNotContain("\n      MYSQL_PASSWORD:");
        assertThat(analyticsService)
                .contains("MYSQL_JDBC_URL: ${MYSQL_JDBC_URL:-}")
                .contains("MYSQL_USERNAME: ${MYSQL_USERNAME:-}")
                .contains("MYSQL_PASSWORD: ${MYSQL_PASSWORD:-}");
    }

    @Test
    void ingestAppsReuseSharedIdentityAndRedisEnvironmentAnchors() throws IOException {
        String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml"));
        String gb32960Service = serviceBlock(compose, "gb32960-ingest-app");
        String jt808Service = serviceBlock(compose, "jt808-ingest-app");
        String mqttService = serviceBlock(compose, "yutong-mqtt-app");

        assertThat(compose)
                .contains("x-identity-mysql-env: &identity-mysql-env")
                .contains("x-redis-session-env: &redis-session-env")
                .doesNotContain("VEHICLE_IDENTITY_STORE");
        assertThat(gb32960Service)
                .contains("- *identity-mysql-env")
                .contains("- *redis-session-env");
        assertThat(jt808Service)
                .contains("- *identity-mysql-env")
                .contains("- *redis-session-env");
        assertThat(mqttService)
                .contains("- *identity-mysql-env")
                .doesNotContain("- *redis-session-env");

        assertThat(gb32960Service + jt808Service + mqttService)
                .doesNotContain("VEHICLE_IDENTITY_MYSQL_JDBC_URL")
                .doesNotContain("VEHICLE_IDENTITY_MYSQL_USERNAME")
                .doesNotContain("VEHICLE_IDENTITY_MYSQL_PASSWORD")
                .doesNotContain("REDIS_HOST")
                .doesNotContain("REDIS_PORT")
                .doesNotContain("REDIS_DATABASE")
                .doesNotContain("REDIS_USERNAME")
                .doesNotContain("REDIS_PASSWORD");
    }

    @Test
    void moduleDataFlowShowsSplitProductionAppsInsteadOfDeletedBootstrapAll() throws IOException {
        String moduleDataFlow = Files.readString(repositoryRoot().resolve("docs/module-data-flow.html"));

        assertThat(moduleDataFlow)
                .contains("gb32960-ingest-app")
                .contains("jt808-ingest-app")
                .contains("yutong-mqtt-app")
                .contains("vehicle-history-app")
                .contains("vehicle-analytics-app")
                .doesNotContain("bootstrap-all");
    }

    private static void assertServiceMemoryLimit(String compose,
                                                 String serviceName,
                                                 String envName,
                                                 String defaultLimit) {
        assertThat(compose)
                .contains(serviceName + ":\n")
                .contains("\n  " + serviceName + ":\n")
                .contains("    mem_limit: ${" + envName + ":-" + defaultLimit + "}");
    }

    private static void assertServiceReadinessHealthcheck(String compose, String serviceName, String httpPort) {
        assertThat(serviceBlock(compose, serviceName))
                .contains("    healthcheck:\n")
                .contains("      <<: *readiness-healthcheck\n")
                .contains("      test: [\"CMD-SHELL\", \"curl -fsS http://127.0.0.1:"
                        + httpPort + "/actuator/health/readiness >/dev/null\"]")
                .doesNotContain("      interval: 30s\n")
                .doesNotContain("      timeout: 5s\n")
                .doesNotContain("      retries: 3\n")
                .doesNotContain("      start_period: 60s\n");
    }

    private static void assertLaunchctlTemplate(Path launchctl,
                                                String serviceName,
                                                String appName,
                                                String httpPort) throws IOException {
        Path template = launchctl.resolve("com.lingniu." + serviceName + ".plist.template");
        assertThat(template).exists();
        assertThat(Files.readString(template))
                .contains("<string>com.lingniu." + serviceName + "</string>")
                .contains("__PROJECT_ROOT__/modules/apps/" + appName + "/target/" + appName + ".jar")
                .contains("<key>HTTP_PORT</key>\n    <string>" + httpPort + "</string>")
                .contains("<key>NACOS_CONFIG_ENABLED</key>\n    <string>false</string>")
                .contains("<key>MANAGEMENT_HEALTH_REDIS_ENABLED</key>\n    <string>false</string>")
                .contains("/tmp/lingniu-" + serviceName + "-live/")
                .doesNotContain("<key>VEHICLE_IDENTITY_STORE</key>")
                .doesNotContain("<key>KAFKA_ENABLED</key>")
                .doesNotContain("<key>TDENGINE_INITIALIZE_SCHEMA</key>");
    }

    private static String serviceBlock(String compose, String serviceName) {
        String marker = "\n  " + serviceName + ":\n";
        int start = compose.indexOf(marker);
        if (start < 0) {
            throw new IllegalArgumentException("service not found: " + serviceName);
        }
        for (int cursor = start + marker.length(); cursor < compose.length(); ) {
            int nextLine = compose.indexOf('\n', cursor);
            if (nextLine < 0) {
                return compose.substring(start);
            }
            int lineStart = nextLine + 1;
            if (lineStart + 2 < compose.length()
                    && compose.charAt(lineStart) == ' '
                    && compose.charAt(lineStart + 1) == ' '
                    && compose.charAt(lineStart + 2) != ' ') {
                return compose.substring(start, nextLine);
            }
            cursor = lineStart;
        }
        return compose.substring(start);
    }

    private static String commonEnvBlock(String compose) {
        String marker = "x-common-env: &common-env\n";
        int start = compose.indexOf(marker);
        if (start < 0) {
            throw new IllegalArgumentException("common env block not found");
        }
        int end = compose.indexOf("\nx-identity-mysql-env:", start);
        if (end < 0) {
            end = compose.indexOf("\nservices:\n", start);
        }
        if (end < 0) {
            throw new IllegalArgumentException("services block not found");
        }
        return compose.substring(start, end);
    }

    private static String blockBetween(String text, String startMarker, String endMarker) {
        int start = text.indexOf(startMarker);
        if (start < 0) {
            throw new IllegalArgumentException("block start not found: " + startMarker);
        }
        int end = text.indexOf(endMarker, start + startMarker.length());
        if (end < 0) {
            throw new IllegalArgumentException("block end not found: " + endMarker);
        }
        return text.substring(start, end);
    }

    private static int countOccurrences(String text, String needle) {
        int count = 0;
        int index = 0;
        while ((index = text.indexOf(needle, index)) >= 0) {
            count++;
            index += needle.length();
        }
        return count;
    }

    private static Properties yamlProperties(Path path) {
        YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean();
        factory.setResources(new FileSystemResource(path));
        Properties properties = factory.getObject();
        assertThat(properties).isNotNull();
        return properties;
    }

    private static Path repositoryRoot() {
        Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath();
        while (current != null) {
            if (Files.exists(current.resolve("deploy/portainer/docker-compose.yml"))) {
                return current;
            }
            current = current.getParent();
        }
        throw new IllegalStateException("repository root not found");
    }
}
