package identity

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)

type Resolver interface {
	Resolve(context.Context, envelope.FrameEnvelope) (envelope.FrameEnvelope, error)
}

type NoopResolver struct{}

func (NoopResolver) Resolve(_ context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
	return env, nil
}

type TimeoutResolver struct {
	Delegate Resolver
	Timeout  time.Duration
}

func (r TimeoutResolver) Resolve(ctx context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
	if r.Delegate == nil {
		return env, nil
	}
	if r.Timeout <= 0 {
		return r.Delegate.Resolve(ctx, env)
	}
	resolveCtx, cancel := context.WithTimeout(ctx, r.Timeout)
	defer cancel()
	return r.Delegate.Resolve(resolveCtx, env)
}

type MySQLResolver struct {
	db                          *sql.DB
	table                       string
	snapshotOnlyLookups         bool
	snapshotRefreshMu           sync.Mutex
	snapshot                    atomic.Pointer[identitySnapshot]
	locationTouchInterval       time.Duration
	locationTouchRetryInterval  time.Duration
	registrationWriteAttempts   int
	registrationWriteRetryDelay time.Duration
	lookupCacheTTL              time.Duration
	staleLookupTTL              time.Duration
	cacheCleanupInterval        time.Duration
	maxCacheEntries             int
	sourceCodeLookup            bool
	lookupMu                    sync.Mutex
	lookupCache                 map[string]lookupCacheEntry
	registrationCache           map[string]registrationCacheEntry
	sourceCodeCache             map[string]sourceCodeCacheEntry
	nextLookupCleanup           time.Time
	touchMu                     sync.Mutex
	locationTouches             map[string]time.Time
	locationTouchFailures       map[string]time.Time
	nextTouchCleanup            time.Time
	registrationWriteQueue      chan jt808RegistrationWriteJob
	registrationWriteClosed     chan struct{}
	registrationWriteDone       chan struct{}
	registrationWriteOnce       sync.Once
	registrationWriteWG         sync.WaitGroup
	registrationWriteTimeout    time.Duration
	registrationEnqueueTimeout  time.Duration
	disableRegistrationWrites   bool
	onRegistrationWriteError    func(error)
	onRegistrationWriteResult   func(RegistrationWriteResult)
}

type MySQLResolverOptions struct {
	SnapshotOnlyLookups         bool
	LocationTouchInterval       time.Duration
	LocationTouchRetryInterval  time.Duration
	RegistrationWriteAttempts   int
	RegistrationWriteRetryDelay time.Duration
	LookupCacheTTL              time.Duration
	StaleLookupTTL              time.Duration
	CacheCleanupInterval        time.Duration
	MaxCacheEntries             int
	SourceCodeLookup            bool
	AsyncRegistrationWrites     bool
	RegistrationWriteQueueSize  int
	RegistrationWriteWorkers    int
	RegistrationWriteTimeout    time.Duration
	RegistrationEnqueueTimeout  time.Duration
	DisableRegistrationWrites   bool
	OnRegistrationWriteError    func(error)
	OnRegistrationWriteResult   func(RegistrationWriteResult)
}

type RegistrationWriteResult struct {
	Mode   string
	Status string
	Err    error
}

type CacheStats struct {
	LookupEntries               int
	RegistrationEntries         int
	SourceCodeEntries           int
	LocationTouchEntries        int
	LocationTouchFailureEntries int
	RegistrationWriteQueueDepth int
	RegistrationWriteQueueCap   int
	SnapshotBindingEntries      int
	SnapshotIdentifierEntries   int
	SnapshotRegistrationEntries int
	SnapshotSourceEntries       int
	SnapshotReady               bool
	SnapshotRefreshedAt         time.Time
	MaxEntries                  int
}

type jt808RegistrationWriteJob struct {
	query              string
	args               []any
	locationTouchPhone string
	locationTouchAt    time.Time
}

var ErrRegistrationWriteQueueClosed = errors.New("jt808 registration write queue is closed")
var ErrRegistrationWriteQueueTimeout = errors.New("jt808 registration write queue enqueue timeout")

type lookupCacheEntry struct {
	vin          string
	sourceCode   string
	platformName string
	sourceKind   string
	notFound     bool
	expiresAt    time.Time
	staleAt      time.Time
}

type registrationCacheEntry struct {
	vin       string
	deviceID  string
	plate     string
	authToken string
	notFound  bool
	expiresAt time.Time
	staleAt   time.Time
}

type sourceCodeCacheEntry struct {
	sourceCode   string
	platformName string
	sourceKind   string
	notFound     bool
	expiresAt    time.Time
	staleAt      time.Time
}

type sourceMetadata struct {
	SourceCode   string
	PlatformName string
	SourceKind   string
}

type vehicleIdentifierMatch struct {
	VIN          string
	SourceCode   string
	PlatformName string
}

var errAmbiguousIdentifier = errors.New("ambiguous vehicle identifier")

func NewMySQLResolver(db *sql.DB, table string) *MySQLResolver {
	return NewMySQLResolverWithOptions(db, table, MySQLResolverOptions{})
}

func NewMySQLResolverWithOptions(db *sql.DB, table string, opts MySQLResolverOptions) *MySQLResolver {
	if db == nil {
		panic("identity db must not be nil")
	}
	table = strings.TrimSpace(table)
	if table == "" || !safeIdentifier(table) {
		table = "vehicle_identity_binding"
	}
	interval := opts.LocationTouchInterval
	if interval <= 0 {
		interval = 10 * time.Minute
	}
	touchRetryInterval := opts.LocationTouchRetryInterval
	if touchRetryInterval == 0 {
		touchRetryInterval = 5 * time.Second
	}
	if touchRetryInterval < 0 {
		touchRetryInterval = 0
	}
	if touchRetryInterval > interval {
		touchRetryInterval = interval
	}
	writeAttempts := opts.RegistrationWriteAttempts
	if writeAttempts <= 0 {
		writeAttempts = 2
	}
	writeRetryDelay := opts.RegistrationWriteRetryDelay
	if writeRetryDelay == 0 {
		writeRetryDelay = 20 * time.Millisecond
	}
	if writeRetryDelay < 0 {
		writeRetryDelay = 0
	}
	lookupTTL := opts.LookupCacheTTL
	if lookupTTL <= 0 {
		lookupTTL = 10 * time.Minute
	}
	staleLookupTTL := opts.StaleLookupTTL
	if staleLookupTTL == 0 {
		staleLookupTTL = time.Hour
	}
	if staleLookupTTL < 0 {
		staleLookupTTL = 0
	}
	cleanupInterval := opts.CacheCleanupInterval
	if cleanupInterval <= 0 {
		cleanupInterval = time.Minute
	}
	maxEntries := opts.MaxCacheEntries
	if maxEntries <= 0 {
		maxEntries = 300000
	}
	resolver := &MySQLResolver{
		db:                          db,
		table:                       table,
		snapshotOnlyLookups:         opts.SnapshotOnlyLookups,
		locationTouchInterval:       interval,
		locationTouchRetryInterval:  touchRetryInterval,
		registrationWriteAttempts:   writeAttempts,
		registrationWriteRetryDelay: writeRetryDelay,
		lookupCacheTTL:              lookupTTL,
		staleLookupTTL:              staleLookupTTL,
		cacheCleanupInterval:        cleanupInterval,
		maxCacheEntries:             maxEntries,
		sourceCodeLookup:            opts.SourceCodeLookup,
		lookupCache:                 map[string]lookupCacheEntry{},
		registrationCache:           map[string]registrationCacheEntry{},
		sourceCodeCache:             map[string]sourceCodeCacheEntry{},
		locationTouches:             map[string]time.Time{},
		locationTouchFailures:       map[string]time.Time{},
		onRegistrationWriteError:    opts.OnRegistrationWriteError,
		onRegistrationWriteResult:   opts.OnRegistrationWriteResult,
		disableRegistrationWrites:   opts.DisableRegistrationWrites,
	}
	if opts.AsyncRegistrationWrites && !opts.DisableRegistrationWrites {
		queueSize := opts.RegistrationWriteQueueSize
		if queueSize <= 0 {
			queueSize = 100000
		}
		workers := opts.RegistrationWriteWorkers
		if workers <= 0 {
			workers = 4
		}
		timeout := opts.RegistrationWriteTimeout
		if timeout <= 0 {
			timeout = 5 * time.Second
		}
		enqueueTimeout := opts.RegistrationEnqueueTimeout
		if enqueueTimeout == 0 {
			enqueueTimeout = 50 * time.Millisecond
		}
		if enqueueTimeout < 0 {
			enqueueTimeout = 0
		}
		resolver.registrationWriteQueue = make(chan jt808RegistrationWriteJob, queueSize)
		resolver.registrationWriteClosed = make(chan struct{})
		resolver.registrationWriteDone = make(chan struct{})
		resolver.registrationWriteTimeout = timeout
		resolver.registrationEnqueueTimeout = enqueueTimeout
		resolver.registrationWriteWG.Add(workers)
		for i := 0; i < workers; i++ {
			go resolver.registrationWriteWorker()
		}
		go func() {
			resolver.registrationWriteWG.Wait()
			close(resolver.registrationWriteDone)
		}()
	}
	return resolver
}

