package credentialstore

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

	"github.com/google/uuid"

	"iop/packages/go/config"
)

var (
	// ErrRouteNotFound is returned when a requested route does not exist.
	ErrRouteNotFound = errors.New("credentialstore: route not found")

	// ErrIncompatibleProfile is returned when the protocol profile is incompatible with a slot's vendor and credential kind.
	ErrIncompatibleProfile = errors.New("credentialstore: profile incompatible with slot credential")

	// ErrCrossPrincipalSlot is returned when attempting to bind a route to a slot belonging to a different principal.
	ErrCrossPrincipalSlot = errors.New("credentialstore: slot belongs to a different principal")

	// ErrRouteAliasAlreadyExists is returned when a route alias is already in use for a principal.
	ErrRouteAliasAlreadyExists = errors.New("credentialstore: route alias already exists for principal")

	// ErrRouteRevoked is returned when attempting to modify a revoked route.
	ErrRouteRevoked = errors.New("credentialstore: revoked route cannot be modified")

	// ErrRouteNotActive is returned when an operation requires an active route.
	ErrRouteNotActive = errors.New("credentialstore: route is not active")
)

// CreateRouteInput holds parameters for binding a route to a credential slot.
type CreateRouteInput struct {
	PrincipalID      string
	SlotID           string
	Alias            string
	ProfileID        string
	UpstreamModel    string
	ResourceSelector string
}

// UpdateRouteInput replaces the mutable target fields of a route under a
// revision compare-and-swap. The route ID and principal ownership are stable.
type UpdateRouteInput struct {
	PrincipalID      string
	RouteID          string
	CurrentRevision  int64
	SlotID           string
	Alias            string
	ProfileID        string
	UpstreamModel    string
	ResourceSelector string
}

// RouteRecord represents a persisted route binding row.
type RouteRecord struct {
	ID               string
	PrincipalID      string
	SlotID           string
	Alias            string
	ProfileID        string
	UpstreamModel    string
	ResourceSelector string
	Status           string
	Revision         int64
	CreatedAt        time.Time
	UpdatedAt        time.Time
	RevokedAt        *time.Time
}

type profileAuthRule struct {
	header string
	scheme string
}

type credentialProfileRule map[string]profileAuthRule

// credentialProfileRules is deliberately closed. A credential slot can only
// bind to a catalog profile when its vendor, credential kind, profile ID, and
// profile authentication declaration all agree. New provider support must add
// an explicit entry here instead of inheriting a driver-level default.
var credentialProfileRules = map[string]credentialProfileRule{
	"openai/bearer": {"openai": {header: "Authorization", scheme: "Bearer"}},
	"gemini/bearer": {"gemini": {header: "Authorization", scheme: "Bearer"}},
	"vllm/bearer":   {"openai": {header: "Authorization", scheme: "Bearer"}},
	"sglang/bearer": {"openai": {header: "Authorization", scheme: "Bearer"}},
	"glm/bearer": {
		"glm":        {header: "Authorization", scheme: "Bearer"},
		"glm_coding": {header: "Authorization", scheme: "Bearer"},
	},
	"kimi/bearer":               {"kimi": {header: "Authorization", scheme: "Bearer"}},
	"grok/bearer":               {"grok": {header: "Authorization", scheme: "Bearer"}},
	"anthropic/api_key":         {"anthropic": {header: "x-api-key"}},
	"minimax/bearer":            {"minimax_chat": {header: "Authorization", scheme: "Bearer"}, "minimax_messages": {header: "Authorization", scheme: "Bearer"}},
	"mimo/bearer":               {"mimo_chat": {header: "Authorization", scheme: "Bearer"}},
	"mimo/api_key":              {"mimo_messages": {header: "api-key"}},
	"seulgi/bearer":             {"seulgi_chat": {header: "Authorization", scheme: "Bearer"}},
	"seulgi/api_key":            {"seulgi_messages": {header: "x-api-key"}},
	"seulgivibe_openai/bearer":  {"seulgi_chat": {header: "Authorization", scheme: "Bearer"}},
	"seulgivibe_claude/api_key": {"seulgi_messages": {header: "x-api-key"}},
}

// credentialProtocolProfiles is a process-owned catalog for credential
// routing. The exported config snapshot is intentionally mutable for legacy
// callers, so credential compatibility must not depend on it after startup.
var credentialProtocolProfiles = config.BuiltInProtocolProfileCatalog()

