package history

import (
	"compress/gzip"
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"
)

type Queryer interface {
	QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}

type RawFrameQuery struct {
	Protocol       string
	VehicleKey     string
	VIN            string
	Phone          string
	DeviceID       string
	MessageID      string
	OrderBy        string
	IncludeFields  bool
	IncludePayload bool
	IncludeTotal   bool
	ParsedFields   []string
	DateFrom       string
	DateTo         string
	Limit          int
	Offset         int
}

type RawFrameRow struct {
	TS             string          `json:"ts"`
	FrameID        string          `json:"frame_id"`
	EventID        string          `json:"event_id"`
	MessageID      int64           `json:"message_id"`
	MessageIDHex   string          `json:"message_id_hex"`
	EventTime      string          `json:"event_time"`
	ReceivedAt     string          `json:"received_at"`
	RawSizeBytes   int64           `json:"raw_size_bytes"`
	RawHex         string          `json:"raw_hex,omitempty"`
	RawText        string          `json:"raw_text,omitempty"`
	ParsedFields   json.RawMessage `json:"parsed_fields,omitempty"`
	ParseStatus    string          `json:"parse_status"`
	ParseError     string          `json:"parse_error,omitempty"`
	SourceEndpoint string          `json:"source_endpoint"`
	Protocol       string          `json:"protocol"`
	VehicleKey     string          `json:"vehicle_key"`
	VIN            string          `json:"vin"`
	Phone          string          `json:"phone,omitempty"`
	DeviceID       string          `json:"device_id,omitempty"`
}

type LocationQuery struct {
	Protocol     string
	VIN          string
	IncludeTotal bool
	DateFrom     string
	DateTo       string
	Limit        int
	Offset       int
}

type LocationRow struct {
	TS             string   `json:"ts"`
	EventID        string   `json:"event_id"`
	ReceivedAt     string   `json:"received_at"`
	Longitude      float64  `json:"longitude"`
	Latitude       float64  `json:"latitude"`
	AltitudeM      *float64 `json:"altitude_m,omitempty"`
	SpeedKMH       *float64 `json:"speed_kmh,omitempty"`
	SOCPercent     *float64 `json:"soc_percent,omitempty"`
	DirectionDeg   *int64   `json:"direction_deg,omitempty"`
	AlarmFlag      *int64   `json:"alarm_flag,omitempty"`
	StatusFlag     *int64   `json:"status_flag,omitempty"`
	TotalMileageKM *float64 `json:"total_mileage_km,omitempty"`
	Protocol       string   `json:"protocol"`
	VIN            string   `json:"vin"`
}

type rawFrameQueryRequest struct {
	Protocol       string   `json:"protocol"`
	VehicleKey     string   `json:"vehicleKey"`
	VehicleKeyAlt  string   `json:"vehicle_key"`
	VIN            string   `json:"vin"`
	Phone          string   `json:"phone"`
	DeviceID       string   `json:"deviceId"`
	DeviceIDAlt    string   `json:"device_id"`
	MessageID      string   `json:"messageId"`
	MessageIDAlt   string   `json:"message_id"`
	OrderBy        string   `json:"orderBy"`
	OrderByAlt     string   `json:"order_by"`
	IncludeFields  bool     `json:"includeFields"`
	IncludePayload bool     `json:"includePayload"`
	IncludeTotal   bool     `json:"includeTotal"`
	Fields         []string `json:"fields"`
	ParsedFields   []string `json:"parsedFields"`
	DateFrom       string   `json:"dateFrom"`
	DateFromAlt    string   `json:"date_from"`
	DateTo         string   `json:"dateTo"`
	DateToAlt      string   `json:"date_to"`
	Limit          int      `json:"limit"`
	Offset         int      `json:"offset"`
}

type RawFrameRepository struct {
	db       Queryer
	database string
}

func NewRawFrameRepository(db Queryer, database string) *RawFrameRepository {
	if db == nil {
		panic("raw frame query db must not be nil")
	}
	database = strings.TrimSpace(database)
	if database != "" && !safeIdentifier(database) {
		database = ""
	}
	return &RawFrameRepository{db: db, database: database}
}

