package com.lingniu.ingest.tdenginehistory;

import org.junit.jupiter.api.Test;

import javax.sql.DataSource;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

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

class TdengineJdbcHistoryWriterTest {

    private final TdengineHistorySchema schema = new TdengineHistorySchema("vehicle_history");

    @Test
    void initializesSchemaWithBootstrapSql() throws Exception {
        RecordingJdbc jdbc = new RecordingJdbc();
        TdengineJdbcHistoryWriter writer = new TdengineJdbcHistoryWriter(jdbc.dataSource(), schema);

        writer.initializeSchema();

        assertThat(jdbc.executedSql)
                .containsExactlyElementsOf(schema.bootstrapSql());
        assertThat(jdbc.commits).isEqualTo(1);
    }

    @Test
    void writesRawFramesInBatchesPerChildTable() throws Exception {
        RecordingJdbc jdbc = new RecordingJdbc();
        TdengineJdbcHistoryWriter writer = new TdengineJdbcHistoryWriter(jdbc.dataSource(), schema);
        TdengineRawFrameRow first = rawFrame("frame-1", Instant.parse("2026-06-29T05:00:01Z"), "none");
        TdengineRawFrameRow second = rawFrame("frame-2", Instant.parse("2026-06-29T05:00:02Z"), "none");

        writer.appendRawFrames(List.of(first, second));

        assertThat(jdbc.executedSql).startsWith(schema.bootstrapSql().toArray(String[]::new));
        assertThat(jdbc.executedSql)
                .filteredOn(sql -> sql.contains("USING raw_frames TAGS"))
                .hasSize(1);
        assertThat(jdbc.preparedBatches).isEmpty();
        assertThat(jdbc.executedSql)
                .anyMatch(sql -> sql.startsWith("INSERT INTO raw_jt808_")
                        && sql.contains(Long.toString(first.ts().toEpochMilli()))
                        && sql.contains("frame-1")
                        && sql.contains("frame-2")
                        && sql.contains(") ("));
        assertThat(jdbc.commits).isEqualTo(1);
    }

    @Test
    void writesLocationRowsWithNullableMileage() throws Exception {
        RecordingJdbc jdbc = new RecordingJdbc();
        TdengineJdbcHistoryWriter writer = new TdengineJdbcHistoryWriter(jdbc.dataSource(), schema);
        TdengineLocationRow location = new TdengineLocationRow(
                Instant.parse("2026-06-29T05:00:01Z"),
                "fact-1",
                "frame-1",
                Instant.parse("2026-06-29T05:00:02Z"),
                113.1,
                23.4,
                8.0,
                42.0,
                90.0,
                1,
                3,
                null,
                "archive://gb32960/frame-1.bin",
                "GB32960",
                "vin:VIN123",
                "VIN123",
                "");

        writer.appendLocations(List.of(location));

        assertThat(jdbc.executedSql).startsWith(schema.bootstrapSql().toArray(String[]::new));
        assertThat(jdbc.executedSql)
                .anyMatch(sql -> sql.contains("USING vehicle_locations TAGS"));
        assertThat(jdbc.preparedBatches).isEmpty();
        assertThat(jdbc.executedSql)
                .anyMatch(sql -> sql.startsWith("INSERT INTO loc_gb32960_")
                        && sql.contains("fact-1")
                        && sql.contains("NULL")
                        && sql.contains("archive://gb32960/frame-1.bin"));
    }

    @Test
    void schemaBootstrapRunsOnlyOnceAcrossWrites() throws Exception {
        RecordingJdbc jdbc = new RecordingJdbc();
        TdengineJdbcHistoryWriter writer = new TdengineJdbcHistoryWriter(jdbc.dataSource(), schema);

        writer.appendRawFrames(List.of(rawFrame("frame-1", Instant.parse("2026-06-29T05:00:01Z"))));
        writer.appendRawFrames(List.of(rawFrame("frame-2", Instant.parse("2026-06-29T05:00:02Z"))));

        assertThat(jdbc.executedSql)
                .filteredOn(sql -> sql.startsWith("CREATE DATABASE"))
                .hasSize(1);
        assertThat(jdbc.commits).isEqualTo(2);
    }

    @Test
    void writesLiteralInsertWithoutPreparedBatch() throws Exception {
        RecordingJdbc jdbc = new RecordingJdbc();
        jdbc.failPreparedBatches = true;
        TdengineJdbcHistoryWriter writer = new TdengineJdbcHistoryWriter(jdbc.dataSource(), schema);

        writer.appendRawFrames(List.of(rawFrame("frame-1", Instant.parse("2026-06-29T05:00:01Z"), "none")));

        assertThat(jdbc.preparedBatches).isEmpty();
        assertThat(jdbc.executedSql)
                .anyMatch(sql -> sql.startsWith("INSERT INTO raw_jt808_")
                        && sql.contains("parse_error")
                        && sql.contains("'SUCCEEDED', 'none', '10.0.0.1:808'"));
        assertThat(jdbc.commits).isEqualTo(1);
    }

    @Test
    void usesLiteralInsertForRowsWithEmptyStrings() throws Exception {
        RecordingJdbc jdbc = new RecordingJdbc();
        TdengineJdbcHistoryWriter writer = new TdengineJdbcHistoryWriter(jdbc.dataSource(), schema);

        writer.appendRawFrames(List.of(rawFrame("frame-1", Instant.parse("2026-06-29T05:00:01Z"))));

        assertThat(jdbc.preparedBatches).isEmpty();
        assertThat(jdbc.executedSql)
                .anyMatch(sql -> sql.startsWith("INSERT INTO raw_jt808_")
                        && sql.contains("parse_error")
                        && sql.contains("'SUCCEEDED', '', '10.0.0.1:808'"));
        assertThat(jdbc.commits).isEqualTo(1);
    }