func compatibilityKey(vendor, credentialKind string) string {
	return normalizeCredentialPart(vendor) + "/" + normalizeCredentialPart(credentialKind)
}

func checkSlotProfileCompatibility(slot CredentialSlotRecord, profile config.ConcreteProtocolProfile) error {
	rule, ok := credentialProfileRules[compatibilityKey(slot.Vendor, slot.CredentialKind)]
	if !ok {
		return fmt.Errorf("%w: unknown vendor/credential kind %s/%s", ErrIncompatibleProfile, slot.Vendor, slot.CredentialKind)
	}
	auth, ok := rule[profile.ID]
	if !ok || !strings.EqualFold(strings.TrimSpace(profile.Auth.Header), auth.header) || !strings.EqualFold(strings.TrimSpace(profile.Auth.Scheme), auth.scheme) {
		return fmt.Errorf("%w: %s/%s cannot use profile %s", ErrIncompatibleProfile, slot.Vendor, slot.CredentialKind, profile.ID)
	}
	return nil
}

// CreateRoute creates a route binding for a principal and slot.
func (s *Store) CreateRoute(ctx context.Context, in CreateRouteInput) (*RouteRecord, error) {
	if strings.TrimSpace(in.PrincipalID) == "" {
		return nil, fmt.Errorf("credentialstore: CreateRoute: principal_id is required")
	}
	if strings.TrimSpace(in.SlotID) == "" {
		return nil, fmt.Errorf("credentialstore: CreateRoute: slot_id is required")
	}
	if strings.TrimSpace(in.UpstreamModel) == "" {
		return nil, fmt.Errorf("credentialstore: CreateRoute: upstream_model is required")
	}

	alias := normalizeAlias(in.Alias)
	resSelector := normalizeResourceSelector(in.ResourceSelector)
	upstreamModel := strings.TrimSpace(in.UpstreamModel)
	routeID := uuid.New().String()
	now := time.Now().UTC()

	var record RouteRecord
	err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
		slot, err := s.routeSlotTx(ctx, tx, in.PrincipalID, in.SlotID)
		if err != nil {
			return err
		}
		if slot.Status == StatusRevoked {
			return ErrSlotRevoked
		}
		if slot.Status == StatusDisabled {
			return ErrSlotNotActive
		}
		profile, err := resolveSlotProfile(slot, in.ProfileID)
		if err != nil {
			return err
		}
		if err := s.ensureRouteIDAvailableTx(ctx, tx, in.PrincipalID, routeID); err != nil {
			return err
		}
		if err := s.ensureRouteAliasAvailableTx(ctx, tx, in.PrincipalID, routeID, alias, ""); err != nil {
			return err
		}
		var aliasArg any
		if alias != "" {
			aliasArg = alias
		}
		if _, err := tx.ExecContext(ctx, s.bind(`
			INSERT INTO routes (id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at)
			VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		`), routeID, in.PrincipalID, slot.ID, aliasArg, profile.ID, upstreamModel, resSelector, StatusActive, 0, now, now); err != nil {
			return fmt.Errorf("credentialstore: insert route: %w", err)
		}
		if slot.Status == StatusDraft {
			if err := s.activateDraftSlotTx(ctx, tx, slot); err != nil {
				return err
			}
		}
		if err := s.bumpProjectionGenerationTx(ctx, tx); err != nil {
			return err
		}
		record = RouteRecord{ID: routeID, PrincipalID: in.PrincipalID, SlotID: slot.ID, Alias: alias, ProfileID: profile.ID, UpstreamModel: upstreamModel, ResourceSelector: resSelector, Status: StatusActive, Revision: 0, CreatedAt: now, UpdatedAt: now}
		return nil
	})

	if err != nil {
		return nil, err
	}
	return &record, nil
}

func normalizeResourceSelector(value string) string {
	if normalized := strings.TrimSpace(value); normalized != "" {
		return normalized
	}
	return "default"
}

func resolveSlotProfile(slot CredentialSlotRecord, profileID string) (config.ConcreteProtocolProfile, error) {
	profile, err := config.ResolveProtocolProfile(profileID, slot.Vendor, credentialProtocolProfiles)
	if err != nil {
		return config.ConcreteProtocolProfile{}, fmt.Errorf("%w: %v", ErrIncompatibleProfile, err)
	}
	if err := checkSlotProfileCompatibility(slot, profile); err != nil {
		return config.ConcreteProtocolProfile{}, err
	}
	return profile, nil
}