func (r *MySQLResolver) CacheStats() CacheStats {
	if r == nil {
		return CacheStats{}
	}
	r.lookupMu.Lock()
	stats := CacheStats{
		LookupEntries:       len(r.lookupCache),
		RegistrationEntries: len(r.registrationCache),
		SourceCodeEntries:   len(r.sourceCodeCache),
		MaxEntries:          r.maxCacheEntries,
	}
	if snapshot := r.snapshot.Load(); snapshot != nil {
		stats.SnapshotBindingEntries = len(snapshot.bindings)
		stats.SnapshotIdentifierEntries = len(snapshot.identifiers)
		stats.SnapshotRegistrationEntries = len(snapshot.registrations)
		stats.SnapshotSourceEntries = len(snapshot.sources)
		stats.SnapshotReady = true
		stats.SnapshotRefreshedAt = snapshot.refreshedAt
	}
	r.lookupMu.Unlock()
	r.touchMu.Lock()
	stats.LocationTouchEntries = len(r.locationTouches)
	stats.LocationTouchFailureEntries = len(r.locationTouchFailures)
	r.touchMu.Unlock()
	if r.registrationWriteQueue != nil {
		stats.RegistrationWriteQueueDepth = len(r.registrationWriteQueue)
		stats.RegistrationWriteQueueCap = cap(r.registrationWriteQueue)
	}
	return stats
}

func (r *MySQLResolver) Close() error {
	if r == nil || r.registrationWriteClosed == nil {
		return nil
	}
	r.registrationWriteOnce.Do(func() {
		close(r.registrationWriteClosed)
	})
	<-r.registrationWriteDone
	return nil
}

func (r *MySQLResolver) EnsureSchema(ctx context.Context) error {
	if _, err := r.db.ExecContext(ctx, identityBindingTableSQL(r.table)); err != nil {
		return err
	}
	for _, statement := range identityBindingAlterSQL(r.table) {
		if _, err := r.db.ExecContext(ctx, statement); err != nil {
			if !isIgnoredIdentityBindingAlterError(err) {
				return err
			}
		}
	}
	if _, err := r.db.ExecContext(ctx, vehicleTableSQL); err != nil {
		return err
	}
	if _, err := r.db.ExecContext(ctx, vehicleIdentifierTableSQL); err != nil {
		return err
	}
	return EnsureJT808RegistrationSchema(ctx, r.db)
}

func identityBindingTableSQL(table string) string {
	if table == "" || !safeIdentifier(table) {
		table = "vehicle_identity_binding"
	}
	return `CREATE TABLE IF NOT EXISTS ` + table + ` (
  vin VARCHAR(32) PRIMARY KEY,
  plate VARCHAR(32) NULL,
  phone VARCHAR(32) NULL,
  oem VARCHAR(64) NULL,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uk_identity_plate (plate),
  UNIQUE KEY uk_identity_phone (phone)
)`
}

func identityBindingAlterSQL(table string) []string {
	if table == "" || !safeIdentifier(table) {
		table = "vehicle_identity_binding"
	}
	return []string{
		`ALTER TABLE ` + table + ` ADD COLUMN oem VARCHAR(64) NULL AFTER phone`,
		`ALTER TABLE ` + table + ` DROP INDEX uk_identity_device`,
		`ALTER TABLE ` + table + ` DROP COLUMN device_id`,
	}
}

func isIgnoredIdentityBindingAlterError(err error) bool {
	text := strings.ToLower(strings.TrimSpace(fmt.Sprint(err)))
	return strings.Contains(text, "duplicate column") ||
		strings.Contains(text, "error 1060") ||
		strings.Contains(text, "can't drop") ||
		strings.Contains(text, "check that column/key exists") ||
		strings.Contains(text, "error 1091")
}

const vehicleTableSQL = `CREATE TABLE IF NOT EXISTS vehicle (
  vin VARCHAR(32) PRIMARY KEY,
  plate VARCHAR(32) NULL,
  oem VARCHAR(64) NULL,
  enabled TINYINT(1) NOT NULL DEFAULT 1,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  KEY idx_vehicle_plate (plate),
  KEY idx_vehicle_enabled (enabled)
)`

const vehicleIdentifierTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_identifier (
  protocol VARCHAR(32) NOT NULL,
  source_code VARCHAR(64) NOT NULL DEFAULT '',
  identifier_type VARCHAR(32) NOT NULL,
  identifier_value VARCHAR(128) NOT NULL,
  vin VARCHAR(32) NOT NULL,
  plate VARCHAR(32) NULL,
  oem VARCHAR(64) NULL,
  raw_value VARCHAR(128) NULL,
  enabled TINYINT(1) NOT NULL DEFAULT 1,
  latest_import_file VARCHAR(255) NULL,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (protocol, source_code, identifier_type, identifier_value),
  KEY idx_identifier_lookup (protocol, identifier_type, identifier_value, enabled),
  KEY idx_identifier_vin (vin),
  KEY idx_identifier_plate (plate)
)`

const jt808RegistrationTableSQL = `CREATE TABLE IF NOT EXISTS jt808_registration (
  phone VARCHAR(32) PRIMARY KEY,
  device_id VARCHAR(64) NULL,
  plate VARCHAR(32) NULL,
  vin VARCHAR(32) NULL DEFAULT 'unknown',
  province VARCHAR(16) NULL,
  city VARCHAR(16) NULL,
  manufacturer VARCHAR(64) NULL,
  device_type VARCHAR(64) NULL,
  plate_color VARCHAR(16) NULL,
  auth_token VARCHAR(128) NULL,
  auth_imei VARCHAR(64) NULL,
  auth_software_version VARCHAR(64) NULL,
  source_endpoint VARCHAR(64) NULL,
  source_ip VARCHAR(64) NULL,
  first_registered_at DATETIME NULL,
  latest_registered_at DATETIME NULL,
  latest_authenticated_at DATETIME NULL,
  latest_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  KEY idx_jt808_registration_device (device_id),
  KEY idx_jt808_registration_plate (plate),
  KEY idx_jt808_registration_vin (vin),
  KEY idx_jt808_registration_source_ip (source_ip),
  KEY idx_jt808_registration_seen (latest_seen_at)
)`

var jt808RegistrationAlterSQL = []string{
	"ALTER TABLE jt808_registration ADD COLUMN source_ip VARCHAR(64) NULL AFTER source_endpoint",
	"ALTER TABLE jt808_registration ADD KEY idx_jt808_registration_source_ip (source_ip)",
}

const jt808RegistrationSourceIPBackfillSQL = `UPDATE jt808_registration
SET source_ip = SUBSTRING_INDEX(TRIM(source_endpoint), ':', 1)
WHERE (source_ip IS NULL OR TRIM(source_ip) = '')
  AND source_endpoint IS NOT NULL
  AND TRIM(source_endpoint) <> ''`

func isIgnoredJT808RegistrationAlterError(err error) bool {
	text := strings.ToLower(strings.TrimSpace(fmt.Sprint(err)))
	return strings.Contains(text, "duplicate column") ||
		strings.Contains(text, "duplicate key name") ||
		strings.Contains(text, "error 1060") ||
		strings.Contains(text, "error 1061")
}

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
}

func (r *MySQLResolver) Resolve(ctx context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
	env.Phone = normalizePhone(env.Phone)
	if strings.TrimSpace(env.VIN) != "" {
		if err := r.trackJT808(ctx, env); err != nil {
			return env, err
		}
		return env, nil
	}
	source, err := r.sourceMetadataForEnvelope(ctx, env)
	if err != nil {
		return env, err
	}
	applyEnvelopeSourceMetadata(&env, source)
	candidates := CandidateKeys(env)
	for _, candidate := range candidates {
		if env.Protocol == envelope.ProtocolJT808 {
			vin, identitySource, cacheStatus, identitySourceMetadata, err := r.lookupVehicleIdentifier(ctx, env.Protocol, source.SourceCode, candidate)
			if err != nil {
				if !errors.Is(err, sql.ErrNoRows) && !errors.Is(err, errAmbiguousIdentifier) {
					return env, err
				}
			} else if strings.TrimSpace(vin) != "" {
				env.VIN = strings.TrimSpace(vin)
				overrideEnvelopeSourceMetadata(&env, identitySourceMetadata)
				addEnvelopeIdentityMetadataWithCacheStatus(&env, identitySource, candidate.Value, cacheStatus)
				env.EventID = env.StableEventID()
				if err := r.trackJT808(ctx, env); err != nil {
					return env, err
				}
				return env, nil
			}
		}
		vin, cacheStatus, err := r.lookup(ctx, candidate.Column, candidate.Value)
		if err != nil {
			if errors.Is(err, sql.ErrNoRows) {
				continue
			}
			return env, err
		}
		if strings.TrimSpace(vin) != "" {
			env.VIN = strings.TrimSpace(vin)
			addEnvelopeIdentityMetadataWithCacheStatus(&env, candidate.Column, candidate.Value, cacheStatus)
			env.EventID = env.StableEventID()
			if err := r.trackJT808(ctx, env); err != nil {
				return env, err
			}
			return env, nil
		}
	}
	resolved, ok, err := r.resolveFromJT808Registration(ctx, env, source.SourceCode)
	if err != nil {
		return env, err
	}
	if ok {
		env = resolved
		if err := r.trackJT808(ctx, env); err != nil {
			return env, err
		}
		return env, nil
	}
	if err := r.trackJT808(ctx, env); err != nil {
		return env, err
	}
	return env, nil
}

func (r *MySQLResolver) resolveFromJT808Registration(ctx context.Context, env envelope.FrameEnvelope, sourceCode string) (envelope.FrameEnvelope, bool, error) {
	if env.Protocol != envelope.ProtocolJT808 || env.MessageID != "0x0200" || strings.TrimSpace(env.Phone) == "" {
		return env, false, nil
	}
	vin, deviceID, plate, cacheStatus, err := r.lookupJT808Registration(ctx, env.Phone)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return env, false, nil
		}
		return env, false, err
	}
	env.DeviceID = firstNonEmpty(env.DeviceID, deviceID)
	env.Plate = firstNonEmpty(env.Plate, plate)
	if strings.TrimSpace(vin) != "" && !strings.EqualFold(strings.TrimSpace(vin), "unknown") {
		env.VIN = strings.TrimSpace(vin)
		env.EventID = env.StableEventID()
		r.enrichResolvedJT808SourceMetadata(ctx, &env, env.VIN)
		addEnvelopeIdentityMetadataWithCacheStatus(&env, "jt808_registration.vin", env.Phone, cacheStatus)
		return env, true, nil
	}
	value := strings.TrimSpace(env.Plate)
	if value == "" {
		return env, false, nil
	}
	vin, source, cacheStatus, metadata, err := r.lookupVehicleIdentifier(ctx, env.Protocol, sourceCode, CandidateKey{Column: "plate", Value: value})
	if err == nil && strings.TrimSpace(vin) != "" {
		env.VIN = strings.TrimSpace(vin)
		overrideEnvelopeSourceMetadata(&env, metadata)
		env.EventID = env.StableEventID()
		addEnvelopeIdentityMetadataWithCacheStatus(&env, source, value, cacheStatus)
		return env, true, nil
	}
	if err != nil && !errors.Is(err, sql.ErrNoRows) && !errors.Is(err, errAmbiguousIdentifier) {
		return env, false, err
	}
	vin, cacheStatus, err = r.lookup(ctx, "plate", value)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return env, false, nil
		}
		return env, false, err
	}
	if strings.TrimSpace(vin) == "" {
		return env, false, nil
	}
	env.VIN = strings.TrimSpace(vin)
	env.EventID = env.StableEventID()
	addEnvelopeIdentityMetadataWithCacheStatus(&env, "jt808_registration.plate", value, cacheStatus)
	return env, true, nil
}

func (r *MySQLResolver) enrichResolvedJT808SourceMetadata(ctx context.Context, env *envelope.FrameEnvelope, vin string) {
	if r == nil || env == nil || env.Protocol != envelope.ProtocolJT808 || strings.TrimSpace(vin) == "" {
		return
	}
	for _, candidate := range CandidateKeys(*env) {
		if candidateIdentifierType(candidate.Column) == "" {
			continue
		}
		matchedVIN, _, _, metadata, err := r.lookupVehicleIdentifier(ctx, env.Protocol, "", candidate)
		if err != nil || strings.TrimSpace(matchedVIN) == "" {
			continue
		}
		if !strings.EqualFold(strings.TrimSpace(matchedVIN), strings.TrimSpace(vin)) {
			continue
		}
		overrideEnvelopeSourceMetadata(env, metadata)
		return
	}
}

func (r *MySQLResolver) lookupJT808Registration(ctx context.Context, phone string) (vin string, deviceID string, plate string, cacheStatus string, err error) {
	phone = normalizePhone(phone)
	now := time.Now()
	var stale registrationCacheEntry
	hasStale := false
	r.lookupMu.Lock()
	cached, ok := r.registrationCache[phone]
	if ok && now.Before(cached.expiresAt) {
		r.lookupMu.Unlock()
		if cached.notFound {
			return "", "", "", "", sql.ErrNoRows
		}
		return cached.vin, cached.deviceID, cached.plate, "", nil
	}
	if ok && cached.canServeStale(now) {
		stale = cached
		hasStale = true
	}
	r.lookupMu.Unlock()
	if snapshot, ok := r.snapshotRegistration(phone); ok {
		return snapshot.vin, snapshot.deviceID, snapshot.plate, "snapshot", nil
	}
	if r.snapshotOnlyLookups {
		return "", "", "", "", sql.ErrNoRows
	}

	var vinValue, deviceValue, plateValue sql.NullString
	err = r.db.QueryRowContext(ctx, "SELECT vin, device_id, plate FROM jt808_registration WHERE phone = ?", phone).
		Scan(&vinValue, &deviceValue, &plateValue)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		if hasStale {
			return stale.vin, stale.deviceID, stale.plate, "stale", nil
		}
		return "", "", "", "", err
	}
	entry := registrationCacheEntry{
		vin:       strings.TrimSpace(vinValue.String),
		deviceID:  strings.TrimSpace(deviceValue.String),
		plate:     strings.TrimSpace(plateValue.String),
		notFound:  errors.Is(err, sql.ErrNoRows),
		expiresAt: now.Add(r.lookupCacheTTL),
	}
	entry.staleAt = r.staleUntil(entry.expiresAt, entry.notFound)
	r.cacheRegistration(phone, entry, now)
	if entry.notFound {
		return "", "", "", "", sql.ErrNoRows
	}
	return entry.vin, entry.deviceID, entry.plate, "", nil
}

func addIdentityMetadata(parsed map[string]any, source string, value string) {
	if parsed == nil {
		return
	}
	identity, _ := parsed["identity"].(map[string]any)
	if identity == nil {
		identity = map[string]any{}
	}
	identity["resolved"] = true
	identity["source"] = source
	identity["value"] = value
	parsed["identity"] = identity
}

func addEnvelopeIdentityMetadata(env *envelope.FrameEnvelope, source string, value string) {
	addEnvelopeIdentityMetadataWithCacheStatus(env, source, value, "")
}

func addEnvelopeIdentityMetadataWithCacheStatus(env *envelope.FrameEnvelope, source string, value string, cacheStatus string) {
	if env == nil {
		return
	}
	if env.Parsed == nil {
		env.Parsed = map[string]any{}
	}
	addIdentityMetadata(env.Parsed, source, value)
	if cacheStatus != "" {
		if identity, ok := env.Parsed["identity"].(map[string]any); ok {
			identity["cache_status"] = cacheStatus
		}
	}
}

func (r *MySQLResolver) sourceMetadataForEnvelope(ctx context.Context, env envelope.FrameEnvelope) (sourceMetadata, error) {
	if !r.sourceCodeLookup || env.Protocol != envelope.ProtocolJT808 {
		return sourceMetadata{}, nil
	}
	sourceIP := normalizeEndpointIP(env.SourceEndpoint)
	if sourceIP == "" {
		return sourceMetadata{}, nil
	}
	protocolValue := string(env.Protocol)
	cacheKey := protocolValue + "\x00" + sourceIP
	if snapshot, ok := r.snapshotSource(protocolValue, sourceIP); ok {
		return snapshot, nil
	}
	if r.snapshotOnlyLookups {
		return sourceMetadata{}, nil
	}
	now := time.Now()
	var stale sourceCodeCacheEntry
	hasStale := false
	r.lookupMu.Lock()
	cached, ok := r.sourceCodeCache[cacheKey]
	if ok && now.Before(cached.expiresAt) {
		r.lookupMu.Unlock()
		if cached.notFound {
			return sourceMetadata{}, nil
		}
		return cached.sourceMetadata(), nil
	}
	if ok && cached.canServeStale(now) {
		stale = cached
		hasStale = true
	}
	r.lookupMu.Unlock()

	var sourceCode, platformName, sourceKind sql.NullString
	err := r.db.QueryRowContext(ctx, `SELECT source_code, platform_name, source_kind