type LocationRepository struct {
	db       Queryer
	database string
}

func NewLocationRepository(db Queryer, database string) *LocationRepository {
	if db == nil {
		panic("location query db must not be nil")
	}
	database = strings.TrimSpace(database)
	if database != "" && !safeIdentifier(database) {
		database = ""
	}
	return &LocationRepository{db: db, database: database}
}

func (r *RawFrameRepository) Query(ctx context.Context, query RawFrameQuery) ([]RawFrameRow, error) {
	query = normalizeRawFrameQuery(query)
	sqlText, args := buildRawFrameSQL(r.tableName(query), query)
	rows, err := r.db.QueryContext(ctx, sqlText, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	out := make([]RawFrameRow, 0)
	for rows.Next() {
		var row RawFrameRow
		var ts scanDateTime
		var eventTime scanDateTime
		var receivedAt scanDateTime
		var parsedFields string
		if err := rows.Scan(
			&ts,
			&row.FrameID,
			&row.EventID,
			&row.MessageID,
			&eventTime,
			&receivedAt,
			&row.RawSizeBytes,
			&row.RawHex,
			&row.RawText,
			&parsedFields,
			&row.ParseStatus,
			&row.ParseError,
			&row.SourceEndpoint,
			&row.Protocol,
			&row.VehicleKey,
			&row.VIN,
			&row.Phone,
			&row.DeviceID,
		); err != nil {
			return nil, err
		}
		row.TS = ts.String
		row.EventTime = eventTime.String
		row.ReceivedAt = receivedAt.String
		row.MessageIDHex = fmt.Sprintf("0x%04X", row.MessageID)
		row.ParsedFields = rawJSONMessage(parsedFields)
		out = append(out, row)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	if err := r.hydratePayloadChunks(ctx, out); err != nil {
		return nil, err
	}
	filterParsedFields(out, query.ParsedFields)
	return out, nil
}

func (r *RawFrameRepository) Count(ctx context.Context, query RawFrameQuery) (int64, error) {
	query = normalizeRawFrameQuery(query)
	sqlText, args := buildRawFrameCountSQL(r.tableName(query), query)
	return countRows(ctx, r.db, sqlText, args...)
}

func (r *LocationRepository) Query(ctx context.Context, query LocationQuery) ([]LocationRow, error) {
	query = normalizeLocationQuery(query)
	sqlText, args := buildLocationSQL(r.tableName(), query)
	rows, err := r.db.QueryContext(ctx, sqlText, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	out := make([]LocationRow, 0)
	for rows.Next() {
		var row LocationRow
		var ts scanDateTime
		var receivedAt scanDateTime
		var altitude sql.NullFloat64
		var speed sql.NullFloat64
		var soc sql.NullFloat64
		var direction sql.NullInt64
		var alarm sql.NullInt64
		var status sql.NullInt64
		var mileage sql.NullFloat64
		if err := rows.Scan(
			&ts,
			&row.EventID,
			&receivedAt,
			&row.Longitude,
			&row.Latitude,
			&altitude,
			&speed,
			&soc,
			&direction,
			&alarm,
			&status,
			&mileage,
			&row.Protocol,
			&row.VIN,
		); err != nil {
			return nil, err
		}
		row.TS = ts.String
		row.ReceivedAt = receivedAt.String
		row.AltitudeM = nullableFloat(altitude)
		row.SpeedKMH = nullableFloat(speed)
		row.SOCPercent = nullableFloat(soc)
		row.DirectionDeg = nullableInt(direction)
		row.AlarmFlag = nullableInt(alarm)
		row.StatusFlag = nullableInt(status)
		row.TotalMileageKM = nullableFloat(mileage)
		out = append(out, row)
	}
	return out, rows.Err()
}

func (r *LocationRepository) Count(ctx context.Context, query LocationQuery) (int64, error) {
	query = normalizeLocationQuery(query)
	sqlText, args := buildLocationCountSQL(r.tableName(), query)
	return countRows(ctx, r.db, sqlText, args...)
}

func countRows(ctx context.Context, db Queryer, sqlText string, args ...any) (int64, error) {
	rows, err := db.QueryContext(ctx, sqlText, args...)
	if err != nil {
		return 0, err
	}
	defer rows.Close()
	var total int64
	if rows.Next() {
		if err := rows.Scan(&total); err != nil {
			return 0, err
		}
	}
	return total, rows.Err()
}

func (r *RawFrameRepository) tableName(query ...RawFrameQuery) string {
	if len(query) > 0 {
		if child := r.rawChildTableName(query[0]); child != "" {
			return child
		}
	}
	if r.database == "" {
		return "raw_frames"
	}
	return r.database + ".raw_frames"
}

func (r *RawFrameRepository) rawChildTableName(query RawFrameQuery) string {
	protocol := strings.ToUpper(strings.TrimSpace(query.Protocol))
	if protocol == "" {
		return ""
	}
	vehicleKey := strings.TrimSpace(query.VehicleKey)
	if vehicleKey == "" {
		vehicleKey = strings.TrimSpace(query.VIN)
	}
	if vehicleKey == "" {
		return ""
	}
	table := "raw_" + strings.ToLower(protocol) + "_" + hash16(vehicleKey)
	if r.database != "" {
		return r.database + "." + table
	}
	return table
}

func (r *RawFrameRepository) chunkTableName() string {
	if r.database == "" {
		return "raw_frame_payload_chunks"
	}
	return r.database + ".raw_frame_payload_chunks"
}

func (r *LocationRepository) tableName() string {
	if r.database == "" {
		return "vehicle_locations"
	}
	return r.database + ".vehicle_locations"
}

func normalizeRawFrameQuery(query RawFrameQuery) RawFrameQuery {
	query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol))
	query.VehicleKey = strings.TrimSpace(query.VehicleKey)
	query.VIN = strings.TrimSpace(query.VIN)
	query.Phone = strings.TrimSpace(query.Phone)
	query.DeviceID = strings.TrimSpace(query.DeviceID)
	query.MessageID = strings.TrimSpace(query.MessageID)
	query.ParsedFields = normalizeParsedFieldNames(query.ParsedFields)
	if len(query.ParsedFields) > 0 {
		query.IncludeFields = true
	}
	query.OrderBy = normalizeRawFrameOrderBy(query.OrderBy)
	query.DateFrom = normalizeDateTimeLiteral(query.DateFrom)
	query.DateTo = normalizeDateTimeLiteral(query.DateTo)
	if query.Limit <= 0 {
		query.Limit = 20
	}
	return query
}

func normalizeLocationQuery(query LocationQuery) LocationQuery {
	query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol))
	query.VIN = strings.TrimSpace(query.VIN)
	query.DateFrom = normalizeDateTimeLiteral(query.DateFrom)
	query.DateTo = normalizeDateTimeLiteral(query.DateTo)
	if query.Limit <= 0 {
		query.Limit = 20
	}
	return query
}

