package credentialstore

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

	"google.golang.org/protobuf/proto"

	iop "iop/proto/gen/iop"
)

const defaultProjectionTTL = 5 * time.Minute

var (
	ErrProjectionGenerationMissing = errors.New("credentialstore: projection generation is missing")
	ErrProjectionTooLarge          = errors.New("credentialstore: projection exceeds configured bounds")
)

// ProjectionLimits bounds the amount of verifier and route metadata loaded
// into one immutable projection. MaxBytes is measured using protobuf wire
// size after the snapshot is assembled.
type ProjectionLimits struct {
	MaxTokens int
	MaxRoutes int
	MaxBytes  int
}

// DefaultProjectionLimits returns conservative hard bounds for one Edge
// authorization projection.
func DefaultProjectionLimits() ProjectionLimits {
	return ProjectionLimits{
		MaxTokens: 4096,
		MaxRoutes: 16384,
		MaxBytes:  4 << 20,
	}
}

// ProjectionBuildOptions controls snapshot expiry, bounds, and time. Clock is
// injectable so expiry fixtures do not depend on wall-clock sleeps.
type ProjectionBuildOptions struct {
	TTL    time.Duration
	Limits ProjectionLimits
	Clock  func() time.Time
}

func normalizeProjectionBuildOptions(opts ProjectionBuildOptions) (ProjectionBuildOptions, error) {
	if opts.TTL < 0 {
		return ProjectionBuildOptions{}, fmt.Errorf("credentialstore: projection ttl must be positive")
	}
	if opts.TTL == 0 {
		opts.TTL = defaultProjectionTTL
	}
	if opts.Clock == nil {
		opts.Clock = time.Now
	}
	defaults := DefaultProjectionLimits()
	if opts.Limits.MaxTokens < 0 || opts.Limits.MaxRoutes < 0 || opts.Limits.MaxBytes < 0 {
		return ProjectionBuildOptions{}, fmt.Errorf("credentialstore: projection limits must not be negative")
	}
	if opts.Limits.MaxTokens == 0 {
		opts.Limits.MaxTokens = defaults.MaxTokens
	}
	if opts.Limits.MaxRoutes == 0 {
		opts.Limits.MaxRoutes = defaults.MaxRoutes
	}
	if opts.Limits.MaxBytes == 0 {
		opts.Limits.MaxBytes = defaults.MaxBytes
	}
	return opts, nil
}

// ProjectionGeneration returns the durable generation currently committed by
// token, slot, and route mutations.
func (s *Store) ProjectionGeneration(ctx context.Context) (uint64, error) {
	if s == nil || s.db == nil {
		return 0, ErrProjectionGenerationMissing
	}
	var generation int64
	if err := s.db.QueryRowContext(ctx, `SELECT generation FROM principal_projection_state WHERE singleton=1`).Scan(&generation); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return 0, ErrProjectionGenerationMissing
		}
		return 0, err
	}
	if generation < 0 {
		return 0, fmt.Errorf("credentialstore: invalid projection generation %d", generation)
	}
	return uint64(generation), nil
}

// bumpProjectionGenerationTx increments the singleton generation inside the
// caller's mutation transaction. A failure rolls the entire mutation back.
func (s *Store) bumpProjectionGenerationTx(ctx context.Context, tx *sql.Tx) error {
	result, err := tx.ExecContext(ctx, `UPDATE principal_projection_state SET generation=generation+1 WHERE singleton=1`)
	if err != nil {
		return fmt.Errorf("credentialstore: bump projection generation: %w", err)
	}
	affected, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("credentialstore: inspect projection generation bump: %w", err)
	}
	if affected != 1 {
		return ErrProjectionGenerationMissing
	}
	return nil
}