FROM vehicle_data_source
WHERE protocol = ? AND source_ip = ? AND enabled = 1
  AND (
    (source_code IS NOT NULL AND TRIM(source_code) <> '')
    OR source_kind IN ('PLATFORM', 'DIRECT')
    OR (platform_name IS NOT NULL AND TRIM(platform_name) <> '')
  )
ORDER BY updated_at DESC
LIMIT 1`, protocolValue, sourceIP).Scan(&sourceCode, &platformName, &sourceKind)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) || isOptionalSourceCodeLookupError(err) {
			r.cacheSourceMetadata(cacheKey, sourceMetadata{}, true, now)
			return sourceMetadata{}, nil
		}
		if hasStale {
			return stale.sourceMetadata(), nil
		}
		return sourceMetadata{}, err
	}
	value := sourceMetadata{
		SourceCode:   strings.TrimSpace(sourceCode.String),
		PlatformName: strings.TrimSpace(platformName.String),
		SourceKind:   strings.TrimSpace(sourceKind.String),
	}
	r.cacheSourceMetadata(cacheKey, value, value.SourceCode == "" && value.PlatformName == "" && value.SourceKind == "", now)
	return value, nil
}

func (r *MySQLResolver) cacheSourceMetadata(cacheKey string, metadata sourceMetadata, notFound bool, now time.Time) {
	r.lookupMu.Lock()
	r.sourceCodeCache[cacheKey] = sourceCodeCacheEntry{
		sourceCode:   strings.TrimSpace(metadata.SourceCode),
		platformName: strings.TrimSpace(metadata.PlatformName),
		sourceKind:   strings.TrimSpace(metadata.SourceKind),
		notFound:     notFound,
		expiresAt:    now.Add(r.lookupCacheTTL),
	}
	entry := r.sourceCodeCache[cacheKey]
	entry.staleAt = r.staleUntil(entry.expiresAt, entry.notFound)
	r.sourceCodeCache[cacheKey] = entry
	r.cleanupLookupCachesLocked(now, false)
	r.lookupMu.Unlock()
}

func (entry sourceCodeCacheEntry) sourceMetadata() sourceMetadata {
	return sourceMetadata{
		SourceCode:   strings.TrimSpace(entry.sourceCode),
		PlatformName: strings.TrimSpace(entry.platformName),
		SourceKind:   strings.TrimSpace(entry.sourceKind),
	}
}

func applyEnvelopeSourceMetadata(env *envelope.FrameEnvelope, metadata sourceMetadata) {
	applyEnvelopeSourceMetadataMode(env, metadata, false)
}

func overrideEnvelopeSourceMetadata(env *envelope.FrameEnvelope, metadata sourceMetadata) {
	applyEnvelopeSourceMetadataMode(env, metadata, true)
}

func applyEnvelopeSourceMetadataMode(env *envelope.FrameEnvelope, metadata sourceMetadata, override bool) {
	if env == nil {
		return
	}
	if value := strings.TrimSpace(metadata.SourceCode); value != "" && (override || env.SourceCode == "") {
		env.SourceCode = value
	}
	if value := strings.TrimSpace(metadata.PlatformName); value != "" && (override || env.PlatformName == "") {
		env.PlatformName = value
	}
	if value := strings.TrimSpace(metadata.SourceKind); value != "" && (override || env.SourceKind == "") {
		env.SourceKind = value
	}
	if env.Parsed == nil || (env.SourceCode == "" && env.PlatformName == "" && env.SourceKind == "") {
		return
	}
	identity, _ := env.Parsed["identity"].(map[string]any)
	if identity == nil {
		identity = map[string]any{}
	}
	if env.SourceCode != "" {
		identity["source_code"] = env.SourceCode
	}
	if env.PlatformName != "" {
		identity["platform_name"] = env.PlatformName
	}
	if env.SourceKind != "" {
		identity["source_kind"] = env.SourceKind
	}
	env.Parsed["identity"] = identity
}

func normalizeEndpointIP(endpoint string) string {
	return envelope.NormalizeSourceEndpointKey(endpoint)
}

func isOptionalSourceCodeLookupError(err error) bool {
	if err == nil {
		return false
	}
	text := strings.ToLower(strings.TrimSpace(fmt.Sprint(err)))
	return strings.Contains(text, "error 1146") ||
		strings.Contains(text, "doesn't exist") ||
		strings.Contains(text, "unknown table")
}

func (r *MySQLResolver) lookupVehicleIdentifier(ctx context.Context, protocol envelope.Protocol, sourceCode string, candidate CandidateKey) (string, string, string, sourceMetadata, error) {
	identifierType := candidateIdentifierType(candidate.Column)
	if identifierType == "" {
		return "", "", "", sourceMetadata{}, sql.ErrNoRows
	}
	value := normalizeIdentifierValue(identifierType, candidate.Value)
	if value == "" {
		return "", "", "", sourceMetadata{}, sql.ErrNoRows
	}
	protocolValue := string(protocol)
	sourceCode = strings.TrimSpace(sourceCode)
	if sourceCode != "" {
		vin, source, cacheStatus, metadata, err := r.lookupVehicleIdentifierInScope(ctx, protocolValue, sourceCode, identifierType, value)
		if err == nil || !errors.Is(err, sql.ErrNoRows) {
			return vin, source, cacheStatus, metadata, err
		}
	}
	return r.lookupVehicleIdentifierInScope(ctx, protocolValue, "", identifierType, value)
}

func (r *MySQLResolver) lookupVehicleIdentifierInScope(ctx context.Context, protocolValue string, sourceCode string, identifierType string, value string) (string, string, string, sourceMetadata, error) {
	scope := strings.TrimSpace(sourceCode)
	cacheKey := "vehicle_identifier\x00" + protocolValue + "\x00" + scope + "\x00" + identifierType + "\x00" + value
	if snapshot, ok := r.snapshotIdentifier(protocolValue, scope, identifierType, value); ok {
		metadata := snapshot.sourceMetadata()
		return snapshot.VIN, vehicleIdentifierSource(identifierType, firstNonEmpty(scope, metadata.SourceCode)), "snapshot", metadata, nil
	}
	if r.snapshotOnlyLookups {
		return "", "", "", sourceMetadata{}, sql.ErrNoRows
	}
	now := time.Now()
	var stale lookupCacheEntry
	hasStale := false
	r.lookupMu.Lock()
	cached, ok := r.lookupCache[cacheKey]
	if ok && now.Before(cached.expiresAt) {
		r.lookupMu.Unlock()
		if cached.notFound {
			return "", "", "", sourceMetadata{}, sql.ErrNoRows
		}
		return cached.vin, vehicleIdentifierSource(identifierType, firstNonEmpty(scope, cached.sourceCode)), "", cached.sourceMetadata(), nil
	}
	if ok && cached.canServeStale(now) {
		stale = cached
		hasStale = true
	}
	r.lookupMu.Unlock()

	query := `SELECT DISTINCT vin, source_code, COALESCE(NULLIF(TRIM(oem), ''), source_code) AS platform_name