func buildRawFrameSQL(table string, query RawFrameQuery) (string, []any) {
	where := rawFrameWhere(query)
	rawHexSelect := "'' AS raw_hex"
	rawTextSelect := "'' AS raw_text"
	parsedFieldsSelect := "'' AS parsed_fields"
	if query.IncludePayload {
		rawHexSelect = "raw_hex"
		rawTextSelect = "raw_text"
	}
	if query.IncludeFields {
		parsedFieldsSelect = "parsed_json AS parsed_fields"
	}
	sqlText := `SELECT ts, frame_id, event_id, message_id, event_time, received_at, raw_size_bytes, ` + rawHexSelect + `, ` + rawTextSelect + `, ` + parsedFieldsSelect + `, parse_status, parse_error, source_endpoint, protocol, vehicle_key, vin, phone, device_id FROM ` + table
	if len(where) > 0 {
		sqlText += " WHERE " + strings.Join(where, " AND ")
	}
	sqlText += " ORDER BY " + rawFrameOrderColumn(query.OrderBy) + " DESC LIMIT " + strconv.Itoa(query.Limit) + " OFFSET " + strconv.Itoa(query.Offset)
	return sqlText, nil
}

func buildRawFrameCountSQL(table string, query RawFrameQuery) (string, []any) {
	sqlText := `SELECT COUNT(*) FROM ` + table
	if where := rawFrameWhere(query); len(where) > 0 {
		sqlText += " WHERE " + strings.Join(where, " AND ")
	}
	return sqlText, nil
}