func (s *Store) routeSlotTx(ctx context.Context, tx *sql.Tx, principalID, slotID string) (CredentialSlotRecord, error) {
	var slot CredentialSlotRecord
	if err := s.loadSlotTx(ctx, tx, principalID, slotID, &slot); err == nil {
		return slot, nil
	} else if !errors.Is(err, sql.ErrNoRows) {
		return CredentialSlotRecord{}, err
	}
	var actualPrincipalID string
	err := tx.QueryRowContext(ctx, s.bind(`SELECT principal_id FROM credential_slots WHERE id=?`), slotID).Scan(&actualPrincipalID)
	if err == nil && actualPrincipalID != principalID {
		return CredentialSlotRecord{}, ErrCrossPrincipalSlot
	}
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return CredentialSlotRecord{}, err
	}
	return CredentialSlotRecord{}, ErrSlotNotFound
}

func (s *Store) ensureRouteAliasAvailableTx(ctx context.Context, tx *sql.Tx, principalID, routeID, alias, excludeRouteID string) error {
	if alias == "" {
		return nil
	}
	query := `SELECT COUNT(*) FROM routes WHERE principal_id=? AND (alias=? OR id=?)`
	args := []any{principalID, alias, alias}
	if excludeRouteID != "" {
		query += ` AND id<>?`
		args = append(args, excludeRouteID)
	}
	var count int
	if err := tx.QueryRowContext(ctx, s.bind(query), args...).Scan(&count); err != nil {
		return fmt.Errorf("credentialstore: check route alias: %w", err)
	}
	if count > 0 || alias == routeID {
		return ErrRouteAliasAlreadyExists
	}
	return nil
}

func (s *Store) ensureRouteIDAvailableTx(ctx context.Context, tx *sql.Tx, principalID, routeID string) error {
	var count int
	if err := tx.QueryRowContext(ctx, s.bind(`SELECT COUNT(*) FROM routes WHERE principal_id=? AND (id=? OR alias=?)`), principalID, routeID, routeID).Scan(&count); err != nil {
		return fmt.Errorf("credentialstore: check route id: %w", err)
	}
	if count > 0 {
		return ErrRouteAliasAlreadyExists
	}
	return nil
}

func (s *Store) activateDraftSlotTx(ctx context.Context, tx *sql.Tx, slot CredentialSlotRecord) error {
	if err := s.validateEnvelopeKey(ctx, slot.Envelope); err != nil {
		return err
	}
	res, err := tx.ExecContext(ctx, s.bind(`UPDATE credential_slots SET status=?, revision=revision+1, updated_at=? WHERE id=? AND revision=? AND status=?`), StatusActive, time.Now().UTC(), slot.ID, slot.Revision, StatusDraft)
	if err != nil {
		return err
	}
	affected, err := res.RowsAffected()
	if err != nil {
		return err
	}
	if affected != 1 {
		return fmt.Errorf("%w: draft slot activation lost", ErrRevisionMismatch)
	}
	return nil
}

// GetRoute retrieves a route binding by principal ID and route ID.
func (s *Store) GetRoute(ctx context.Context, principalID, routeID string) (*RouteRecord, error) {
	var r RouteRecord
	var alias sql.NullString
	var createdAt, updatedAt string
	var revokedAt sql.NullString
	err := s.db.QueryRowContext(ctx, s.bind(`
		SELECT id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at, revoked_at
		FROM routes
		WHERE principal_id=? AND id=?
	`), principalID, routeID).Scan(
		&r.ID, &r.PrincipalID, &r.SlotID, &alias, &r.ProfileID, &r.UpstreamModel, &r.ResourceSelector, &r.Status, &r.Revision,
		&createdAt, &updatedAt, &revokedAt,
	)
	if err != nil {
		if err == sql.ErrNoRows {
			return nil, ErrRouteNotFound
		}
		return nil, err
	}
	if alias.Valid {
		r.Alias = alias.String
	}
	r.CreatedAt, _ = parseTime(createdAt)
	r.UpdatedAt, _ = parseTime(updatedAt)
	if revokedAt.Valid {
		parsed, _ := parseTime(revokedAt.String)
		r.RevokedAt = &parsed
	}
	return &r, nil
}