FROM vehicle_identifier
WHERE protocol = ? AND identifier_type = ? AND identifier_value = ? AND enabled = 1 AND vin IS NOT NULL AND vin <> ''`
	args := []any{protocolValue, identifierType, value}
	if scope != "" {
		query += ` AND source_code = ?`
		args = append(args, scope)
	}
	query += `
ORDER BY vin, source_code
LIMIT 2`
	match, err := querySingleVehicleIdentifier(ctx, r.db, query, args...)
	if err != nil {
		if hasStale && isTransientLookupError(err) {
			return stale.vin, vehicleIdentifierSource(identifierType, firstNonEmpty(scope, stale.sourceCode)), "stale", stale.sourceMetadata(), nil
		}
		if isTransientLookupError(err) {
			return "", "", "", sourceMetadata{}, err
		}
		r.cacheLookup(cacheKey, r.newLookupCacheEntry(vehicleIdentifierMatch{}, true, now), now)
		return "", "", "", sourceMetadata{}, err
	}
	r.cacheLookup(cacheKey, r.newLookupCacheEntry(match, false, now), now)
	metadata := match.sourceMetadata()
	return match.VIN, vehicleIdentifierSource(identifierType, firstNonEmpty(scope, metadata.SourceCode)), "", metadata, nil
}

func querySingleVehicleIdentifier(ctx context.Context, db *sql.DB, query string, args ...any) (vehicleIdentifierMatch, error) {
	rows, err := db.QueryContext(ctx, query, args...)
	if err != nil {
		return vehicleIdentifierMatch{}, err
	}
	defer rows.Close()
	vins := map[string]struct{}{}
	sourceCodes := map[string]struct{}{}
	platformNames := map[string]struct{}{}
	for rows.Next() {
		var vin, sourceCode, platformName sql.NullString
		if err := rows.Scan(&vin, &sourceCode, &platformName); err != nil {
			return vehicleIdentifierMatch{}, err
		}
		vinValue := strings.TrimSpace(vin.String)
		if vinValue == "" {
			continue
		}
		vins[vinValue] = struct{}{}
		sourceCodeValue := strings.TrimSpace(sourceCode.String)
		if sourceCodeValue != "" {
			sourceCodes[sourceCodeValue] = struct{}{}
		}
		platformNameValue := strings.TrimSpace(platformName.String)
		if platformNameValue != "" {
			platformNames[platformNameValue] = struct{}{}
		}
	}
	if err := rows.Err(); err != nil {
		return vehicleIdentifierMatch{}, err
	}
	if len(vins) == 1 {
		match := vehicleIdentifierMatch{}
		for vin := range vins {
			match.VIN = vin
		}
		if len(sourceCodes) == 1 {
			for sourceCode := range sourceCodes {
				match.SourceCode = sourceCode
			}
			if len(platformNames) == 1 {
				for platformName := range platformNames {
					match.PlatformName = platformName
				}
			}
		}
		return match, nil
	}
	if len(vins) > 1 {
		return vehicleIdentifierMatch{}, errAmbiguousIdentifier
	}
	return vehicleIdentifierMatch{}, sql.ErrNoRows
}

func vehicleIdentifierSource(identifierType string, sourceCode string) string {
	source := "vehicle_identifier." + identifierType
	if strings.TrimSpace(sourceCode) != "" {
		source += "." + strings.TrimSpace(sourceCode)
	}
	return source
}

func (r *MySQLResolver) lookup(ctx context.Context, column string, value string) (string, string, error) {
	cacheKey := column + "\x00" + value
	if vin, ok := r.snapshotBinding(column, value); ok {
		return vin, "snapshot", nil
	}
	if r.snapshotOnlyLookups {
		return "", "", sql.ErrNoRows
	}
	now := time.Now()
	var stale lookupCacheEntry
	hasStale := false
	r.lookupMu.Lock()
	cached, ok := r.lookupCache[cacheKey]
	if ok && now.Before(cached.expiresAt) {
		r.lookupMu.Unlock()
		if cached.notFound {
			return "", "", sql.ErrNoRows
		}
		return cached.vin, "", nil
	}
	if ok && cached.canServeStale(now) {
		stale = cached
		hasStale = true
	}
	r.lookupMu.Unlock()

	query := "SELECT vin FROM " + r.table + " WHERE " + column + " = ? AND vin IS NOT NULL AND vin <> ''"
	var vin string
	err := r.db.QueryRowContext(ctx, query, value).Scan(&vin)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		if hasStale {
			return stale.vin, "stale", nil
		}
		return "", "", err
	}
	r.cacheLookup(cacheKey, r.newLookupCacheEntry(vehicleIdentifierMatch{VIN: strings.TrimSpace(vin)}, errors.Is(err, sql.ErrNoRows), now), now)
	return vin, "", err
}

func (r *MySQLResolver) cacheLookup(cacheKey string, entry lookupCacheEntry, now time.Time) {
	r.lookupMu.Lock()
	if entry.staleAt.IsZero() {
		entry.staleAt = r.staleUntil(entry.expiresAt, entry.notFound)
	}
	r.lookupCache[cacheKey] = entry
	r.cleanupLookupCachesLocked(now, false)
	r.lookupMu.Unlock()
}

func (r *MySQLResolver) cacheRegistration(phone string, entry registrationCacheEntry, now time.Time) {
	r.lookupMu.Lock()
	if entry.staleAt.IsZero() {
		entry.staleAt = r.staleUntil(entry.expiresAt, entry.notFound)
	}
	r.registrationCache[phone] = entry
	r.cleanupLookupCachesLocked(now, false)
	r.lookupMu.Unlock()
}

func (r *MySQLResolver) newLookupCacheEntry(match vehicleIdentifierMatch, notFound bool, now time.Time) lookupCacheEntry {
	expiresAt := now.Add(r.lookupCacheTTL)
	metadata := match.sourceMetadata()
	return lookupCacheEntry{
		vin:          strings.TrimSpace(match.VIN),
		sourceCode:   strings.TrimSpace(metadata.SourceCode),
		platformName: strings.TrimSpace(metadata.PlatformName),
		sourceKind:   strings.TrimSpace(metadata.SourceKind),
		notFound:     notFound,
		expiresAt:    expiresAt,
		staleAt:      r.staleUntil(expiresAt, notFound),
	}
}

func (match vehicleIdentifierMatch) sourceMetadata() sourceMetadata {
	sourceCode := strings.TrimSpace(match.SourceCode)
	if sourceCode == "" {
		return sourceMetadata{}
	}
	return sourceMetadata{
		SourceCode:   sourceCode,
		PlatformName: strings.TrimSpace(firstNonEmpty(match.PlatformName, sourceCode)),
		SourceKind:   "PLATFORM",
	}
}

func (e lookupCacheEntry) sourceMetadata() sourceMetadata {
	return sourceMetadata{
		SourceCode:   strings.TrimSpace(e.sourceCode),
		PlatformName: strings.TrimSpace(e.platformName),
		SourceKind:   strings.TrimSpace(e.sourceKind),
	}
}

func (r *MySQLResolver) staleUntil(expiresAt time.Time, notFound bool) time.Time {
	if notFound || r.staleLookupTTL <= 0 {
		return expiresAt
	}
	return expiresAt.Add(r.staleLookupTTL)
}

func (e lookupCacheEntry) canServeStale(now time.Time) bool {
	return !e.notFound && strings.TrimSpace(e.vin) != "" && now.Before(e.staleAt)
}

func (e registrationCacheEntry) canServeStale(now time.Time) bool {
	return !e.notFound && (strings.TrimSpace(e.vin) != "" || strings.TrimSpace(e.deviceID) != "" || strings.TrimSpace(e.plate) != "") && now.Before(e.staleAt)
}

func (e sourceCodeCacheEntry) canServeStale(now time.Time) bool {
	return !e.notFound && strings.TrimSpace(e.sourceCode) != "" && now.Before(e.staleAt)
}

func isTransientLookupError(err error) bool {
	return err != nil && !errors.Is(err, sql.ErrNoRows) && !errors.Is(err, errAmbiguousIdentifier)
}

func (r *MySQLResolver) cleanupLookupCachesLocked(now time.Time, force bool) {
	if !force && r.maxCacheEntries > 0 &&
		len(r.lookupCache) <= r.maxCacheEntries &&
		len(r.registrationCache) <= r.maxCacheEntries &&
		len(r.sourceCodeCache) <= r.maxCacheEntries &&
		!r.nextLookupCleanup.IsZero() &&
		now.Before(r.nextLookupCleanup) {
		return
	}
	r.nextLookupCleanup = now.Add(r.cacheCleanupInterval)
	for key, entry := range r.lookupCache {
		if !now.Before(entry.staleAt) {
			delete(r.lookupCache, key)
		}
	}
	for key, entry := range r.registrationCache {
		if !now.Before(entry.staleAt) {
			delete(r.registrationCache, key)
		}
	}
	for key, entry := range r.sourceCodeCache {
		if !now.Before(entry.staleAt) {
			delete(r.sourceCodeCache, key)
		}
	}
	r.trimLookupCacheLocked()
	r.trimRegistrationCacheLocked()
	r.trimSourceCodeCacheLocked()
}

func (r *MySQLResolver) trimLookupCacheLocked() {
	if r.maxCacheEntries <= 0 {
		return
	}
	for len(r.lookupCache) > r.maxCacheEntries {
		victim := ""
		var victimExpiresAt time.Time
		for key, entry := range r.lookupCache {
			if victim == "" || entry.expiresAt.Before(victimExpiresAt) {
				victim = key
				victimExpiresAt = entry.expiresAt
			}
		}
		if victim == "" {
			return
		}
		delete(r.lookupCache, victim)
	}
}

func (r *MySQLResolver) trimRegistrationCacheLocked() {
	if r.maxCacheEntries <= 0 {
		return
	}
	for len(r.registrationCache) > r.maxCacheEntries {
		victim := ""
		var victimExpiresAt time.Time
		for key, entry := range r.registrationCache {
			if victim == "" || entry.expiresAt.Before(victimExpiresAt) {
				victim = key
				victimExpiresAt = entry.expiresAt
			}
		}
		if victim == "" {
			return
		}
		delete(r.registrationCache, victim)
	}
}

func (r *MySQLResolver) trimSourceCodeCacheLocked() {
	if r.maxCacheEntries <= 0 {
		return
	}
	for len(r.sourceCodeCache) > r.maxCacheEntries {
		victim := ""
		var victimExpiresAt time.Time
		for key, entry := range r.sourceCodeCache {
			if victim == "" || entry.expiresAt.Before(victimExpiresAt) {
				victim = key
				victimExpiresAt = entry.expiresAt
			}
		}
		if victim == "" {
			return
		}
		delete(r.sourceCodeCache, victim)
	}
}

func candidateIdentifierType(column string) string {
	switch strings.TrimSpace(strings.ToLower(column)) {
	case "phone":
		return "JT808_PHONE"
	case "plate":
		return "PLATE"
	default:
		return ""
	}
}

func normalizeIdentifierValue(identifierType string, value string) string {
	value = strings.TrimSpace(value)
	switch strings.ToUpper(strings.TrimSpace(identifierType)) {
	case "JT808_PHONE":
		return normalizePhone(value)
	default:
		return value
	}
}

func (r *MySQLResolver) trackJT808(ctx context.Context, env envelope.FrameEnvelope) error {
	if env.Protocol != envelope.ProtocolJT808 || strings.TrimSpace(env.Phone) == "" || strings.TrimSpace(env.MessageID) == "" {
		return nil
	}
	registration := mapValue(env.Parsed, "registration")
	authentication := mapValue(env.Parsed, "authentication")
	deviceID := firstNonEmpty(env.DeviceID, textValue(registration, "device_id"))
	plate := firstNonEmpty(env.Plate, textValue(registration, "plate"))
	vin := strings.TrimSpace(env.VIN)
	if vin == "" {
		vin = "unknown"
	}
	if r.disableRegistrationWrites {
		// The gateway still updates its local phone session immediately. Durable
		// registration/authentication facts are projected from Kafka raw by the
		// identity writer, keeping MySQL latency out of the ingress process.
		r.cacheJT808Registration(env, deviceID, plate, vin)
		if env.MessageID == JT808RegisterMessageID || env.MessageID == JT808AuthMessageID {
			r.observeRegistrationWrite("delegated", "ok", nil)
		}
		return nil
	}

	locationTouchPhone := ""
	locationTouchAt := time.Time{}
	if env.MessageID != JT808RegisterMessageID && env.MessageID != JT808AuthMessageID {
		var ok bool
		locationTouchPhone, locationTouchAt, ok = r.reserveJT808LocationTouch(env)
		if !ok {
			return nil
		}
	}
	firstRegisteredAt := nilTime()
	latestRegisteredAt := nilTime()
	if env.MessageID == JT808RegisterMessageID {
		firstRegisteredAt = "CURRENT_TIMESTAMP"
		latestRegisteredAt = "CURRENT_TIMESTAMP"
	}
	latestAuthenticatedAt := nilTime()
	if env.MessageID == JT808AuthMessageID {
		latestAuthenticatedAt = "CURRENT_TIMESTAMP"
	}

	query := `INSERT INTO jt808_registration
  (phone, device_id, plate, vin, province, city, manufacturer, device_type, plate_color,
   auth_token, auth_imei, auth_software_version, source_endpoint, source_ip,
   first_registered_at, latest_registered_at, latest_authenticated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ` + firstRegisteredAt + `, ` + latestRegisteredAt + `, ` + latestAuthenticatedAt + `)