type payloadChunkManifest struct {
	Chunked     bool   `json:"chunked"`
	PayloadKind string `json:"payload_kind"`
	EventID     string `json:"event_id"`
	ChunkCount  int    `json:"chunk_count"`
}

func (r *RawFrameRepository) hydratePayloadChunks(ctx context.Context, rows []RawFrameRow) error {
	type target struct {
		rowIndex int
		kind     string
		eventID  string
		count    int
	}
	targets := make([]target, 0)
	eventSeen := map[string]struct{}{}
	for index := range rows {
		for _, candidate := range []struct {
			kind  string
			value string
		}{
			{kind: "raw_hex", value: rows[index].RawHex},
			{kind: "raw_text", value: rows[index].RawText},
			{kind: "parsed_fields", value: string(rows[index].ParsedFields)},
		} {
			manifest, ok := parsePayloadChunkManifest(candidate.value)
			if !ok || !payloadKindMatches(candidate.kind, manifest.PayloadKind) || manifest.ChunkCount <= 0 {
				continue
			}
			eventID := strings.TrimSpace(manifest.EventID)
			if eventID == "" {
				eventID = rows[index].EventID
			}
			targets = append(targets, target{
				rowIndex: index,
				kind:     candidate.kind,
				eventID:  eventID,
				count:    manifest.ChunkCount,
			})
			eventSeen[eventID] = struct{}{}
		}
	}
	if len(targets) == 0 {
		return nil
	}

	eventIDs := make([]string, 0, len(eventSeen))
	for eventID := range eventSeen {
		eventIDs = append(eventIDs, eventID)
	}
	sqlText := `SELECT event_id, payload_kind, chunk_index, chunk_text FROM ` + r.chunkTableName() +
		" WHERE event_id IN (" + quotedList(eventIDs) + ") ORDER BY event_id, payload_kind, chunk_index"
	chunkRows, err := r.db.QueryContext(ctx, sqlText)
	if err != nil {
		return err
	}
	defer chunkRows.Close()

	chunks := map[string]map[string][]string{}
	for chunkRows.Next() {
		var eventID string
		var kind string
		var index int
		var text string
		if err := chunkRows.Scan(&eventID, &kind, &index, &text); err != nil {
			return err
		}
		if chunks[eventID] == nil {
			chunks[eventID] = map[string][]string{}
		}
		current := chunks[eventID][kind]
		if index >= len(current) {
			next := make([]string, index+1)
			copy(next, current)
			current = next
		}
		current[index] = text
		chunks[eventID][kind] = current
	}
	if err := chunkRows.Err(); err != nil {
		return err
	}

	for _, target := range targets {
		parts := chunks[target.eventID][target.kind]
		if len(parts) != target.count {
			continue
		}
		hydrated := strings.Join(parts, "")
		switch target.kind {
		case "raw_hex":
			rows[target.rowIndex].RawHex = hydrated
		case "raw_text":
			rows[target.rowIndex].RawText = hydrated
		case "parsed_fields":
			rows[target.rowIndex].ParsedFields = rawJSONMessage(hydrated)
		}
	}
	return nil
}

func rawJSONMessage(value string) json.RawMessage {
	value = strings.TrimSpace(value)
	if value == "" {
		return nil
	}
	if json.Valid([]byte(value)) {
		return json.RawMessage(value)
	}
	return json.RawMessage(jsonString(value))
}

func filterParsedFields(rows []RawFrameRow, fieldNames []string) {
	fieldNames = normalizeParsedFieldNames(fieldNames)
	if len(fieldNames) == 0 {
		return
	}
	for index := range rows {
		if len(rows[index].ParsedFields) == 0 {
			continue
		}
		var fields map[string]any
		if err := json.Unmarshal(rows[index].ParsedFields, &fields); err != nil {
			continue
		}
		filtered := make(map[string]any, len(fieldNames))
		for _, name := range fieldNames {
			if value, ok := fields[name]; ok {
				filtered[name] = value
			}
		}
		if len(filtered) == 0 {
			rows[index].ParsedFields = json.RawMessage(`{}`)
			continue
		}
		rows[index].ParsedFields = rawJSONMessage(jsonString(filtered))
	}
}

