package com.lingniu.ingest.eventhistory;

import com.lingniu.ingest.codec.BccChecksum;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcDcDcBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcStackBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.profile.Gb32960ProfileRegistry;
import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionCatalog;
import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionSelector;
import com.lingniu.ingest.tdenginehistory.TdengineHistoryReader;
import com.lingniu.ingest.tdenginehistory.TdengineLocationQuery;
import com.lingniu.ingest.tdenginehistory.TdengineLocationRow;
import com.lingniu.ingest.tdenginehistory.TdenginePage;
import com.lingniu.ingest.tdenginehistory.TdenginePageCursor;
import com.lingniu.ingest.tdenginehistory.TdengineQueryOrder;
import com.lingniu.ingest.tdenginehistory.TdengineRawFrameQuery;
import com.lingniu.ingest.tdenginehistory.TdengineRawFrameRow;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Set;

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

class Gb32960DecodedFrameServiceTest {

    @TempDir
    Path archiveRoot;

    @Test
    void decodesFullGb32960BlocksFromRawArchive() throws Exception {
        String key = "2026/06/22/GB32960/LNVFC000000000001/raw-1.bin";
        Path rawFile = archiveRoot.resolve(key);
        Files.createDirectories(rawFile.getParent());
        Files.write(rawFile, realtimeFrame());
        String uri = "archive://" + key;

        Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
                new CapturingTdengineReader(List.of(rawRow("raw-1", uri))),
                decoder(),
                archiveRoot,
                null);

        List<Gb32960DecodedFrameService.DecodedFrame> frames = service.query(
                LocalDate.parse("2026-06-22"),
                LocalDate.parse("2026-06-22"),
                HistoryQueryOrder.DESC,
                10,
                null,
                null);

        assertThat(frames).hasSize(1);
        Gb32960DecodedFrameService.DecodedFrame frame = frames.getFirst();
        assertThat(frame.vin()).isEqualTo("LNVFC000000000001");
        assertThat(frame.command()).isEqualTo("REALTIME_REPORT");
        assertThat(frame.blocks()).extracting(block -> block.get("type"))
                .containsExactly("VEHICLE", "GD_FC_DCDC");

        @SuppressWarnings("unchecked")
        Map<String, Object> vehicle = (Map<String, Object>) frames.getFirst().blocks().getFirst().get("data");
        assertThat(vehicle.get("totalMileageKm").toString()).isEqualTo("10000");
        assertThat(vehicle).containsEntry("socPercent", 85);