ON DUPLICATE KEY UPDATE
  device_id = IF(VALUES(device_id) <> '', VALUES(device_id), device_id),
  plate = IF(VALUES(plate) <> '', VALUES(plate), plate),
  vin = IF(VALUES(vin) <> 'unknown', VALUES(vin), vin),
  province = IF(VALUES(province) <> '', VALUES(province), province),
  city = IF(VALUES(city) <> '', VALUES(city), city),
  manufacturer = IF(VALUES(manufacturer) <> '', VALUES(manufacturer), manufacturer),
  device_type = IF(VALUES(device_type) <> '', VALUES(device_type), device_type),
  plate_color = IF(VALUES(plate_color) <> '', VALUES(plate_color), plate_color),
  auth_token = IF(VALUES(auth_token) <> '', VALUES(auth_token), auth_token),
  auth_imei = IF(VALUES(auth_imei) <> '', VALUES(auth_imei), auth_imei),
  auth_software_version = IF(VALUES(auth_software_version) <> '', VALUES(auth_software_version), auth_software_version),
  source_endpoint = IF(VALUES(source_endpoint) <> '', VALUES(source_endpoint), source_endpoint),
  source_ip = IF(VALUES(source_ip) <> '', VALUES(source_ip), source_ip),
  latest_seen_at = CURRENT_TIMESTAMP,
  first_registered_at = COALESCE(first_registered_at, VALUES(first_registered_at)),
  latest_registered_at = COALESCE(VALUES(latest_registered_at), latest_registered_at),
  latest_authenticated_at = COALESCE(VALUES(latest_authenticated_at), latest_authenticated_at)`
	args := []any{
		env.Phone,
		deviceID,
		plate,
		vin,
		textValue(registration, "province"),
		textValue(registration, "city"),
		textValue(registration, "manufacturer"),
		textValue(registration, "device_type"),
		textValue(registration, "plate_color"),
		textValue(authentication, "token"),
		textValue(authentication, "imei"),
		textValue(authentication, "software_version"),
		env.SourceEndpoint,
		normalizeEndpointIP(env.SourceEndpoint),
	}
	if r.registrationWriteQueue != nil {
		if err := r.enqueueJT808RegistrationWrite(ctx, jt808RegistrationWriteJob{
			query:              query,
			args:               args,
			locationTouchPhone: locationTouchPhone,
			locationTouchAt:    locationTouchAt,
		}); err != nil {
			r.observeRegistrationWrite("async_enqueue", "error", err)
			if locationTouchPhone != "" {
				r.rollbackJT808LocationTouch(locationTouchPhone, locationTouchAt)
				r.markJT808LocationTouchFailure(locationTouchPhone)
			}
			return err
		}
		r.observeRegistrationWrite("async_enqueue", "ok", nil)
		r.cacheJT808Registration(env, deviceID, plate, vin)
		return nil
	}
	err := r.execJT808Registration(ctx, query, args...)
	if err != nil {
		r.observeRegistrationWrite("sync", "error", err)
		if locationTouchPhone != "" {
			r.rollbackJT808LocationTouch(locationTouchPhone, locationTouchAt)
			r.markJT808LocationTouchFailure(locationTouchPhone)
		}
		return err
	}
	r.observeRegistrationWrite("sync", "ok", nil)
	if locationTouchPhone != "" {
		r.clearJT808LocationTouchFailure(locationTouchPhone)
	}
	r.cacheJT808Registration(env, deviceID, plate, vin)
	return nil
}

func (r *MySQLResolver) enqueueJT808RegistrationWrite(ctx context.Context, job jt808RegistrationWriteJob) error {
	if r.registrationWriteQueue == nil {
		return r.execJT808Registration(ctx, job.query, job.args...)
	}
	select {
	case <-r.registrationWriteClosed:
		return ErrRegistrationWriteQueueClosed
	default:
	}
	var timeoutC <-chan time.Time
	var timer *time.Timer
	if r.registrationEnqueueTimeout > 0 {
		timer = time.NewTimer(r.registrationEnqueueTimeout)
		timeoutC = timer.C
		defer timer.Stop()
	}
	select {
	case r.registrationWriteQueue <- job:
		return nil
	case <-r.registrationWriteClosed:
		return ErrRegistrationWriteQueueClosed
	case <-ctx.Done():
		return ctx.Err()
	case <-timeoutC:
		return ErrRegistrationWriteQueueTimeout
	}
}

func (r *MySQLResolver) registrationWriteWorker() {
	defer r.registrationWriteWG.Done()
	for {
		select {
		case job := <-r.registrationWriteQueue:
			r.runJT808RegistrationWriteJob(job)
		case <-r.registrationWriteClosed:
			for {
				select {
				case job := <-r.registrationWriteQueue:
					r.runJT808RegistrationWriteJob(job)
				default:
					return
				}
			}
		}
	}
}

func (r *MySQLResolver) runJT808RegistrationWriteJob(job jt808RegistrationWriteJob) {
	ctx := context.Background()
	var cancel context.CancelFunc
	if r.registrationWriteTimeout > 0 {
		ctx, cancel = context.WithTimeout(ctx, r.registrationWriteTimeout)
	} else {
		ctx, cancel = context.WithCancel(ctx)
	}
	err := r.execJT808Registration(ctx, job.query, job.args...)
	cancel()
	if err != nil {
		r.observeRegistrationWrite("async_background", "error", err)
		if job.locationTouchPhone != "" {
			r.rollbackJT808LocationTouch(job.locationTouchPhone, job.locationTouchAt)
			r.markJT808LocationTouchFailure(job.locationTouchPhone)
		}
		return
	}
	r.observeRegistrationWrite("async_background", "ok", nil)
	if job.locationTouchPhone != "" {
		r.clearJT808LocationTouchFailure(job.locationTouchPhone)
	}
}

func (r *MySQLResolver) observeRegistrationWrite(mode string, status string, err error) {
	if r == nil {
		return
	}
	mode = strings.TrimSpace(mode)
	if mode == "" {
		mode = "unknown"
	}
	status = strings.TrimSpace(status)
	if status == "" {
		status = "unknown"
	}
	if r.onRegistrationWriteResult != nil {
		r.onRegistrationWriteResult(RegistrationWriteResult{
			Mode:   mode,
			Status: status,
			Err:    err,
		})
	}
	if err != nil && r.onRegistrationWriteError != nil {
		r.onRegistrationWriteError(err)
	}
}

func (r *MySQLResolver) execJT808Registration(ctx context.Context, query string, args ...any) error {
	attempts := r.registrationWriteAttempts
	if attempts <= 0 {
		attempts = 1
	}
	var err error
	for attempt := 1; attempt <= attempts; attempt++ {
		_, err = r.db.ExecContext(ctx, query, args...)
		if err == nil || !isTransientMySQLIdentityWriteError(err) || attempt == attempts {
			return err
		}
		if r.registrationWriteRetryDelay <= 0 {
			continue
		}
		timer := time.NewTimer(r.registrationWriteRetryDelay)
		select {
		case <-ctx.Done():
			timer.Stop()
			return ctx.Err()
		case <-timer.C:
		}
	}
	return err
}

func isTransientMySQLIdentityWriteError(err error) bool {
	if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
		return false
	}
	text := strings.ToLower(strings.TrimSpace(err.Error()))
	return strings.Contains(text, "deadlock") ||
		strings.Contains(text, "error 1213") ||
		strings.Contains(text, "40001") ||
		strings.Contains(text, "lock wait timeout") ||
		strings.Contains(text, "error 1205") ||
		strings.Contains(text, "timeout") ||
		strings.Contains(text, "temporary") ||
		strings.Contains(text, "temporarily") ||
		strings.Contains(text, "connection refused") ||
		strings.Contains(text, "connection reset") ||
		strings.Contains(text, "connection closed") ||
		strings.Contains(text, "broken pipe") ||
		strings.Contains(text, "bad connection") ||
		strings.Contains(text, "invalid connection") ||
		strings.Contains(text, "i/o timeout") ||
		text == "eof" ||
		strings.Contains(text, "unexpected eof") ||
		strings.Contains(text, "server is down") ||
		strings.Contains(text, "network is unreachable") ||
		strings.Contains(text, "no route to host")
}

func (r *MySQLResolver) cacheJT808Registration(env envelope.FrameEnvelope, deviceID string, plate string, vin string) {
	phone := normalizePhone(env.Phone)
	if phone == "" {
		return
	}
	vin = strings.TrimSpace(vin)
	if vin == "" {
		vin = "unknown"
	}
	now := time.Now()
	r.lookupMu.Lock()
	defer r.lookupMu.Unlock()
	existing := r.registrationCache[phone]
	r.registrationCache[phone] = registrationCacheEntry{
		vin:       preferKnownVIN(vin, existing.vin),
		deviceID:  firstNonEmpty(deviceID, existing.deviceID),
		plate:     firstNonEmpty(plate, existing.plate),
		notFound:  false,
		expiresAt: now.Add(r.lookupCacheTTL),
	}
	entry := r.registrationCache[phone]
	entry.staleAt = r.staleUntil(entry.expiresAt, entry.notFound)
	r.registrationCache[phone] = entry
	r.cleanupLookupCachesLocked(now, false)
}

func preferKnownVIN(candidate string, existing string) string {
	candidate = strings.TrimSpace(candidate)
	existing = strings.TrimSpace(existing)
	if candidate != "" && !strings.EqualFold(candidate, "unknown") {
		return candidate
	}
	if existing != "" {
		return existing
	}
	if candidate != "" {
		return candidate
	}
	return "unknown"
}

func (r *MySQLResolver) reserveJT808LocationTouch(env envelope.FrameEnvelope) (string, time.Time, bool) {
	if env.MessageID != "0x0200" {
		return "", time.Time{}, false
	}
	phone := normalizePhone(env.Phone)
	if phone == "" {
		return "", time.Time{}, false
	}
	now := time.Now()
	r.touchMu.Lock()
	defer r.touchMu.Unlock()
	if retryAt, ok := r.locationTouchFailures[phone]; ok {
		if now.Before(retryAt) {
			return "", time.Time{}, false
		}
		delete(r.locationTouchFailures, phone)
	}
	last, ok := r.locationTouches[phone]
	if ok && now.Sub(last) < r.locationTouchInterval {
		return "", time.Time{}, false
	}
	r.locationTouches[phone] = now
	r.cleanupLocationTouchesLocked(now, false)
	return phone, now, true
}

func (r *MySQLResolver) rollbackJT808LocationTouch(phone string, reservedAt time.Time) {
	phone = normalizePhone(phone)
	if phone == "" || reservedAt.IsZero() {
		return
	}
	r.touchMu.Lock()
	defer r.touchMu.Unlock()
	if current, ok := r.locationTouches[phone]; ok && current.Equal(reservedAt) {
		delete(r.locationTouches, phone)
	}
}

func (r *MySQLResolver) markJT808LocationTouchFailure(phone string) {
	phone = normalizePhone(phone)
	if phone == "" || r.locationTouchRetryInterval <= 0 {
		return
	}
	r.touchMu.Lock()
	defer r.touchMu.Unlock()
	if r.locationTouchFailures == nil {
		r.locationTouchFailures = map[string]time.Time{}
	}
	now := time.Now()
	r.locationTouchFailures[phone] = now.Add(r.locationTouchRetryInterval)
	r.cleanupLocationTouchesLocked(now, false)
}

func (r *MySQLResolver) clearJT808LocationTouchFailure(phone string) {
	phone = normalizePhone(phone)
	if phone == "" {
		return
	}
	r.touchMu.Lock()
	defer r.touchMu.Unlock()
	delete(r.locationTouchFailures, phone)
}

func (r *MySQLResolver) cleanupLocationTouchesLocked(now time.Time, force bool) {
	if !force && r.maxCacheEntries > 0 &&
		len(r.locationTouches) <= r.maxCacheEntries &&
		len(r.locationTouchFailures) <= r.maxCacheEntries &&
		!r.nextTouchCleanup.IsZero() &&
		now.Before(r.nextTouchCleanup) {
		return
	}
	r.nextTouchCleanup = now.Add(r.cacheCleanupInterval)
	cutoff := now.Add(-r.locationTouchInterval)
	for phone, last := range r.locationTouches {
		if last.Before(cutoff) || last.Equal(cutoff) {
			delete(r.locationTouches, phone)
		}
	}
	for phone, retryAt := range r.locationTouchFailures {
		if !now.Before(retryAt) {
			delete(r.locationTouchFailures, phone)
		}
	}
	if r.maxCacheEntries <= 0 {
		return
	}
	for len(r.locationTouches) > r.maxCacheEntries {
		victim := ""
		var oldest time.Time
		for phone, last := range r.locationTouches {
			if victim == "" || last.Before(oldest) {
				victim = phone
				oldest = last
			}
		}
		if victim == "" {
			return
		}
		delete(r.locationTouches, victim)
	}
	for len(r.locationTouchFailures) > r.maxCacheEntries {
		victim := ""
		var earliest time.Time
		for phone, retryAt := range r.locationTouchFailures {
			if victim == "" || retryAt.Before(earliest) {
				victim = phone
				earliest = retryAt
			}
		}
		if victim == "" {
			return
		}
		delete(r.locationTouchFailures, victim)
	}
}

type CandidateKey struct {
	Column string
	Value  string
}

func CandidateKeys(env envelope.FrameEnvelope) []CandidateKey {
	var out []CandidateKey
	add := func(column string, value string) {
		value = strings.TrimSpace(value)
		if value == "" || strings.EqualFold(value, "unknown") {
			return
		}
		for _, existing := range out {
			if existing.Column == column && existing.Value == value {
				return
			}
		}
		out = append(out, CandidateKey{Column: column, Value: value})
	}
	add("phone", normalizePhone(env.Phone))
	add("plate", env.Plate)
	add("vin", env.VehicleKeyHint)
	return out
}

func normalizePhone(phone string) string {
	trimmed := strings.TrimLeft(strings.TrimSpace(phone), "0")
	if trimmed == "" {
		return strings.TrimSpace(phone)
	}
	return trimmed
}

func mapValue(values map[string]any, key string) map[string]any {
	if values == nil {
		return nil
	}
	nested, _ := values[key].(map[string]any)
	return nested
}

func textValue(values map[string]any, key string) string {
	if values == nil {
		return ""
	}
	value, ok := values[key]
	if !ok || value == nil {
		return ""
	}
	switch typed := value.(type) {
	case string:
		return strings.TrimSpace(typed)
	default:
		return strings.TrimSpace(fmt.Sprint(typed))
	}
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		if trimmed := strings.TrimSpace(value); trimmed != "" {
			return trimmed
		}
	}
	return ""
}

func nilTime() string {
	return "NULL"
}