func normalizeParsedFieldNames(values []string) []string {
	out := make([]string, 0, len(values))
	seen := map[string]struct{}{}
	for _, value := range values {
		for _, part := range strings.Split(value, ",") {
			name := strings.TrimSpace(part)
			if name == "" {
				continue
			}
			if _, ok := seen[name]; ok {
				continue
			}
			seen[name] = struct{}{}
			out = append(out, name)
		}
	}
	return out
}

func payloadKindMatches(candidate string, manifest string) bool {
	if candidate == manifest {
		return true
	}
	return candidate == "parsed_fields" && manifest == "parsed_json"
}

func parsePayloadChunkManifest(value string) (payloadChunkManifest, bool) {
	var manifest payloadChunkManifest
	if !strings.Contains(value, `"chunked"`) {
		return manifest, false
	}
	if err := json.Unmarshal([]byte(value), &manifest); err != nil {
		return manifest, false
	}
	return manifest, manifest.Chunked
}

func quotedList(values []string) string {
	out := make([]string, 0, len(values))
	for _, value := range values {
		out = append(out, "'"+quote(value)+"'")
	}
	return strings.Join(out, ", ")
}

func buildLocationSQL(table string, query LocationQuery) (string, []any) {
	where := locationWhere(query)
	sqlText := `SELECT ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh, soc_percent, direction_deg, alarm_flag, status_flag, total_mileage_km, protocol, vin FROM ` + table
	if len(where) > 0 {
		sqlText += " WHERE " + strings.Join(where, " AND ")
	}
	sqlText += " ORDER BY ts DESC LIMIT " + strconv.Itoa(query.Limit) + " OFFSET " + strconv.Itoa(query.Offset)
	return sqlText, nil
}

func buildLocationCountSQL(table string, query LocationQuery) (string, []any) {
	sqlText := `SELECT COUNT(*) FROM ` + table
	if where := locationWhere(query); len(where) > 0 {
		sqlText += " WHERE " + strings.Join(where, " AND ")
	}
	return sqlText, nil
}

func rawFrameWhere(query RawFrameQuery) []string {
	var where []string
	add := func(clause string) {
		where = append(where, clause)
	}
	if query.Protocol != "" {
		add("protocol = '" + quote(query.Protocol) + "'")
	}
	if query.VehicleKey != "" {
		add("vehicle_key = '" + quote(query.VehicleKey) + "'")
	}
	if query.VIN != "" {
		add("vin = '" + quote(query.VIN) + "'")
	}
	if query.Phone != "" {
		add("phone = '" + quote(query.Phone) + "'")
	}
	if query.DeviceID != "" {
		add("device_id = '" + quote(query.DeviceID) + "'")
	}
	if query.MessageID != "" {
		if parsed, ok := parseMessageID(query.MessageID); ok {
			add("message_id = " + strconv.FormatInt(parsed, 10))
		}
	}
	if query.DateFrom != "" {
		add("ts >= '" + quote(normalizeDateTimeLiteral(query.DateFrom)) + "'")
	}
	if query.DateTo != "" {
		add("ts <= '" + quote(normalizeDateTimeLiteral(query.DateTo)) + "'")
	}
	return where
}

func normalizeRawFrameOrderBy(value string) string {
	value = strings.ToLower(strings.TrimSpace(value))
	switch value {
	case "receivedat", "received_at":
		return "receivedAt"
	default:
		return "ts"
	}
}

func rawFrameOrderColumn(value string) string {
	if normalizeRawFrameOrderBy(value) == "receivedAt" {
		return "received_at"
	}
	return "ts"
}