    @Test
    void groupsLiteralRowsForSameChildTableIntoOneInsert() throws Exception {
        RecordingJdbc jdbc = new RecordingJdbc();
        TdengineJdbcHistoryWriter writer = new TdengineJdbcHistoryWriter(jdbc.dataSource(), schema);

        writer.appendRawFrames(List.of(
                rawFrame("frame-1", Instant.parse("2026-06-29T05:00:01Z")),
                rawFrame("frame-2", Instant.parse("2026-06-29T05:00:02Z"))));

        List<String> inserts = jdbc.executedSql.stream()
                .filter(sql -> sql.startsWith("INSERT INTO raw_jt808_"))
                .toList();
        assertThat(inserts).hasSize(1);
        assertThat(inserts.getFirst())
                .contains("frame-1")
                .contains("frame-2")
                .contains(") (");
    }

    @Test
    void bumpsDuplicateTimestampsForSameChildTable() throws Exception {
        RecordingJdbc jdbc = new RecordingJdbc();
        TdengineJdbcHistoryWriter writer = new TdengineJdbcHistoryWriter(jdbc.dataSource(), schema);
        Instant ts = Instant.parse("2026-06-29T05:00:01Z");

        writer.appendRawFrames(List.of(
                rawFrame("frame-1", ts),
                rawFrame("frame-2", ts)));

        String insert = jdbc.executedSql.stream()
                .filter(sql -> sql.startsWith("INSERT INTO raw_jt808_"))
                .findFirst()
                .orElseThrow();
        assertThat(insert)
                .contains("(" + ts.toEpochMilli() + ", 'frame-1'")
                .contains("(" + (ts.toEpochMilli() + 1) + ", 'frame-2'");
    }

    private static TdengineRawFrameRow rawFrame(String frameId, Instant ts) {
        return rawFrame(frameId, ts, "");
    }

    private static TdengineRawFrameRow rawFrame(String frameId, Instant ts, String parseError) {
        return new TdengineRawFrameRow(
                ts,
                frameId,
                ts.plusSeconds(1),
                0x0200,
                0,
                ts,
                "archive://jt808/" + frameId + ".bin",
                "sha256:" + frameId,
                67,
                "SUCCEEDED",
                parseError,
                "10.0.0.1:808",
                "{}",
                "{\"frame\":\"" + frameId + "\"}",
                "JT808",
                "jt808:g7gps",
                "VIN123",
                "013800000000");
    }

    private static final class RecordingJdbc {
        private final List<String> executedSql = new ArrayList<>();
        private final Map<String, PreparedBatch> preparedBatches = new HashMap<>();
        private boolean failPreparedBatches;
        private int commits;
        private int rollbacks;

        DataSource dataSource() {
            return (DataSource) Proxy.newProxyInstance(
                    getClass().getClassLoader(),
                    new Class<?>[]{DataSource.class},
                    (proxy, method, args) -> switch (method.getName()) {
                        case "getConnection" -> connection();
                        case "unwrap" -> null;
                        case "isWrapperFor" -> false;
                        default -> defaultValue(method.getReturnType());
                    });
        }

        private Connection connection() {
            return (Connection) Proxy.newProxyInstance(
                    getClass().getClassLoader(),
                    new Class<?>[]{Connection.class},
                    (proxy, method, args) -> switch (method.getName()) {
                        case "createStatement" -> statement();
                        case "prepareStatement" -> preparedStatement((String) args[0]);
                        case "commit" -> {
                            commits++;
                            yield null;
                        }
                        case "rollback" -> {
                            rollbacks++;
                            yield null;
                        }
                        case "getAutoCommit" -> true;
                        case "unwrap" -> null;
                        case "isWrapperFor" -> false;
                        default -> defaultValue(method.getReturnType());
                    });
        }

        private Statement statement() {
            return (Statement) Proxy.newProxyInstance(
                    getClass().getClassLoader(),
                    new Class<?>[]{Statement.class},
                    (proxy, method, args) -> switch (method.getName()) {
                        case "execute", "executeUpdate" -> {
                            executedSql.add((String) args[0]);
                            yield method.getReturnType() == boolean.class ? true : 1;
                        }
                        case "unwrap" -> null;
                        case "isWrapperFor" -> false;
                        default -> defaultValue(method.getReturnType());
                    });
        }

        private PreparedStatement preparedStatement(String sql) {
            PreparedBatch batch = preparedBatches.computeIfAbsent(sql, PreparedBatch::new);
            Map<Integer, Object> current = new HashMap<>();
            return (PreparedStatement) Proxy.newProxyInstance(
                    getClass().getClassLoader(),
                    new Class<?>[]{PreparedStatement.class},
                    (proxy, method, args) -> switch (method.getName()) {
                        case "setTimestamp", "setObject" -> {
                            current.put((Integer) args[0], args[1]);
                            yield null;
                        }
                        case "addBatch" -> {
                            batch.rows().add(new HashMap<>(current));
                            current.clear();
                            yield null;
                        }
                        case "executeBatch" -> {
                            if (failPreparedBatches) {
                                throw new SQLException("simulated prepared batch failure");
                            }
                            yield new int[batch.rows().size()];
                        }
                        case "unwrap" -> null;
                        case "isWrapperFor" -> false;
                        default -> defaultValue(method.getReturnType());
                    });
        }

        private static Object defaultValue(Class<?> type) {
            if (type == boolean.class) {
                return false;
            }
            if (type == int.class) {
                return 0;
            }
            if (type == long.class) {
                return 0L;
            }
            return null;
        }

        record PreparedBatch(String sql, List<Map<Integer, Object>> rows) {
            PreparedBatch(String sql) {
                this(sql, new ArrayList<>());
            }
        }
    }
}