// BuildPrincipalProjection reads one transactionally consistent, secret-free
// snapshot. Only active token digests and routes whose credential slot is also
// active are included. Ciphertext, envelope metadata, and raw credentials are
// deliberately absent from the protobuf contract.
func (s *Store) BuildPrincipalProjection(ctx context.Context, opts ProjectionBuildOptions) (*iop.PrincipalProjection, error) {
	if s == nil || s.db == nil {
		return nil, ErrProjectionGenerationMissing
	}
	opts, err := normalizeProjectionBuildOptions(opts)
	if err != nil {
		return nil, err
	}

	tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
	if err != nil {
		return nil, fmt.Errorf("credentialstore: begin projection snapshot: %w", err)
	}
	defer func() { _ = tx.Rollback() }()

	var generation int64
	if err := tx.QueryRowContext(ctx, `SELECT generation FROM principal_projection_state WHERE singleton=1`).Scan(&generation); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrProjectionGenerationMissing
		}
		return nil, fmt.Errorf("credentialstore: read projection generation: %w", err)
	}
	if generation < 0 {
		return nil, fmt.Errorf("credentialstore: invalid projection generation %d", generation)
	}

	tokens, err := s.projectedTokensTx(ctx, tx, opts.Limits.MaxTokens)
	if err != nil {
		return nil, err
	}
	routes, err := s.projectedRoutesTx(ctx, tx, opts.Limits.MaxRoutes)
	if err != nil {
		return nil, err
	}

	issuedAt := opts.Clock().UTC()
	snapshot := &iop.PrincipalProjection{
		Generation:        uint64(generation),
		IssuedAtUnixNano:  issuedAt.UnixNano(),
		ExpiresAtUnixNano: issuedAt.Add(opts.TTL).UnixNano(),
		Tokens:            tokens,
		Routes:            routes,
	}
	if size := proto.Size(snapshot); size > opts.Limits.MaxBytes {
		return nil, fmt.Errorf("%w: bytes=%d max=%d", ErrProjectionTooLarge, size, opts.Limits.MaxBytes)
	}
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("credentialstore: commit projection snapshot: %w", err)
	}
	return snapshot, nil
}

func (s *Store) projectedTokensTx(ctx context.Context, tx *sql.Tx, max int) ([]*iop.ProjectedPrincipalToken, error) {
	rows, err := tx.QueryContext(ctx, s.bind(`
		SELECT t.digest, p.id, p.alias, t.token_ref, t.revision
		FROM tokens t
		JOIN principals p ON p.id=t.principal_id
		WHERE t.status=?
		ORDER BY p.id, t.token_ref
		LIMIT ?
	`), StatusActive, max+1)
	if err != nil {
		return nil, fmt.Errorf("credentialstore: query projected tokens: %w", err)
	}
	defer rows.Close()

	tokens := make([]*iop.ProjectedPrincipalToken, 0)
	for rows.Next() {
		var token iop.ProjectedPrincipalToken
		var revision int64
		if err := rows.Scan(&token.TokenDigestSha256, &token.PrincipalRef, &token.PrincipalAlias, &token.TokenRef, &revision); err != nil {
			return nil, fmt.Errorf("credentialstore: scan projected token: %w", err)
		}
		if len(tokens) == max {
			return nil, fmt.Errorf("%w: tokens exceed %d", ErrProjectionTooLarge, max)
		}
		if revision < 0 {
			return nil, fmt.Errorf("credentialstore: token %s has negative revision", token.TokenRef)
		}
		token.TokenRevision = uint64(revision)
		tokens = append(tokens, &token)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("credentialstore: iterate projected tokens: %w", err)
	}
	return tokens, nil
}

func (s *Store) projectedRoutesTx(ctx context.Context, tx *sql.Tx, max int) ([]*iop.ProjectedPrincipalRoute, error) {
	rows, err := tx.QueryContext(ctx, s.bind(`
		SELECT r.id, r.alias, r.principal_id, r.slot_id, r.profile_id,
		       r.upstream_model, r.resource_selector, r.revision, s.revision
		FROM routes r
		JOIN credential_slots s ON s.id=r.slot_id AND s.principal_id=r.principal_id
		WHERE r.status=? AND s.status=?
		ORDER BY r.principal_id, r.id
		LIMIT ?
	`), StatusActive, StatusActive, max+1)
	if err != nil {
		return nil, fmt.Errorf("credentialstore: query projected routes: %w", err)
	}
	defer rows.Close()

	routes := make([]*iop.ProjectedPrincipalRoute, 0)
	for rows.Next() {
		var route iop.ProjectedPrincipalRoute
		var alias sql.NullString
		var routeRevision, credentialRevision int64
		if err := rows.Scan(
			&route.RouteId, &alias, &route.PrincipalRef, &route.CredentialSlotRef,
			&route.ProfileId, &route.UpstreamModel, &route.ResourceSelector,
			&routeRevision, &credentialRevision,
		); err != nil {
			return nil, fmt.Errorf("credentialstore: scan projected route: %w", err)
		}
		if len(routes) == max {
			return nil, fmt.Errorf("%w: routes exceed %d", ErrProjectionTooLarge, max)
		}
		if routeRevision < 0 || credentialRevision < 0 {
			return nil, fmt.Errorf("credentialstore: route %s has a negative revision", route.RouteId)
		}
		if alias.Valid {
			route.RouteAlias = alias.String
		}
		route.RouteRevision = uint64(routeRevision)
		route.CredentialRevision = uint64(credentialRevision)
		routes = append(routes, &route)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("credentialstore: iterate projected routes: %w", err)
	}
	return routes, nil
}