func locationWhere(query LocationQuery) []string {
	var where []string
	add := func(clause string) {
		where = append(where, clause)
	}
	if query.Protocol != "" {
		add("protocol = '" + quote(query.Protocol) + "'")
	}
	if query.VIN != "" {
		add("vin = '" + quote(query.VIN) + "'")
	}
	if query.DateFrom != "" {
		add("ts >= '" + quote(normalizeDateTimeLiteral(query.DateFrom)) + "'")
	}
	if query.DateTo != "" {
		add("ts <= '" + quote(normalizeDateTimeLiteral(query.DateTo)) + "'")
	}
	return where
}

type RawFrameHandler struct {
	repository *RawFrameRepository
}

type LocationHandler struct {
	repository *LocationRepository
}

func NewRawFrameHandler(repository *RawFrameRepository) *RawFrameHandler {
	if repository == nil {
		panic("raw frame repository must not be nil")
	}
	return &RawFrameHandler{repository: repository}
}

func NewLocationHandler(repository *LocationRepository) *LocationHandler {
	if repository == nil {
		panic("location repository must not be nil")
	}
	return &LocationHandler{repository: repository}
}

func (h *RawFrameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet && r.Method != http.MethodPost {
		writeHistoryError(w, http.StatusMethodNotAllowed, "method not allowed")
		return
	}
	path := strings.Trim(r.URL.Path, "/")
	if path != "api/history/raw-frames" && path != "api/history/raw-frames/query" {
		writeHistoryError(w, http.StatusNotFound, "route not found")
		return
	}
	query, err := rawFrameQueryFromRequest(r)
	if err != nil {
		writeHistoryError(w, http.StatusBadRequest, err.Error())
		return
	}
	var total int64
	if query.IncludeTotal {
		total, err = h.repository.Count(r.Context(), query)
		if err != nil {
			writeHistoryError(w, http.StatusInternalServerError, err.Error())
			return
		}
	}
	rows, err := h.repository.Query(r.Context(), query)
	if err != nil {
		writeHistoryError(w, http.StatusInternalServerError, err.Error())
		return
	}
	if !query.IncludeTotal {
		total = int64(len(rows))
	}
	writeHistoryJSON(w, r, map[string]any{
		"items":  rows,
		"total":  total,
		"limit":  query.Limit,
		"offset": query.Offset,
	})
}

func (h *LocationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		writeHistoryError(w, http.StatusMethodNotAllowed, "method not allowed")
		return
	}
	if strings.Trim(r.URL.Path, "/") != "api/history/locations" {
		writeHistoryError(w, http.StatusNotFound, "route not found")
		return
	}
	query, err := parseLocationQuery(r)
	if err != nil {
		writeHistoryError(w, http.StatusBadRequest, err.Error())
		return
	}
	var total int64
	if query.IncludeTotal {
		total, err = h.repository.Count(r.Context(), query)
		if err != nil {
			writeHistoryError(w, http.StatusInternalServerError, err.Error())
			return
		}
	}
	rows, err := h.repository.Query(r.Context(), query)
	if err != nil {
		writeHistoryError(w, http.StatusInternalServerError, err.Error())
		return
	}
	if !query.IncludeTotal {
		total = int64(len(rows))
	}
	writeHistoryJSON(w, r, map[string]any{
		"items":  rows,
		"total":  total,
		"limit":  query.Limit,
		"offset": query.Offset,
	})
}

func writeHistoryJSON(w http.ResponseWriter, r *http.Request, value any) {
	w.Header().Set("Content-Type", "application/json")
	w.Header().Add("Vary", "Accept-Encoding")
	if strings.Contains(strings.ToLower(r.Header.Get("Accept-Encoding")), "gzip") {
		w.Header().Set("Content-Encoding", "gzip")
		gzipWriter := gzip.NewWriter(w)
		defer gzipWriter.Close()
		_ = json.NewEncoder(gzipWriter).Encode(value)
		return
	}
	_ = json.NewEncoder(w).Encode(value)
}

func rawFrameQueryFromRequest(r *http.Request) (RawFrameQuery, error) {
	if r.Method == http.MethodPost {
		return parseRawFrameQueryBody(r)
	}
	return parseRawFrameQuery(r)
}