        @SuppressWarnings("unchecked")
        Map<String, Object> dcdc = (Map<String, Object>) frames.getFirst().blocks().get(1).get("data");
        assertThat(dcdc.get("inputVoltageV").toString()).isEqualTo("300");
        assertThat(dcdc.get("outputCurrentA").toString()).isEqualTo("110");
    }

    @Test
    void queryNormalizesFloatingPointNoiseForJsonOutput() throws Exception {
        Object normalized = Gb32960DecodedFrameService.normalizeJsonValue(636.3000000000001);

        assertThat(normalized).isInstanceOf(BigDecimal.class);
        assertThat(normalized.toString()).isEqualTo("636.3");
    }

    @Test
    void findsSingleFrameDirectlyByRawArchiveUri() throws Exception {
        String key = "2026/06/22/GB32960/LNVFC000000000001/1792527837000000.bin";
        writeArchive(key, realtimeFrame());

        Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
                (TdengineHistoryReader) null,
                decoder(),
                archiveRoot,
                null);

        Gb32960DecodedFrameService.DecodedFrame frame =
                service.findByRawArchiveUri("archive://" + key, "Hyundai");

        assertThat(frame.eventId()).isEqualTo("1792527837000000");
        assertThat(frame.vin()).isEqualTo("LNVFC000000000001");
        assertThat(frame.blocks()).extracting(block -> block.get("type"))
                .containsExactly("VEHICLE", "GD_FC_DCDC");
    }

    @Test
    void skipsMissingRawArchiveAndContinues() throws Exception {
        String missingKey = "2026/06/22/GB32960/LNVFC000000000001/missing.bin";
        String availableKey = "2026/06/22/GB32960/LNVFC000000000001/raw-2.bin";
        Path rawFile = archiveRoot.resolve(availableKey);
        Files.createDirectories(rawFile.getParent());
        Files.write(rawFile, realtimeFrame());

        Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
                new CapturingTdengineReader(List.of(
                        rawRow("missing", "archive://" + missingKey),
                        rawRow("available", "archive://" + availableKey))),
                decoder(),
                archiveRoot,
                null);

        List<Gb32960DecodedFrameService.DecodedFrame> frames = service.query(
                LocalDate.parse("2026-06-22"),
                LocalDate.parse("2026-06-22"),
                HistoryQueryOrder.DESC,
                10,
                null,
                null);

        assertThat(frames).hasSize(1);
        assertThat(frames.getFirst().eventId()).isEqualTo("available");
        assertThat(frames.getFirst().blocks()).extracting(block -> block.get("type"))
                .containsExactly("VEHICLE", "GD_FC_DCDC");
    }

    @Test
    void snapshotsRequireVinSoSegmentsAreMergedOnlyWithinOneVehicle() {
        Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
                (TdengineHistoryReader) null,
                decoder(),
                archiveRoot,
                null);

        assertThatThrownBy(() -> service.snapshots(
                LocalDate.parse("2026-06-22"),
                LocalDate.parse("2026-06-22"),
                HistoryQueryOrder.DESC,
                10,
                null,
                null))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("vin is required");
    }

    @Test
    void snapshotsMergeGb32960StackCellVoltageSegmentsByEventTime() throws Exception {
        String vin = "LNVFC000000000001";
        String key1 = "2026/06/22/GB32960/" + vin + "/stack-1.bin";
        String key2 = "2026/06/22/GB32960/" + vin + "/stack-2.bin";
        String key3 = "2026/06/22/GB32960/" + vin + "/stack-3.bin";
        writeArchive(key1, stackFrame(1, 4, true));
        writeArchive(key2, stackFrame(5, 3, false));
        writeArchive(key3, stackFrame(8, 2, false));

        Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
                new CapturingTdengineReader(List.of(
                        rawRow("segment-3", "archive://" + key3),
                        rawRow("segment-2", "archive://" + key2),
                        rawRow("segment-1", "archive://" + key1))),
                decoder(),
                archiveRoot,
                null);

        List<Gb32960DecodedFrameService.LogicalSnapshot> snapshots = service.snapshots(
                LocalDate.parse("2026-06-22"),
                LocalDate.parse("2026-06-22"),
                HistoryQueryOrder.DESC,
                10,
                vin,
                null);

        assertThat(snapshots).hasSize(1);
        Gb32960DecodedFrameService.LogicalSnapshot snapshot = snapshots.getFirst();
        assertThat(snapshot.vin()).isEqualTo(vin);
        assertThat(snapshot.eventTime()).isEqualTo("2026-06-22T10:00:00Z");
        assertThat(snapshot.sourceFrames()).hasSize(3);
        assertThat(snapshot.blocks()).extracting(block -> block.get("type"))
                .contains("VEHICLE", "GD_FC_STACK");

        @SuppressWarnings("unchecked")
        Map<String, Object> stackBlock = (Map<String, Object>) snapshot.blocks().stream()
                .filter(block -> "GD_FC_STACK".equals(block.get("type")))
                .findFirst()
                .orElseThrow()
                .get("data");
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> stacks = (List<Map<String, Object>>) stackBlock.get("stacks");
        assertThat(stacks).hasSize(1);
        @SuppressWarnings("unchecked")
        List<?> mergedVoltages = (List<?>) stacks.getFirst().get("frameCellVoltagesV");
        assertThat(stacks.getFirst()).containsEntry("frameCellStart", 1);
        assertThat(stacks.getFirst()).containsEntry("frameCellCount", 9);
        assertThat(stacks.getFirst()).containsEntry("frameCellComplete", true);
        assertThat(stacks.getFirst()).containsEntry("frameCellMissingCount", 0L);
        assertThat(mergedVoltages).hasSize(9);
        List<Double> expected = List.of(1.001, 1.002, 1.003, 1.004, 1.005, 1.006, 1.007, 1.008, 1.009);
        for (int i = 0; i < expected.size(); i++) {
            assertThat(((Number) mergedVoltages.get(i)).doubleValue()).isCloseTo(expected.get(i), within(0.000001));
        }
    }

    @Test
    void snapshotsUseTdengineRawFrames() throws Exception {
        String vin = "LNVFC000000000001";
        String key = "2026/06/22/GB32960/" + vin + "/tdengine-raw.bin";
        String uri = "archive://" + key;
        writeArchive(key, realtimeFrame());
        CapturingTdengineReader reader = new CapturingTdengineReader(List.of(new TdengineRawFrameRow(
                Instant.parse("2026-06-22T10:00:00Z"),
                "tdengine-raw",
                Instant.parse("2026-06-22T10:00:01Z"),
                0x02,
                0,
                Instant.parse("2026-06-22T10:00:00Z"),
                uri,
                "sha256:test",
                realtimeFrame().length,
                "SUCCEEDED",
                "",
                "127.0.0.1:32960",
                "{\"platformAccount\":\"Hyundai\"}",
                "",
                "GB32960",
                vin,
                vin,
                "")));

        Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
                reader,
                decoder(),
                archiveRoot,
                null);

        List<Gb32960DecodedFrameService.LogicalSnapshot> snapshots = service.snapshots(
                QueryTimeRange.parse("2026-06-22T18:00:00", "2026-06-22T18:00:00"),
                HistoryQueryOrder.DESC,
                10,
                vin,
                null);

        assertThat(reader.lastQuery.protocol()).isEqualTo("GB32960");
        assertThat(reader.lastQuery.vehicleKey()).isEqualTo(vin);
        assertThat(snapshots).hasSize(1);
        assertThat(snapshots.getFirst().sourceFrames()).extracting(Gb32960DecodedFrameService.SourceFrame::rawArchiveUri)
                .containsExactly(uri);
        assertThat(snapshots.getFirst().blocks()).extracting(block -> block.get("type"))
                .containsExactly("VEHICLE", "GD_FC_DCDC");
    }

    @Test
    void queryCanUseTdengineRawFramesWithoutVinForPlatformLoginFrames() throws Exception {
        String key = "2026/06/22/GB32960/unknown-vin/platform-login.bin";
        String uri = "archive://" + key;
        writeArchive(key, platformLoginFrame());
        CapturingTdengineReader reader = new CapturingTdengineReader(List.of(new TdengineRawFrameRow(
                Instant.parse("2026-06-22T10:00:00Z"),
                "tdengine-platform-login",
                Instant.parse("2026-06-22T10:00:01Z"),
                0x05,
                0,
                Instant.parse("2026-06-22T10:00:00Z"),
                uri,
                "sha256:test",
                platformLoginFrame().length,
                "SUCCEEDED",
                "",
                "8.134.95.166:37302",
                "{\"platformAccount\":\"Hyundai\"}",
                "",
                "GB32960",
                "unknown:GB32960:platform-login",
                "",
                "")));

        Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
                reader,
                decoder(),
                archiveRoot,
                null);

        List<Gb32960DecodedFrameService.DecodedFrame> frames = service.query(
                LocalDate.parse("2026-06-22"),
                LocalDate.parse("2026-06-22"),
                HistoryQueryOrder.DESC,
                10,
                null,
                "Hyundai");

        assertThat(reader.lastQuery.protocol()).isEqualTo("GB32960");
        assertThat(reader.lastQuery.vehicleKey()).isBlank();
        assertThat(reader.lastQuery.vin()).isBlank();
        assertThat(frames).hasSize(1);
        assertThat(frames.getFirst().command()).isEqualTo("PLATFORM_LOGIN");
        assertThat(frames.getFirst().rawArchiveUri()).isEqualTo(uri);
    }

    @Test
    void framePageUsesTdengineCursorAndReturnsNextCursor() throws Exception {
        String vin = "LNVFC000000000001";
        String key = "2026/06/22/GB32960/" + vin + "/tdengine-page.bin";
        String uri = "archive://" + key;
        writeArchive(key, realtimeFrame());
        TdenginePageCursor nextCursor = new TdenginePageCursor(
                Instant.parse("2026-06-22T10:00:00Z"), "tdengine-page");
        CapturingTdengineReader reader = new CapturingTdengineReader(List.of(new TdengineRawFrameRow(
                Instant.parse("2026-06-22T10:00:00Z"),
                "tdengine-page",
                Instant.parse("2026-06-22T10:00:01Z"),
                0x02,
                0,
                Instant.parse("2026-06-22T10:00:00Z"),
                uri,
                "sha256:test",
                realtimeFrame().length,
                "SUCCEEDED",
                "",
                "127.0.0.1:32960",
                "{\"platformAccount\":\"Hyundai\"}",
                "",
                "GB32960",
                vin,
                vin,
                "")), nextCursor);

        Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
                reader,
                decoder(),
                archiveRoot,
                null);

        Gb32960DecodedFrameService.DecodedFramePage page = service.queryPage(
                QueryTimeRange.parse("2026-06-22T18:00:00", "2026-06-22T18:00:00"),
                HistoryQueryOrder.ASC,
                10,
                vin,
                null,
                new TdenginePageCursor(Instant.parse("2026-06-22T09:59:00Z"), "previous"));

        assertThat(reader.lastQuery.protocol()).isEqualTo("GB32960");
        assertThat(reader.lastQuery.vehicleKey()).isEqualTo(vin);
        assertThat(reader.lastQuery.order()).isEqualTo(TdengineQueryOrder.ASC);
        assertThat(reader.lastQuery.cursor()).isEqualTo(new TdenginePageCursor(
                Instant.parse("2026-06-22T09:59:00Z"), "previous"));
        assertThat(page.items()).hasSize(1);
        assertThat(page.items().getFirst().rawArchiveUri()).isEqualTo(uri);
        assertThat(page.nextCursor()).isEqualTo(new Gb32960DecodedFrameService.CursorResponse(
                "2026-06-22T10:00:00Z", "tdengine-page"));
    }

    @Test
    void snapshotsKeepMissingStackCellsAsNullsUpToCellCount() throws Exception {
        String vin = "LNVFC000000000001";
        String key = "2026/06/22/GB32960/" + vin + "/stack-tail-missing.bin";
        writeArchive(key, stackFrame(1, 4, true));

        Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
                new CapturingTdengineReader(List.of(rawRow("partial", "archive://" + key))),
                decoder(),
                archiveRoot,
                null);

        List<Gb32960DecodedFrameService.LogicalSnapshot> snapshots = service.snapshots(
                LocalDate.parse("2026-06-22"),
                LocalDate.parse("2026-06-22"),
                HistoryQueryOrder.DESC,
                10,
                vin,
                null);

        @SuppressWarnings("unchecked")
        Map<String, Object> stackBlock = (Map<String, Object>) snapshots.getFirst().blocks().stream()
                .filter(block -> "GD_FC_STACK".equals(block.get("type")))
                .findFirst()
                .orElseThrow()
                .get("data");
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> stacks = (List<Map<String, Object>>) stackBlock.get("stacks");
        @SuppressWarnings("unchecked")
        List<?> voltages = (List<?>) stacks.getFirst().get("frameCellVoltagesV");

        assertThat(stacks.getFirst()).containsEntry("frameCellStart", 1);
        assertThat(stacks.getFirst()).containsEntry("frameCellCount", 9);
        assertThat(stacks.getFirst()).containsEntry("frameCellComplete", false);
        assertThat(stacks.getFirst()).containsEntry("frameCellMissingCount", 5L);
        assertThat(voltages).hasSize(9);
        assertThat(voltages.subList(4, 9)).containsOnlyNulls();
    }

    @Test
    void snapshotFieldsReturnsOnlySelectedFields() throws Exception {
        String vin = "LNVFC000000000001";
        String key = "2026/06/22/GB32960/" + vin + "/selected-fields.bin";
        writeArchive(key, realtimeFrame());

        Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
                new CapturingTdengineReader(List.of(rawRow("selected", "archive://" + key))),
                decoder(),
                archiveRoot,
                null);

        List<Gb32960DecodedFrameService.FieldSnapshot> snapshots = service.snapshotFields(
                QueryTimeRange.parse("2026-06-22T18:00:00", "2026-06-22T18:00:00"),
                HistoryQueryOrder.DESC,
                10,
                vin,
                null,
                "VEHICLE.totalMileageKm,GD_FC_DCDC.inputVoltageV,GD_FC_DCDC.outputCurrentA");

        assertThat(snapshots).hasSize(1);
        assertThat(snapshots.getFirst().fields())
                .containsEntry("VEHICLE.totalMileageKm", new BigDecimal("10000"))
                .containsEntry("GD_FC_DCDC.inputVoltageV", new BigDecimal("300"))
                .containsEntry("GD_FC_DCDC.outputCurrentA", new BigDecimal("110"));
    }

    @Test
    void snapshotFieldsCsvUsesChineseDictionaryHeaders() throws Exception {
        String vin = "LNVFC000000000001";
        String key = "2026/06/22/GB32960/" + vin + "/selected-fields-csv.bin";
        writeArchive(key, realtimeFrame());

        Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
                new CapturingTdengineReader(List.of(rawRow("selected-csv", "archive://" + key))),
                decoder(),
                archiveRoot,
                null);

        String csv = service.snapshotFieldsCsv(
                QueryTimeRange.parse("2026-06-22T18:00:00", "2026-06-22T18:00:00"),
                HistoryQueryOrder.DESC,
                10,
                vin,
                null,
                "VEHICLE.totalMileageKm,GD_FC_DCDC.inputVoltageV");

        assertThat(csv).startsWith("VIN,事件时间,首次接收时间,最后接收时间,原始包,整车数据-累计里程(km),广东燃料电池 DC/DC 数据-输入电压(V)\n");
        assertThat(csv).contains("LNVFC000000000001,2026-06-22T10:00:00Z");
        assertThat(csv).contains(",10000,300\n");
    }

    @Test
    void snapshotFieldsRejectsUnknownDictionaryFields() throws Exception {
        Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
                (TdengineHistoryReader) null,
                decoder(),
                archiveRoot,
                null);

        assertThatThrownBy(() -> service.snapshotFields(
                QueryTimeRange.parse("2026-06-22T18:00:00", "2026-06-22T18:00:00"),
                HistoryQueryOrder.DESC,
                10,
                "LNVFC000000000001",
                null,
                "VEHICLE.totalMileageKm,VEHICLE.notExists"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("unknown gb32960 snapshot fields: VEHICLE.notExists");
    }

    private static Gb32960MessageDecoder decoder() {
        List<InfoBlockParser> standardParsers = List.of(new VehicleV2016BlockParser());
        VendorExtensionCatalog catalog = new VendorExtensionCatalog(Map.of(
                "guangdong-fc", List.of(new GdFcDcDcBlockParser(), new GdFcStackBlockParser())));
        Gb32960ProfileRegistry registry = new Gb32960ProfileRegistry(
                standardParsers, catalog, Set.of("guangdong-fc"));
        VendorExtensionSelector selector = ctx -> "guangdong-fc";
        return new Gb32960MessageDecoder(new Gb32960BodyParser(registry, selector));
    }

    private static TdengineRawFrameRow rawRow(String eventId, String rawArchiveUri) {
        return rawRow(eventId, rawArchiveUri, "LNVFC000000000001");
    }

    private static TdengineRawFrameRow rawRow(String eventId, String rawArchiveUri, String vin) {
        return new TdengineRawFrameRow(
                Instant.parse("2026-06-22T10:00:00Z"),
                eventId,
                Instant.parse("2026-06-22T10:00:01Z"),
                0x02,
                0,
                Instant.parse("2026-06-22T10:00:00Z"),
                rawArchiveUri,
                "sha256:test",
                64,
                "SUCCEEDED",
                "",
                "127.0.0.1:32960",
                "{\"platformAccount\":\"Hyundai\"}",
                "",
                "GB32960",
                vin,
                vin,
                "");
    }

    private static byte[] realtimeFrame() {
        ByteArrayOutputStream body = new ByteArrayOutputStream();
        body.write(26); body.write(6); body.write(22); body.write(18); body.write(0); body.write(0);

        body.write(0x01);
        body.write(0x01);
        body.write(0x03);
        body.write(0x02);
        writeU16(body, 0);
        writeU32(body, 100_000L);
        writeU16(body, 5605);
        writeU16(body, 10000);
        body.write(85);
        body.write(0x01);
        body.write(0x00);
        writeU16(body, 10000);
        body.write(0x00);
        body.write(0x00);

        body.write(0x32);
        writeU16(body, 3000);
        writeU16(body, 1200);
        writeU16(body, 5600);
        writeU16(body, 1100);
        body.write(105);

        byte[] bodyBytes = body.toByteArray();
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        out.write(0x23); out.write(0x23);
        out.write(0x02);
        out.write(0xFE);
        byte[] vin = "LNVFC000000000001".getBytes(StandardCharsets.US_ASCII);
        out.write(vin, 0, 17);
        out.write(0x01);
        writeU16(out, bodyBytes.length);
        out.write(bodyBytes, 0, bodyBytes.length);
        byte[] almost = out.toByteArray();
        out.write(BccChecksum.compute(almost, 2, almost.length - 2) & 0xFF);
        return out.toByteArray();
    }

    private static byte[] platformLoginFrame() {
        ByteArrayOutputStream body = new ByteArrayOutputStream();
        body.write(26); body.write(6); body.write(22); body.write(18); body.write(0); body.write(0);
        writeU16(body, 1);
        writeAsciiPadded(body, "Hyundai", 12);
        writeAsciiPadded(body, "f2e3445d7cda409fb4f", 20);
        body.write(0x01);

        byte[] bodyBytes = body.toByteArray();
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        out.write(0x23); out.write(0x23);
        out.write(0x05);
        out.write(0xFE);
        for (int i = 0; i < 17; i++) {
            out.write(0);
        }
        out.write(0x01);
        writeU16(out, bodyBytes.length);
        out.write(bodyBytes, 0, bodyBytes.length);
        byte[] almost = out.toByteArray();
        out.write(BccChecksum.compute(almost, 2, almost.length - 2) & 0xFF);
        return out.toByteArray();
    }

    private static void writeAsciiPadded(ByteArrayOutputStream out, String value, int length) {
        byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
        int count = Math.min(bytes.length, length);
        out.write(bytes, 0, count);
        for (int i = count; i < length; i++) {
            out.write(0);
        }
    }

    private void writeArchive(String key, byte[] frame) throws Exception {
        Path rawFile = archiveRoot.resolve(key);
        Files.createDirectories(rawFile.getParent());
        Files.write(rawFile, frame);
    }

    private static byte[] stackFrame(int frameCellStart, int frameCellCount, boolean includeVehicle) {
        ByteArrayOutputStream body = new ByteArrayOutputStream();
        body.write(26); body.write(6); body.write(22); body.write(18); body.write(0); body.write(0);

        if (includeVehicle) {
            body.write(0x01);
            body.write(0x01);
            body.write(0x03);
            body.write(0x02);
            writeU16(body, 0);
            writeU32(body, 100_000L);
            writeU16(body, 5605);
            writeU16(body, 10000);
            body.write(85);
            body.write(0x01);
            body.write(0x00);
            writeU16(body, 10000);
            body.write(0x00);
            body.write(0x00);
        }

        body.write(0x30);
        body.write(0x01);
        body.write(0x01);
        body.write(90);
        writeU16(body, 1850);
        writeU16(body, 1500);
        body.write(70);
        writeU16(body, 12);
        writeU16(body, 88);
        writeU16(body, 1009);
        writeU16(body, 1001);
        writeU16(body, 1005);
        writeU16(body, 9);
        writeU16(body, frameCellStart);
        body.write(frameCellCount);
        for (int i = 0; i < frameCellCount; i++) {
            writeU16(body, 1000 + frameCellStart + i);
        }

        byte[] bodyBytes = body.toByteArray();
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        out.write(0x23); out.write(0x23);
        out.write(0x02);
        out.write(0xFE);
        byte[] vin = "LNVFC000000000001".getBytes(StandardCharsets.US_ASCII);
        out.write(vin, 0, 17);
        out.write(0x01);
        writeU16(out, bodyBytes.length);
        out.write(bodyBytes, 0, bodyBytes.length);
        byte[] almost = out.toByteArray();
        out.write(BccChecksum.compute(almost, 2, almost.length - 2) & 0xFF);
        return out.toByteArray();
    }

    private static void writeU16(ByteArrayOutputStream os, int v) {
        os.write((v >> 8) & 0xFF);
        os.write(v & 0xFF);
    }

    private static void writeU32(ByteArrayOutputStream os, long v) {
        os.write((int) ((v >> 24) & 0xFF));
        os.write((int) ((v >> 16) & 0xFF));
        os.write((int) ((v >> 8) & 0xFF));
        os.write((int) (v & 0xFF));
    }

    private static final class CapturingTdengineReader implements TdengineHistoryReader {
        private final List<TdengineRawFrameRow> rows;
        private final TdenginePageCursor nextCursor;
        private TdengineRawFrameQuery lastQuery;

        private CapturingTdengineReader(List<TdengineRawFrameRow> rows) {
            this(rows, null);
        }

        private CapturingTdengineReader(List<TdengineRawFrameRow> rows, TdenginePageCursor nextCursor) {
            this.rows = rows;
            this.nextCursor = nextCursor;
        }

        @Override
        public TdenginePage<TdengineRawFrameRow> queryRawFrames(TdengineRawFrameQuery query) {
            lastQuery = query;
            return new TdenginePage<>(rows, java.util.Optional.ofNullable(nextCursor));
        }

        @Override
        public TdenginePage<TdengineLocationRow> queryLocations(TdengineLocationQuery query) {
            throw new UnsupportedOperationException();
        }

    }
}