// GetRouteByAlias retrieves a route binding by principal ID and alias.
func (s *Store) GetRouteByAlias(ctx context.Context, principalID, alias string) (*RouteRecord, error) {
	var r RouteRecord
	var aliasVal sql.NullString
	var createdAt, updatedAt string
	var revokedAt sql.NullString
	err := s.db.QueryRowContext(ctx, s.bind(`
		SELECT id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at, revoked_at
		FROM routes
		WHERE principal_id=? AND alias=?
	`), principalID, alias).Scan(
		&r.ID, &r.PrincipalID, &r.SlotID, &aliasVal, &r.ProfileID, &r.UpstreamModel, &r.ResourceSelector, &r.Status, &r.Revision,
		&createdAt, &updatedAt, &revokedAt,
	)
	if err != nil {
		if err == sql.ErrNoRows {
			return nil, ErrRouteNotFound
		}
		return nil, err
	}
	if aliasVal.Valid {
		r.Alias = aliasVal.String
	}
	r.CreatedAt, _ = parseTime(createdAt)
	r.UpdatedAt, _ = parseTime(updatedAt)
	if revokedAt.Valid {
		parsed, _ := parseTime(revokedAt.String)
		r.RevokedAt = &parsed
	}
	return &r, nil
}

// ListRoutes returns all route bindings for a principal.
func (s *Store) ListRoutes(ctx context.Context, principalID string) ([]RouteRecord, error) {
	rows, err := s.db.QueryContext(ctx, s.bind(`
		SELECT id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at, revoked_at
		FROM routes
		WHERE principal_id=?
		ORDER BY created_at ASC
	`), principalID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var out []RouteRecord
	for rows.Next() {
		var r RouteRecord
		var alias sql.NullString
		var createdAt, updatedAt string
		var revokedAt sql.NullString
		if err := rows.Scan(
			&r.ID, &r.PrincipalID, &r.SlotID, &alias, &r.ProfileID, &r.UpstreamModel, &r.ResourceSelector, &r.Status, &r.Revision,
			&createdAt, &updatedAt, &revokedAt,
		); err != nil {
			return nil, err
		}
		if alias.Valid {
			r.Alias = alias.String
		}
		r.CreatedAt, _ = parseTime(createdAt)
		r.UpdatedAt, _ = parseTime(updatedAt)
		if revokedAt.Valid {
			parsed, _ := parseTime(revokedAt.String)
			r.RevokedAt = &parsed
		}
		out = append(out, r)
	}
	return out, rows.Err()
}

// ListRoutesBySlot returns all route bindings attached to a specific slot.
func (s *Store) ListRoutesBySlot(ctx context.Context, principalID, slotID string) ([]RouteRecord, error) {
	rows, err := s.db.QueryContext(ctx, s.bind(`
		SELECT id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at, revoked_at
		FROM routes
		WHERE principal_id=? AND slot_id=?
		ORDER BY created_at ASC
	`), principalID, slotID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var out []RouteRecord
	for rows.Next() {
		var r RouteRecord
		var alias sql.NullString
		var createdAt, updatedAt string
		var revokedAt sql.NullString
		if err := rows.Scan(
			&r.ID, &r.PrincipalID, &r.SlotID, &alias, &r.ProfileID, &r.UpstreamModel, &r.ResourceSelector, &r.Status, &r.Revision,
			&createdAt, &updatedAt, &revokedAt,
		); err != nil {
			return nil, err
		}
		if alias.Valid {
			r.Alias = alias.String
		}
		r.CreatedAt, _ = parseTime(createdAt)
		r.UpdatedAt, _ = parseTime(updatedAt)
		if revokedAt.Valid {
			parsed, _ := parseTime(revokedAt.String)
			r.RevokedAt = &parsed
		}
		out = append(out, r)
	}
	return out, rows.Err()
}

// UpdateRoute atomically replaces a route's slot/profile/model/selector/alias
// when the caller presents the current route revision.
func (s *Store) UpdateRoute(ctx context.Context, in UpdateRouteInput) (*RouteRecord, error) {
	if strings.TrimSpace(in.PrincipalID) == "" || strings.TrimSpace(in.RouteID) == "" || strings.TrimSpace(in.SlotID) == "" {
		return nil, fmt.Errorf("credentialstore: UpdateRoute: principal_id, route_id, and slot_id are required")
	}
	if strings.TrimSpace(in.UpstreamModel) == "" {
		return nil, fmt.Errorf("credentialstore: UpdateRoute: upstream_model is required")
	}
	alias := normalizeAlias(in.Alias)
	resSelector := normalizeResourceSelector(in.ResourceSelector)
	upstreamModel := strings.TrimSpace(in.UpstreamModel)

	var updated RouteRecord
	err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
		var route RouteRecord
		if err := s.loadRouteTx(ctx, tx, in.PrincipalID, in.RouteID, &route); err != nil {
			if errors.Is(err, sql.ErrNoRows) {
				return ErrRouteNotFound
			}
			return err
		}
		if route.Status == StatusRevoked {
			return ErrRouteRevoked
		}
		if route.Revision != in.CurrentRevision {
			return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, route.Revision, in.CurrentRevision)
		}
		slot, err := s.routeSlotTx(ctx, tx, in.PrincipalID, in.SlotID)
		if err != nil {
			return err
		}
		if slot.Status != StatusActive {
			return ErrSlotNotActive
		}
		profile, err := resolveSlotProfile(slot, in.ProfileID)
		if err != nil {
			return err
		}
		if err := s.ensureRouteAliasAvailableTx(ctx, tx, in.PrincipalID, in.RouteID, alias, in.RouteID); err != nil {
			return err
		}
		var aliasArg any
		if alias != "" {
			aliasArg = alias
		}
		now := time.Now().UTC()
		res, err := tx.ExecContext(ctx, s.bind(`
			UPDATE routes
			SET slot_id=?, alias=?, profile_id=?, upstream_model=?, resource_selector=?, revision=revision+1, updated_at=?
			WHERE principal_id=? AND id=? AND revision=? AND status<>?
		`), slot.ID, aliasArg, profile.ID, upstreamModel, resSelector, now, in.PrincipalID, in.RouteID, in.CurrentRevision, StatusRevoked)
		if err != nil {
			return err
		}
		affected, err := res.RowsAffected()
		if err != nil {
			return err
		}
		if affected != 1 {
			return fmt.Errorf("%w: failed to update route", ErrRevisionMismatch)
		}
		if err := s.loadRouteTx(ctx, tx, in.PrincipalID, in.RouteID, &updated); err != nil {
			return err
		}
		return s.bumpProjectionGenerationTx(ctx, tx)
	})
	if err != nil {
		return nil, err
	}
	return &updated, nil
}