func parseRawFrameQuery(r *http.Request) (RawFrameQuery, error) {
	values := r.URL.Query()
	limit, err := parseBoundedInt(values.Get("limit"), 20, 1, 500, "limit")
	if err != nil {
		return RawFrameQuery{}, err
	}
	offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset")
	if err != nil {
		return RawFrameQuery{}, err
	}
	query := RawFrameQuery{
		Protocol:       values.Get("protocol"),
		VehicleKey:     values.Get("vehicleKey"),
		VIN:            values.Get("vin"),
		Phone:          values.Get("phone"),
		DeviceID:       values.Get("deviceId"),
		MessageID:      values.Get("messageId"),
		OrderBy:        values.Get("orderBy"),
		IncludeFields:  strings.EqualFold(strings.TrimSpace(values.Get("includeFields")), "true"),
		IncludePayload: strings.EqualFold(strings.TrimSpace(values.Get("includePayload")), "true"),
		IncludeTotal:   strings.EqualFold(strings.TrimSpace(values.Get("includeTotal")), "true"),
		ParsedFields:   append(values["fields"], values["parsedFields"]...),
		DateFrom:       values.Get("dateFrom"),
		DateTo:         values.Get("dateTo"),
		Limit:          limit,
		Offset:         offset,
	}
	if !validDateTime(query.DateFrom) || !validDateTime(query.DateTo) {
		return RawFrameQuery{}, errors.New("dateFrom/dateTo must use YYYY-MM-DD or YYYY-MM-DD HH:mm:ss")
	}
	if strings.TrimSpace(query.MessageID) != "" {
		if _, ok := parseMessageID(query.MessageID); !ok {
			return RawFrameQuery{}, errors.New("messageId must be decimal or hex like 0x0200")
		}
	}
	return normalizeRawFrameQuery(query), nil
}

func parseRawFrameQueryBody(r *http.Request) (RawFrameQuery, error) {
	defer r.Body.Close()
	decoder := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
	decoder.DisallowUnknownFields()
	var body rawFrameQueryRequest
	if err := decoder.Decode(&body); err != nil {
		return RawFrameQuery{}, fmt.Errorf("invalid JSON body: %w", err)
	}
	var extra any
	if err := decoder.Decode(&extra); err != io.EOF {
		return RawFrameQuery{}, errors.New("invalid JSON body")
	}
	if body.VehicleKey == "" {
		body.VehicleKey = body.VehicleKeyAlt
	}
	if body.DeviceID == "" {
		body.DeviceID = body.DeviceIDAlt
	}
	if body.MessageID == "" {
		body.MessageID = body.MessageIDAlt
	}
	if body.OrderBy == "" {
		body.OrderBy = body.OrderByAlt
	}
	if body.DateFrom == "" {
		body.DateFrom = body.DateFromAlt
	}
	if body.DateTo == "" {
		body.DateTo = body.DateToAlt
	}
	limit, err := boundedInt(body.Limit, 20, 1, 500, "limit")
	if err != nil {
		return RawFrameQuery{}, err
	}
	offset, err := boundedInt(body.Offset, 0, 0, 1_000_000, "offset")
	if err != nil {
		return RawFrameQuery{}, err
	}
	query := RawFrameQuery{
		Protocol:       body.Protocol,
		VehicleKey:     body.VehicleKey,
		VIN:            body.VIN,
		Phone:          body.Phone,
		DeviceID:       body.DeviceID,
		MessageID:      body.MessageID,
		OrderBy:        body.OrderBy,
		IncludeFields:  body.IncludeFields,
		IncludePayload: body.IncludePayload,
		IncludeTotal:   body.IncludeTotal,
		ParsedFields:   append(body.Fields, body.ParsedFields...),
		DateFrom:       body.DateFrom,
		DateTo:         body.DateTo,
		Limit:          limit,
		Offset:         offset,
	}
	if !validDateTime(query.DateFrom) || !validDateTime(query.DateTo) {
		return RawFrameQuery{}, errors.New("dateFrom/dateTo must use YYYY-MM-DD or YYYY-MM-DD HH:mm:ss")
	}
	if strings.TrimSpace(query.MessageID) != "" {
		if _, ok := parseMessageID(query.MessageID); !ok {
			return RawFrameQuery{}, errors.New("messageId must be decimal or hex like 0x0200")
		}
	}
	return normalizeRawFrameQuery(query), nil
}

