package com.lingniu.ingest.protocol.jt808.inbound;

import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.sink.EventSink;
import com.lingniu.ingest.codec.BccChecksum;
import com.lingniu.ingest.codec.BcdCodec;
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
import com.lingniu.ingest.core.dispatcher.Dispatcher;
import com.lingniu.ingest.core.dispatcher.HandlerDefinition;
import com.lingniu.ingest.core.dispatcher.HandlerInvoker;
import com.lingniu.ingest.core.dispatcher.HandlerRegistry;
import com.lingniu.ingest.core.pipeline.InterceptorChain;
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
import com.lingniu.ingest.identity.VehicleIdentityBinding;
import com.lingniu.ingest.identity.VehicleIdentityLookup;
import com.lingniu.ingest.identity.VehicleIdentitySource;
import com.lingniu.ingest.protocol.jt808.codec.BodyParserRegistry;
import com.lingniu.ingest.protocol.jt808.codec.Jt808MalformedFrame;
import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
import com.lingniu.ingest.protocol.jt808.codec.parser.AuthBodyParser;
import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
import com.lingniu.ingest.protocol.jt808.handler.Jt808LocationHandler;
import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper;
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry;
import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests;
import com.lingniu.ingest.protocol.jt808.TestSessionStore;
import io.netty.buffer.ByteBuf;
import io.netty.channel.embedded.EmbeddedChannel;
import org.junit.jupiter.api.Test;

import java.io.ByteArrayOutputStream;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

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

class Jt808ChannelHandlerTest {

    @Test
    void registerSessionUsesResolvedInternalVinInsteadOfDeviceIdPlaceholder() {
        TestSessionStore sessions = new TestSessionStore();
        InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
        identity.bind(new VehicleIdentityBinding(
                ProtocolId.JT808, "LNVIN000000000808", "123456789012", "DEV808", "B80808"));
        DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(new ImmediateKafkaSink()));
        AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
        Dispatcher dispatcher = new Dispatcher(
                new HandlerRegistry(),
                new InterceptorChain(List.of()),
                new HandlerInvoker(),
                eventBus,
                batchExecutor);
        Jt808ChannelHandler handler = new Jt808ChannelHandler(
                new Jt808MessageDecoder(new BodyParserRegistry(List.of(new RegisterBodyParser()))),
                dispatcher,
                sessions,
                identity,
                new Jt808ChannelRegistry(),
                new Jt808PendingRequests());
        EmbeddedChannel channel = new EmbeddedChannel(handler);

        channel.writeInbound(buildFrame(
                Jt808MessageId.TERMINAL_REGISTER,
                "123456789012",
                1,
                buildRegisterBody("DEV808", "B80808")));

        assertThat(sessions.findByPhone("123456789012"))
                .get()
                .extracting(session -> session.vin())
                .isEqualTo("LNVIN000000000808");
        assertThat(identity.resolve(new com.lingniu.ingest.identity.VehicleIdentityLookup(
                ProtocolId.JT808, "", "123456789012", "", "")).vin())
                .isEqualTo("LNVIN000000000808");