func (s *Store) slotHasCompatibleBindingTx(ctx context.Context, tx *sql.Tx, slot CredentialSlotRecord) (bool, error) {
	rows, err := tx.QueryContext(ctx, s.bind(`SELECT profile_id FROM routes WHERE principal_id=? AND slot_id=? AND status<>?`), slot.PrincipalID, slot.ID, StatusRevoked)
	if err != nil {
		return false, err
	}
	defer rows.Close()
	for rows.Next() {
		var profileID string
		if err := rows.Scan(&profileID); err != nil {
			return false, err
		}
		if _, err := resolveSlotProfile(slot, profileID); err == nil {
			return true, nil
		}
	}
	return false, rows.Err()
}

// DisableRoute disables an active route binding using CAS on revision.
func (s *Store) DisableRoute(ctx context.Context, principalID, routeID string, currentRevision int64) (*RouteRecord, error) {
	return s.casRouteStatus(ctx, principalID, routeID, currentRevision, []string{StatusActive}, StatusDisabled)
}

// EnableRoute enables a disabled route binding using CAS on revision.
func (s *Store) EnableRoute(ctx context.Context, principalID, routeID string, currentRevision int64) (*RouteRecord, error) {
	return s.enableRoute(ctx, principalID, routeID, currentRevision)
}

func (s *Store) enableRoute(ctx context.Context, principalID, routeID string, currentRevision int64) (*RouteRecord, error) {
	var updated RouteRecord
	err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
		var route RouteRecord
		if err := s.loadRouteTx(ctx, tx, principalID, routeID, &route); err != nil {
			if errors.Is(err, sql.ErrNoRows) {
				return ErrRouteNotFound
			}
			return err
		}
		if route.Revision != currentRevision {
			return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, route.Revision, currentRevision)
		}
		if route.Status == StatusRevoked {
			return ErrRouteRevoked
		}
		if route.Status != StatusDisabled {
			return ErrRouteNotActive
		}
		slot, err := s.routeSlotTx(ctx, tx, principalID, route.SlotID)
		if err != nil {
			return err
		}
		if slot.Status != StatusActive {
			return ErrSlotNotActive
		}
		if _, err := resolveSlotProfile(slot, route.ProfileID); err != nil {
			return err
		}
		now := time.Now().UTC()
		res, err := tx.ExecContext(ctx, s.bind(`UPDATE routes SET status=?, revision=revision+1, updated_at=?, revoked_at=NULL WHERE principal_id=? AND id=? AND revision=? AND status=?`), StatusActive, now, principalID, routeID, currentRevision, StatusDisabled)
		if err != nil {
			return err
		}
		affected, err := res.RowsAffected()
		if err != nil {
			return err
		}
		if affected != 1 {
			return fmt.Errorf("%w: failed to enable route", ErrRevisionMismatch)
		}
		if err := s.loadRouteTx(ctx, tx, principalID, routeID, &updated); err != nil {
			return err
		}
		return s.bumpProjectionGenerationTx(ctx, tx)
	})
	if err != nil {
		return nil, err
	}
	return &updated, nil
}