func parseLocationQuery(r *http.Request) (LocationQuery, error) {
	values := r.URL.Query()
	limit, err := parseBoundedInt(values.Get("limit"), 20, 1, 500, "limit")
	if err != nil {
		return LocationQuery{}, err
	}
	offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset")
	if err != nil {
		return LocationQuery{}, err
	}
	query := LocationQuery{
		Protocol:     values.Get("protocol"),
		VIN:          values.Get("vin"),
		IncludeTotal: strings.EqualFold(strings.TrimSpace(values.Get("includeTotal")), "true"),
		DateFrom:     values.Get("dateFrom"),
		DateTo:       values.Get("dateTo"),
		Limit:        limit,
		Offset:       offset,
	}
	if !validDateTime(query.DateFrom) || !validDateTime(query.DateTo) {
		return LocationQuery{}, errors.New("dateFrom/dateTo must use YYYY-MM-DD or YYYY-MM-DD HH:mm:ss")
	}
	return normalizeLocationQuery(query), nil
}

func boundedInt(value int, fallback int, min int, max int, name string) (int, error) {
	if value == 0 {
		return fallback, nil
	}
	if value < min || value > max {
		return 0, fmt.Errorf("%s must be between %d and %d", name, min, max)
	}
	return value, nil
}

func parseBoundedInt(raw string, fallback int, min int, max int, name string) (int, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return fallback, nil
	}
	value, err := strconv.Atoi(raw)
	if err != nil || value < min || value > max {
		return 0, fmt.Errorf("%s must be between %d and %d", name, min, max)
	}
	return value, nil
}

func parseMessageID(value string) (int64, bool) {
	value = strings.TrimSpace(strings.ToLower(value))
	if value == "" {
		return 0, false
	}
	if strings.HasPrefix(value, "0x") {
		parsed, err := strconv.ParseInt(strings.TrimPrefix(value, "0x"), 16, 32)
		return parsed, err == nil
	}
	parsed, err := strconv.ParseInt(value, 10, 32)
	return parsed, err == nil
}

func validDateTime(value string) bool {
	value = normalizeDateTimeLiteral(value)
	if value == "" {
		return true
	}
	for _, layout := range []string{"2006-01-02", "2006-01-02 15:04:05", time.RFC3339} {
		if _, err := time.Parse(layout, value); err == nil {
			return true
		}
	}
	return false
}

func normalizeDateTimeLiteral(value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return ""
	}
	shanghai := time.FixedZone("Asia/Shanghai", 8*3600)
	for _, layout := range []string{"2006-01-02T15:04:05", "2006-01-02 15:04:05"} {
		if parsed, err := time.ParseInLocation(layout, value, shanghai); err == nil {
			return parsed.In(shanghai).Format("2006-01-02 15:04:05")
		}
	}
	if parsed, err := time.Parse(time.RFC3339, value); err == nil {
		return parsed.In(shanghai).Format("2006-01-02 15:04:05")
	}
	return value
}

func safeIdentifier(value string) bool {
	if value == "" {
		return false
	}
	for _, r := range value {
		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
			continue
		}
		return false
	}
	return true
}

type scanDateTime struct {
	String string
}

func (s *scanDateTime) Scan(value any) error {
	s.String = formatSQLTime(value, "2006-01-02 15:04:05")
	return nil
}

func formatSQLTime(value any, layout string) string {
	switch typed := value.(type) {
	case nil:
		return ""
	case time.Time:
		return typed.Format(layout)
	case []byte:
		return strings.TrimSpace(string(typed))
	case string:
		return strings.TrimSpace(typed)
	default:
		return strings.TrimSpace(fmt.Sprint(typed))
	}
}

func nullableFloat(value sql.NullFloat64) *float64 {
	if !value.Valid {
		return nil
	}
	return &value.Float64
}

func nullableInt(value sql.NullInt64) *int64 {
	if !value.Valid {
		return nil
	}
	return &value.Int64
}

func writeHistoryError(w http.ResponseWriter, status int, message string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(map[string]any{"error": message})
}