        batchExecutor.close();
        eventBus.close();
    }

    @Test
    void unresolvedRegisterBindsGeneratedInternalVinToExternalIdentifiers() {
        TestSessionStore sessions = new TestSessionStore();
        InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
        DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(new ImmediateKafkaSink()));
        AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
        Dispatcher dispatcher = new Dispatcher(
                new HandlerRegistry(),
                new InterceptorChain(List.of()),
                new HandlerInvoker(),
                eventBus,
                batchExecutor);
        Jt808ChannelHandler handler = new Jt808ChannelHandler(
                new Jt808MessageDecoder(new BodyParserRegistry(List.of(new RegisterBodyParser()))),
                dispatcher,
                sessions,
                identity,
                new Jt808ChannelRegistry(),
                new Jt808PendingRequests());
        EmbeddedChannel channel = new EmbeddedChannel(handler);

        channel.writeInbound(buildFrame(
                Jt808MessageId.TERMINAL_REGISTER,
                "123456789012",
                1,
                buildRegisterBody("DEV808", "B80808")));

        assertThat(identity.resolve(new VehicleIdentityLookup(
                ProtocolId.JT808, "", "123456789012", "", "")))
                .satisfies(resolved -> {
                    assertThat(resolved.vin()).startsWith("JT808");
                    assertThat(resolved.resolved()).isTrue();
                    assertThat(resolved.source()).isEqualTo(VehicleIdentitySource.BOUND_PHONE);
                });

        batchExecutor.close();
        eventBus.close();
    }

    @Test
    void authSessionUsesResolvedInternalVinFromImei() {
        TestSessionStore sessions = new TestSessionStore();
        InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
        identity.bind(new VehicleIdentityBinding(
                ProtocolId.JT808, "LNVIN000000AUTH01", "123456789012", "123456789012345", ""));
        DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(new ImmediateKafkaSink()));
        AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
        Dispatcher dispatcher = new Dispatcher(
                new HandlerRegistry(),
                new InterceptorChain(List.of()),
                new HandlerInvoker(),
                eventBus,
                batchExecutor);
        Jt808ChannelHandler handler = new Jt808ChannelHandler(
                new Jt808MessageDecoder(new BodyParserRegistry(List.of(new AuthBodyParser()))),
                dispatcher,
                sessions,
                identity,
                new Jt808ChannelRegistry(),
                new Jt808PendingRequests());
        EmbeddedChannel channel = new EmbeddedChannel(handler);

        channel.writeInbound(buildFrame(
                Jt808MessageId.TERMINAL_AUTH,
                "123456789012",
                2,
                "TOKEN,123456789012345,SW1".getBytes(java.nio.charset.StandardCharsets.US_ASCII)));

        assertThat(sessions.findByPhone("123456789012"))
                .get()
                .extracting(session -> session.vin())
                .isEqualTo("LNVIN000000AUTH01");

        batchExecutor.close();
        eventBus.close();
    }

    @Test
    void registerAckWaitsForDispatchDurability() {
        ControlledSink sink = new ControlledSink();
        DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
        AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
        Dispatcher dispatcher = new Dispatcher(
                new HandlerRegistry(),
                new InterceptorChain(List.of()),
                new HandlerInvoker(),
                eventBus,
                batchExecutor);
        Jt808ChannelHandler handler = new Jt808ChannelHandler(
                new Jt808MessageDecoder(new BodyParserRegistry(List.of(new RegisterBodyParser()))),
                dispatcher,
                new TestSessionStore(),
                new InMemoryVehicleIdentityService(),
                new Jt808ChannelRegistry(),
                new Jt808PendingRequests());
        EmbeddedChannel channel = new EmbeddedChannel(handler);

        channel.writeInbound(buildFrame(
                Jt808MessageId.TERMINAL_REGISTER,
                "123456789012",
                1,
                buildRegisterBody("DEV808", "B80808")));

        assertThat((Object) channel.readOutbound()).isNull();
        sink.awaitPublishCount(1);
        sink.future(0).complete(null);

        ByteBuf ack = readOutboundAfterRunningTasks(channel);
        assertThat(ack).isNotNull();
        ack.release();

        batchExecutor.close();
        eventBus.close();
    }

    @Test
    void inactiveChannelRemovesSessionStoreEntry() {
        TestSessionStore sessions = new TestSessionStore();
        DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(new ImmediateKafkaSink()));
        AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
        Dispatcher dispatcher = new Dispatcher(
                new HandlerRegistry(),
                new InterceptorChain(List.of()),
                new HandlerInvoker(),
                eventBus,
                batchExecutor);
        Jt808ChannelHandler handler = new Jt808ChannelHandler(
                new Jt808MessageDecoder(new BodyParserRegistry(List.of(new AuthBodyParser()))),
                dispatcher,
                sessions,
                new InMemoryVehicleIdentityService(),
                new Jt808ChannelRegistry(),
                new Jt808PendingRequests());
        EmbeddedChannel channel = new EmbeddedChannel(handler);

        channel.writeInbound(buildFrame(
                Jt808MessageId.TERMINAL_AUTH,
                "123456789012",
                2,
                "TOKEN,123456789012345,SW1".getBytes(java.nio.charset.StandardCharsets.US_ASCII)));
        assertThat(sessions.findByPhone("123456789012")).isPresent();

        channel.close();

        assertThat(sessions.findByPhone("123456789012")).isEmpty();

        batchExecutor.close();
        eventBus.close();
    }

    @Test
    void successfulFrameArchiveUsesResolvedVehicleIdentity() throws Exception {
        RecordingSink sink = new RecordingSink(1);
        DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
        AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
        Dispatcher dispatcher = new Dispatcher(
                new HandlerRegistry(),
                new InterceptorChain(List.of()),
                new HandlerInvoker(),
                eventBus,
                batchExecutor);
        InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
        identity.bind(new VehicleIdentityBinding(
                ProtocolId.JT808, "LNVIN000000000808", "123456789012", "DEV808", "粤B80808"));
        Jt808ChannelHandler handler = new Jt808ChannelHandler(
                new Jt808MessageDecoder(new BodyParserRegistry(List.of(new LocationBodyParser()))),
                dispatcher,
                new TestSessionStore(),
                identity,
                new Jt808ChannelRegistry(),
                new Jt808PendingRequests());
        EmbeddedChannel channel = new EmbeddedChannel(handler);
        byte[] frame = buildFrame(Jt808MessageId.TERMINAL_LOCATION, "123456789012", 1, buildLocationBody());

        channel.writeInbound(frame);

        assertThat(sink.await()).isTrue();
        assertThat(sink.events).singleElement().satisfies(event -> {
            assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
            VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
            assertThat(archive.vin()).isEqualTo("LNVIN000000000808");
            assertThat(archive.metadata())
                    .containsEntry("vin", "LNVIN000000000808")
                    .containsEntry("phone", "123456789012")
                    .containsEntry("messageId", "0x0200")
                    .containsEntry("messageName", "TERMINAL_LOCATION")
                    .containsEntry("reportType", "LOCATION")
                    .containsEntry("body.alarmFlag", "0")
                    .containsEntry("body.statusFlag", "196608")
                    .containsEntry("body.status.acc_on", "false")
                    .containsEntry("body.status.load_status", "EMPTY")
                    .containsEntry("body.status.door4_open", "true")
                    .containsEntry("body.status.door5_open", "true")
                    .containsEntry("body.alarm.overspeed", "false")
                    .containsEntry("body.longitude", "116.397128")
                    .containsEntry("body.latitude", "39.916527")
                    .containsEntry("body.altitudeM", "50")
                    .containsEntry("body.directionDeg", "90")
                    .containsEntry("body.deviceTime", "2024-01-01T19:04:05Z")
                    .containsEntry("body.extra.0x01", "00003039")
                    .containsEntry("body.extra.mileage_km", "1234.5")
                    .containsEntry("body.extra.0x30", "58")
                    .containsEntry("body.extra.network_signal_strength", "88")
                    .containsEntry("identityResolved", "true")
                    .containsEntry("identitySource", "BOUND_PHONE");
            assertThat(Double.parseDouble(archive.metadata().get("body.speedKmh"))).isCloseTo(52.3, within(0.001));
        });

        batchExecutor.close();
        eventBus.close();
    }

    @Test
    void registerArchiveIncludesParsedBodyFieldsForRawQuery() throws Exception {
        RecordingSink sink = new RecordingSink(1);
        DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
        AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
        Dispatcher dispatcher = new Dispatcher(
                new HandlerRegistry(),
                new InterceptorChain(List.of()),
                new HandlerInvoker(),
                eventBus,
                batchExecutor);
        InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
        identity.bind(new VehicleIdentityBinding(
                ProtocolId.JT808, "LNVIN000000000808", "123456789012", "DEV808", "B80808"));
        Jt808ChannelHandler handler = new Jt808ChannelHandler(
                new Jt808MessageDecoder(new BodyParserRegistry(List.of(new RegisterBodyParser()))),
                dispatcher,
                new TestSessionStore(),
                identity,
                new Jt808ChannelRegistry(),
                new Jt808PendingRequests());
        EmbeddedChannel channel = new EmbeddedChannel(handler);
        byte[] frame = buildFrame(
                Jt808MessageId.TERMINAL_REGISTER,
                "123456789012",
                3,
                buildRegisterBody("DEV808", "B80808"));

        channel.writeInbound(frame);

        assertThat(sink.await()).isTrue();
        assertThat(sink.events).singleElement().satisfies(event -> {
            assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
            VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
            assertThat(archive.metadata())
                    .containsEntry("messageId", "0x0100")
                    .containsEntry("messageName", "TERMINAL_REGISTER")
                    .containsEntry("reportType", "REGISTER")
                    .containsEntry("body.province", "44")
                    .containsEntry("body.city", "4401")
                    .containsEntry("body.maker", "MAKER")
                    .containsEntry("body.deviceType", "TYPE-A")
                    .containsEntry("body.deviceId", "DEV808")
                    .containsEntry("body.plateColor", "1")
                    .containsEntry("body.plate", "B80808");
        });

        batchExecutor.close();
        eventBus.close();
    }

    @Test
    void unboundRegisterCreatesVinBindingForRegisterAndFollowingRawFrames() throws Exception {
        RecordingSink sink = new RecordingSink(2);
        DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
        AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
        Dispatcher dispatcher = new Dispatcher(
                new HandlerRegistry(),
                new InterceptorChain(List.of()),
                new HandlerInvoker(),
                eventBus,
                batchExecutor);
        InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
        Jt808ChannelHandler handler = new Jt808ChannelHandler(
                new Jt808MessageDecoder(new BodyParserRegistry(List.of(new RegisterBodyParser(), new LocationBodyParser()))),
                dispatcher,
                new TestSessionStore(),
                identity,
                new Jt808ChannelRegistry(),
                new Jt808PendingRequests());
        EmbeddedChannel channel = new EmbeddedChannel(handler);

        channel.writeInbound(buildFrame(
                Jt808MessageId.TERMINAL_REGISTER,
                "13079963289",
                3,
                buildRegisterBody("9963289", "HUA00113F")));
        channel.writeInbound(buildFrame(
                Jt808MessageId.TERMINAL_LOCATION,
                "13079963289",
                4,
                buildLocationBody()));

        assertThat(sink.await()).isTrue();
        VehicleEvent.RawArchive register = (VehicleEvent.RawArchive) sink.events.get(0);
        VehicleEvent.RawArchive location = (VehicleEvent.RawArchive) sink.events.get(1);
        assertThat(register.metadata())
                .containsEntry("reportType", "REGISTER")
                .containsEntry("body.deviceId", "9963289")
                .containsEntry("body.plate", "HUA00113F")
                .containsEntry("identityResolved", "true")
                .containsEntry("identitySource", "REGISTERED");
        assertThat(register.vin())
                .startsWith("JT808")
                .hasSize(17);
        assertThat(location.metadata())
                .containsEntry("reportType", "LOCATION")
                .containsEntry("vin", register.vin())
                .containsEntry("identityResolved", "true")
                .containsEntry("identitySource", "BOUND_PHONE");
        assertThat(location.vin()).isEqualTo(register.vin());
        assertThat(identity.resolve(new com.lingniu.ingest.identity.VehicleIdentityLookup(
                ProtocolId.JT808, "", "13079963289", "", "")).vin())
                .isEqualTo(register.vin());

        batchExecutor.close();
        eventBus.close();
    }

    @Test
    void unboundLocationArchiveUsesUnknownVinInsteadOfPhoneFallback() throws Exception {
        RecordingSink sink = new RecordingSink(1);
        DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
        AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
        Dispatcher dispatcher = new Dispatcher(
                new HandlerRegistry(),
                new InterceptorChain(List.of()),
                new HandlerInvoker(),
                eventBus,
                batchExecutor);
        InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
        Jt808ChannelHandler handler = new Jt808ChannelHandler(
                new Jt808MessageDecoder(new BodyParserRegistry(List.of(new LocationBodyParser()))),
                dispatcher,
                new TestSessionStore(),
                identity,
                new Jt808ChannelRegistry(),
                new Jt808PendingRequests());
        EmbeddedChannel channel = new EmbeddedChannel(handler);
        byte[] frame = buildFrame(Jt808MessageId.TERMINAL_LOCATION, "123456789012", 1, buildLocationBody());

        channel.writeInbound(frame);

        assertThat(sink.await()).isTrue();
        assertThat(sink.events).singleElement().satisfies(event -> {
            assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
            VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
            assertThat(archive.vin()).isEqualTo("unknown");
            assertThat(archive.metadata())
                    .containsEntry("vin", "unknown")
                    .containsEntry("phone", "123456789012")
                    .containsEntry("messageId", "0x0200")
                    .containsEntry("messageName", "TERMINAL_LOCATION")
                    .containsEntry("reportType", "LOCATION")
                    .containsEntry("identityResolved", "false")
                    .containsEntry("identitySource", "FALLBACK_PHONE");
        });

        batchExecutor.close();
        eventBus.close();
    }

    @Test
    void registerArchivePreservesRegistrationFieldsInMetadata() throws Exception {
        RecordingSink sink = new RecordingSink(1);
        DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
        AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
        Dispatcher dispatcher = new Dispatcher(
                new HandlerRegistry(),
                new InterceptorChain(List.of()),
                new HandlerInvoker(),
                eventBus,
                batchExecutor);
        InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
        Jt808ChannelHandler handler = new Jt808ChannelHandler(
                new Jt808MessageDecoder(new BodyParserRegistry(List.of(new RegisterBodyParser()))),
                dispatcher,
                new TestSessionStore(),
                identity,
                new Jt808ChannelRegistry(),
                new Jt808PendingRequests());
        EmbeddedChannel channel = new EmbeddedChannel(handler);
        byte[] frame = buildFrame(
                Jt808MessageId.TERMINAL_REGISTER,
                "123456789012",
                1,
                buildRegisterBody("DEV808", "B80808"));

        channel.writeInbound(frame);

        assertThat(sink.await()).isTrue();
        assertThat(sink.events).singleElement().satisfies(event -> {
            assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
            VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
            assertThat(archive.command()).isEqualTo(Jt808MessageId.TERMINAL_REGISTER);
            assertThat(archive.metadata())
                    .containsEntry("body.province", "44")
                    .containsEntry("body.city", "4401")
                    .containsEntry("body.maker", "MAKER")
                    .containsEntry("body.deviceType", "TYPE-A")
                    .containsEntry("body.deviceId", "DEV808")
                    .containsEntry("body.plateColor", "1")
                    .containsEntry("body.plate", "B80808");
        });

        batchExecutor.close();
        eventBus.close();
    }

    @Test
    void malformedFrameIsArchivedAndDispatchedAsPassthrough() throws Exception {
        RecordingSink sink = new RecordingSink(2);
        DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
        AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
        Dispatcher dispatcher = new Dispatcher(
                registryWithRawHandler(),
                new InterceptorChain(List.of()),
                new HandlerInvoker(),
                eventBus,
                batchExecutor);
        Jt808ChannelHandler handler = new Jt808ChannelHandler(
                new Jt808MessageDecoder(new BodyParserRegistry(List.of())),
                dispatcher,
                new TestSessionStore(),
                new InMemoryVehicleIdentityService(),
                new Jt808ChannelRegistry(),
                new Jt808PendingRequests());
        EmbeddedChannel channel = new EmbeddedChannel(handler);
        byte[] malformed = new byte[]{0x01, 0x02, 0x03};

        channel.writeInbound(malformed);

        assertThat(sink.await()).isTrue();
        assertThat(sink.events).anySatisfy(event -> {
            assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
            VehicleEvent.RawArchive raw = (VehicleEvent.RawArchive) event;
            assertThat(raw.source()).isEqualTo(ProtocolId.JT808);
            assertThat(raw.command()).isZero();
            assertThat(raw.rawBytes()).containsExactly(malformed);
            assertThat(raw.metadata())
                    .containsEntry("parseError", "true")
                    .containsEntry("identityResolved", "false")
                    .containsEntry("identitySource", "UNKNOWN");
        });
        assertThat(sink.events).anySatisfy(event -> {
            assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
            VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
            assertThat(passthrough.source()).isEqualTo(ProtocolId.JT808);
            assertThat(passthrough.vin()).isEqualTo("unknown");
            assertThat(passthrough.metadata())
                    .containsEntry("parseError", "true")
                    .containsEntry("identityResolved", "false")
                    .containsEntry("identitySource", "UNKNOWN");
            assertThat(passthrough.data()).containsExactly(malformed);
        });

        batchExecutor.close();
        eventBus.close();
    }

    @Test
    void frameBoundaryMalformedCandidateIsArchivedAndDispatchedAsPassthrough() throws Exception {
        RecordingSink sink = new RecordingSink(2);
        DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
        AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
        Dispatcher dispatcher = new Dispatcher(
                registryWithRawHandler(),
                new InterceptorChain(List.of()),
                new HandlerInvoker(),
                eventBus,
                batchExecutor);
        Jt808ChannelHandler handler = new Jt808ChannelHandler(
                new Jt808MessageDecoder(new BodyParserRegistry(List.of())),
                dispatcher,
                new TestSessionStore(),
                new InMemoryVehicleIdentityService(),
                new Jt808ChannelRegistry(),
                new Jt808PendingRequests());
        EmbeddedChannel channel = new EmbeddedChannel(handler);
        byte[] raw = new byte[]{0x01, 0x02, 0x03};

        channel.writeInbound(new Jt808MalformedFrame(raw, "unknown", "jt808 missing frame boundary"));

        assertThat(sink.await()).isTrue();
        assertThat(sink.events).anySatisfy(event -> {
            assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
            VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
            assertThat(archive.source()).isEqualTo(ProtocolId.JT808);
            assertThat(archive.rawBytes()).containsExactly(raw);
            assertThat(archive.metadata()).containsEntry("frameError", "true")
                    .containsEntry("frameErrorMessage", "jt808 missing frame boundary")
                    .containsEntry("identityResolved", "false")
                    .containsEntry("identitySource", "UNKNOWN");
        });
        assertThat(sink.events).anySatisfy(event -> {
            assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
            VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
            assertThat(passthrough.source()).isEqualTo(ProtocolId.JT808);
            assertThat(passthrough.metadata()).containsEntry("frameError", "true")
                    .containsEntry("frameErrorMessage", "jt808 missing frame boundary")
                    .containsEntry("identityResolved", "false")
                    .containsEntry("identitySource", "UNKNOWN");
            assertThat(passthrough.data()).containsExactly(raw);
        });

        batchExecutor.close();
        eventBus.close();
    }

    @Test
    void identityResolverFailureStillArchivesAndDispatchesDecodedLocationWithUnknownIdentity() throws Exception {
        RecordingSink sink = new RecordingSink(2);
        var failingIdentity = (com.lingniu.ingest.identity.VehicleIdentityResolver) lookup -> {
            throw new IllegalStateException("identity backend unavailable");
        };
        DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
        AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
        Dispatcher dispatcher = new Dispatcher(
                registryWithLocationHandler(new DirectLocationHandler(new Jt808EventMapper(failingIdentity))),
                new InterceptorChain(List.of()),
                new HandlerInvoker(),
                eventBus,
                batchExecutor);
        Jt808ChannelHandler handler = new Jt808ChannelHandler(
                new Jt808MessageDecoder(new BodyParserRegistry(List.of(new LocationBodyParser()))),
                dispatcher,
                new TestSessionStore(),
                new InMemoryVehicleIdentityService(),
                failingIdentity,
                new Jt808ChannelRegistry(),
                new Jt808PendingRequests());
        EmbeddedChannel channel = new EmbeddedChannel(handler);
        byte[] frame = buildFrame(Jt808MessageId.TERMINAL_LOCATION, "123456789012", 1, buildLocationBody());

        channel.writeInbound(frame);

        assertThat(sink.await()).isTrue();
        assertThat(sink.events).anySatisfy(event -> {
            assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
            VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
            assertThat(archive.source()).isEqualTo(ProtocolId.JT808);
            assertThat(archive.command()).isEqualTo(Jt808MessageId.TERMINAL_LOCATION);
            assertThat(archive.rawBytes()).containsExactly(frame);
            assertThat(archive.metadata())
                    .containsEntry("vin", "unknown")
                    .containsEntry("phone", "123456789012")
                    .containsEntry("identityResolved", "false")
                    .containsEntry("identitySource", "UNKNOWN")
                    .containsEntry("identityError", "true")
                    .containsEntry("identityErrorMessage", "identity backend unavailable");
        });
        assertThat(sink.events).anySatisfy(event -> {
            assertThat(event).isInstanceOf(VehicleEvent.Location.class);
            VehicleEvent.Location location = (VehicleEvent.Location) event;
            assertThat(location.source()).isEqualTo(ProtocolId.JT808);
            assertThat(location.vin()).isEqualTo("unknown");
            assertThat(location.metadata())
                    .containsEntry("vin", "unknown")
                    .containsEntry("phone", "123456789012")
                    .containsEntry("identityResolved", "false")
                    .containsEntry("identitySource", "UNKNOWN")
                    .containsEntry("identityError", "true")
                    .containsEntry("identityErrorMessage", "identity backend unavailable");
        });

        batchExecutor.close();
        eventBus.close();
    }

    private static HandlerRegistry registryWithRawHandler() throws NoSuchMethodException {
        HandlerRegistry registry = new HandlerRegistry();
        Jt808LocationHandler rawHandler = new Jt808LocationHandler(
                new Jt808EventMapper(new InMemoryVehicleIdentityService()));
        registry.register(new HandlerDefinition(
                ProtocolId.JT808,
                0,
                0,
                "test raw",
                rawHandler,
                Jt808LocationHandler.class.getMethod("onRaw", Jt808Message.class),
                Jt808Message.class,
                null,
                null,
                null));
        return registry;
    }

    private static HandlerRegistry registryWithLocationHandler(DirectLocationHandler locationHandler) throws NoSuchMethodException {
        HandlerRegistry registry = new HandlerRegistry();
        registry.register(new HandlerDefinition(
                ProtocolId.JT808,
                Jt808MessageId.TERMINAL_LOCATION,
                0,
                "test location",
                locationHandler,
                DirectLocationHandler.class.getMethod("onLocation", Jt808Message.class),
                Jt808Message.class,
                null,
                null,
                null));
        return registry;
    }

    public static final class DirectLocationHandler {
        private final Jt808EventMapper mapper;

        private DirectLocationHandler(Jt808EventMapper mapper) {
            this.mapper = mapper;
        }

        public List<VehicleEvent> onLocation(Jt808Message msg) {
            return mapper.toEvents(msg);
        }
    }

    private static byte[] buildLocationBody() {
        ByteArrayOutputStream os = new ByteArrayOutputStream();
        writeU32(os, 0);
        writeU32(os, 0x00030000);
        writeU32(os, 39_916_527L);
        writeU32(os, 116_397_128L);
        writeU16(os, 50);
        writeU16(os, 523);
        writeU16(os, 90);
        byte[] bcd = BcdCodec.encode("240102030405");
        os.write(bcd, 0, 6);
        os.write(0x01);
        os.write(4);
        writeU32(os, 12345);
        os.write(0x30);
        os.write(1);
        os.write(0x58);
        return os.toByteArray();
    }

    private static byte[] buildRegisterBody(String deviceId, String plate) {
        ByteArrayOutputStream os = new ByteArrayOutputStream();
        writeU16(os, 44);
        writeU16(os, 4401);
        writeAscii(os, "MAKER", 5);
        writeAscii(os, "TYPE-A", 20);
        writeAscii(os, deviceId, 7);
        os.write(1);
        byte[] plateBytes = plate.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
        os.write(plateBytes, 0, plateBytes.length);
        return os.toByteArray();
    }

    private static void writeAscii(ByteArrayOutputStream os, String value, int len) {
        byte[] raw = value.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
        int copy = Math.min(raw.length, len);
        os.write(raw, 0, copy);
        for (int i = copy; i < len; i++) {
            os.write(0x20);
        }
    }

    private static byte[] buildFrame(int msgId, String phone, int serial, byte[] body) {
        ByteArrayOutputStream os = new ByteArrayOutputStream();
        writeU16(os, msgId);
        writeU16(os, body.length & 0x03FF);
        byte[] phoneBcd = BcdCodec.encode(phone);
        os.write(phoneBcd, 0, 6);
        writeU16(os, serial);
        os.write(body, 0, body.length);
        byte[] raw = os.toByteArray();
        byte bcc = BccChecksum.compute(raw, 0, raw.length);

        ByteArrayOutputStream framed = new ByteArrayOutputStream();
        framed.write(raw, 0, raw.length);
        framed.write(bcc & 0xFF);
        return framed.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 ByteBuf readOutboundAfterRunningTasks(EmbeddedChannel channel) {
        long deadline = System.currentTimeMillis() + 3000;
        while (System.currentTimeMillis() < deadline) {
            channel.runPendingTasks();
            ByteBuf outbound = channel.readOutbound();
            if (outbound != null) {
                return outbound;
            }
            Thread.onSpinWait();
        }
        return null;
    }

    private static final class ControlledSink implements EventSink {
        private final List<CompletableFuture<Void>> futures = new CopyOnWriteArrayList<>();

        @Override
        public String name() {
            return "kafka";
        }

        @Override
        public CompletableFuture<Void> publish(VehicleEvent event) {
            CompletableFuture<Void> future = new CompletableFuture<>();
            futures.add(future);
            return future;
        }

        private CompletableFuture<Void> future(int index) {
            return futures.get(index);
        }

        private void awaitPublishCount(int expected) {
            long deadline = System.currentTimeMillis() + 3000;
            while (System.currentTimeMillis() < deadline) {
                if (futures.size() >= expected) {
                    return;
                }
                Thread.onSpinWait();
            }
            throw new AssertionError("expected " + expected + " sink publishes, actual=" + futures.size());
        }
    }

    private static final class ImmediateKafkaSink implements EventSink {
        @Override
        public String name() {
            return "kafka";
        }

        @Override
        public CompletableFuture<Void> publish(VehicleEvent event) {
            return CompletableFuture.completedFuture(null);
        }
    }

    private static final class RecordingSink implements EventSink {
        private final List<VehicleEvent> events = new CopyOnWriteArrayList<>();
        private final CountDownLatch latch;

        private RecordingSink(int expectedEvents) {
            this.latch = new CountDownLatch(expectedEvents);
        }

        @Override
        public String name() {
            return "kafka";
        }

        @Override
        public CompletableFuture<Void> publish(VehicleEvent event) {
            events.add(event);
            latch.countDown();
            return CompletableFuture.completedFuture(null);
        }

        private boolean await() throws InterruptedException {
            return latch.await(3, TimeUnit.SECONDS);
        }
    }
}