// RevokeRoute revokes a route binding permanently using CAS on revision.
func (s *Store) RevokeRoute(ctx context.Context, principalID, routeID string, currentRevision int64) (*RouteRecord, error) {
	return s.casRouteStatus(ctx, principalID, routeID, currentRevision, []string{StatusActive, StatusDisabled}, StatusRevoked)
}

func (s *Store) casRouteStatus(ctx context.Context, principalID, routeID string, currentRevision int64, fromStatuses []string, toStatus string) (*RouteRecord, error) {
	var updated RouteRecord
	err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
		now := time.Now().UTC()
		placeholders := strings.TrimRight(strings.Repeat("?,", len(fromStatuses)), ",")
		query := `UPDATE routes SET status=?, revision=revision+1, updated_at=?, revoked_at=?
			WHERE principal_id=? AND id=? AND revision=? AND status IN (` + placeholders + `)`
		var revokedArg interface{}
		if toStatus == StatusRevoked {
			revokedArg = now
		}
		args := []any{toStatus, now, revokedArg, principalID, routeID, currentRevision}
		for _, st := range fromStatuses {
			args = append(args, st)
		}
		res, err := tx.ExecContext(ctx, s.bind(query), args...)
		if err != nil {
			return err
		}
		affected, err := res.RowsAffected()
		if err != nil {
			return err
		}
		if affected != 1 {
			return s.classifyRouteTransitionMiss(ctx, tx, principalID, routeID, currentRevision, fromStatuses)
		}
		if err := s.loadRouteTx(ctx, tx, principalID, routeID, &updated); err != nil {
			return err
		}
		return s.bumpProjectionGenerationTx(ctx, tx)
	})
	if err != nil {
		return nil, err
	}
	return &updated, nil
}

func (s *Store) classifyRouteTransitionMiss(ctx context.Context, tx *sql.Tx, principalID, routeID string, currentRevision int64, fromStatuses []string) error {
	var status string
	var revision int64
	err := tx.QueryRowContext(ctx, s.bind(`
		SELECT status, revision FROM routes WHERE principal_id=? AND id=?
	`), principalID, routeID).Scan(&status, &revision)
	if err == sql.ErrNoRows {
		return ErrRouteNotFound
	}
	if err != nil {
		return err
	}
	if revision != currentRevision {
		return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, revision, currentRevision)
	}
	if status == StatusRevoked {
		return ErrRouteRevoked
	}
	return fmt.Errorf("%w: have %s want one of %s", ErrRouteNotActive, status, strings.Join(fromStatuses, ","))
}

func (s *Store) loadRouteTx(ctx context.Context, tx *sql.Tx, principalID, routeID string, target *RouteRecord) error {
	var alias sql.NullString
	var createdAt, updatedAt string
	var revokedAt sql.NullString
	err := tx.QueryRowContext(ctx, s.bind(`
		SELECT id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at, revoked_at
		FROM routes
		WHERE principal_id=? AND id=?
	`), principalID, routeID).Scan(
		&target.ID, &target.PrincipalID, &target.SlotID, &alias, &target.ProfileID, &target.UpstreamModel, &target.ResourceSelector, &target.Status, &target.Revision,
		&createdAt, &updatedAt, &revokedAt,
	)
	if err != nil {
		return err
	}
	if alias.Valid {
		target.Alias = alias.String
	}
	target.CreatedAt, _ = parseTime(createdAt)
	target.UpdatedAt, _ = parseTime(updatedAt)
	if revokedAt.Valid {
		parsed, _ := parseTime(revokedAt.String)
		target.RevokedAt = &parsed
	}
	return nil
}
