// Package admin owns the platform's administrator records inside the
// `backend.admin_accounts` table together with the Basic Auth verifier
// consumed by `backend/internal/server/middleware/basicauth`.
//
// The package introduces the package on top of the The implementation user surface.
// The previous placeholder verifier
// (`basicauth.StaticVerifier`) is retired from production wiring; the
// admin-account CRUD endpoints under `/api/v1/admin/admin-accounts/*`
// flip from 501 placeholders to real implementations backed by
// `*admin.Service`.
//
// The package is intentionally narrow: it owns its own table, exposes
// a Verifier-shaped surface, and ships an idempotent env-driven
// bootstrap so a fresh deploy can authenticate the first operator
// without manual SQL. Cross-domain admin handlers (users, games,
// runtime, mail, notification, geo) live in their respective module
// packages; this package only owns the credential gate.
package admin

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/jackc/pgx/v5/pgconn"
	"go.uber.org/zap"
	"golang.org/x/crypto/bcrypt"
)

// bootstrapBcryptCost is the cost factor used for every admin password
// hash. It matches `ARCHITECTURE.md` §14 and `backend/README.md` §12.
//
// The Stage-5.1 auth code uses `bcrypt.DefaultCost` (10) for one-time
// login codes; admin passwords stay separate at cost 12 so the
// stronger hashing covers reused secrets.
const bootstrapBcryptCost = 12

// pgErrCodeUniqueViolation is the SQLSTATE value emitted by Postgres
// when a UNIQUE constraint is violated. The pgx driver surfaces the
// value on `*pgconn.PgError`. The constant is duplicated from
// `internal/user/user.go` so the two packages stay decoupled.
const pgErrCodeUniqueViolation = "23505"

// Admin is the read-side aggregate served to handlers and the
// in-memory cache. It mirrors the OpenAPI `AdminAccount` schema; the
// password hash is intentionally absent so handlers cannot accidentally
// surface it.
type Admin struct {
	Username string
	CreatedAt time.Time
	LastUsedAt *time.Time
	DisabledAt *time.Time
}

// Deps aggregates every collaborator the Service depends on.
// Constructing the Service through Deps (rather than positional args)
// keeps wiring patches small when new dependencies are added.
type Deps struct {
	// Store must be non-nil. It owns every Postgres query against
	// `backend.admin_accounts`.
	Store *Store

	// Cache must be non-nil. The Verifier consults it on the request
	// path; mutation methods write through after a successful commit.
	Cache *Cache

	// Logger is named under "admin" by NewService. Nil falls back to
	// zap.NewNop.
	Logger *zap.Logger

	// Now overrides time.Now for deterministic tests. A nil Now defaults
	// to time.Now in NewService.
	Now func() time.Time
}

// Service is the admin-domain entry point. Concurrency safety is
// delegated to Postgres for persisted state and to the embedded Cache
// for the in-memory projection.
type Service struct {
	deps Deps
}

// NewService constructs a Service from deps. A nil Now defaults to
// time.Now; a nil Logger defaults to zap.NewNop. Store and Cache must
// be non-nil — calling Service methods with nil values will panic at
// first use, matching how main.go signals missing wiring.
func NewService(deps Deps) *Service {
	if deps.Now == nil {
		deps.Now = time.Now
	}
	if deps.Logger == nil {
		deps.Logger = zap.NewNop()
	}
	deps.Logger = deps.Logger.Named("admin")
	return &Service{deps: deps}
}

// CreateInput is the parameter struct for Service.Create.
type CreateInput struct {
	Username string
	Password string
}

// Validate normalises the request and rejects empty fields.
func (in *CreateInput) Validate() error {
	in.Username = strings.TrimSpace(in.Username)
	if in.Username == "" {
		return fmt.Errorf("%w: username must not be empty", ErrInvalidInput)
	}
	if in.Password == "" {
		return fmt.Errorf("%w: password must not be empty", ErrInvalidInput)
	}
	return nil
}

// List returns every admin row ordered by username ASC.
func (s *Service) List(ctx context.Context) ([]Admin, error) {
	rows, _, err := s.deps.Store.ListAll(ctx)
	if err != nil {
		return nil, fmt.Errorf("admin list: %w", err)
	}
	return rows, nil
}

// Get returns the admin aggregate for username. Returns ErrNotFound
// when no row matches.
func (s *Service) Get(ctx context.Context, username string) (Admin, error) {
	username = strings.TrimSpace(username)
	if username == "" {
		return Admin{}, ErrNotFound
	}
	admin, _, err := s.deps.Store.Lookup(ctx, username)
	if err != nil {
		return Admin{}, err
	}
	return admin, nil
}

// Create persists a fresh admin row with the bcrypt-hashed password,
// refreshes the in-memory cache, and returns the persisted aggregate.
// Returns ErrUsernameTaken when the username already exists.
func (s *Service) Create(ctx context.Context, in CreateInput) (Admin, error) {
	if err := (&in).Validate(); err != nil {
		return Admin{}, err
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bootstrapBcryptCost)
	if err != nil {
		return Admin{}, fmt.Errorf("admin create: hash password: %w", err)
	}
	admin, err := s.deps.Store.Insert(ctx, in.Username, hash)
	if err != nil {
		if errors.Is(err, ErrUsernameTaken) {
			return Admin{}, err
		}
		return Admin{}, fmt.Errorf("admin create: %w", err)
	}
	s.deps.Cache.Put(admin, hash)
	return admin, nil
}

// Disable sets `disabled_at = now()` when the account is currently
// enabled. The operation is idempotent: when the account is already
// disabled the existing row is returned unchanged. Returns ErrNotFound
// when no row matches.
func (s *Service) Disable(ctx context.Context, username string) (Admin, error) {
	username = strings.TrimSpace(username)
	if username == "" {
		return Admin{}, ErrNotFound
	}
	now := s.deps.Now().UTC()
	admin, hash, err := s.deps.Store.SetDisabledAt(ctx, username, &now)
	if err != nil {
		return Admin{}, fmt.Errorf("admin disable: %w", err)
	}
	s.deps.Cache.Put(admin, hash)
	return admin, nil
}

// Enable clears `disabled_at` when the account is currently disabled.
// The operation is idempotent: when the account is already enabled the
// existing row is returned unchanged. Returns ErrNotFound when no row
// matches.
func (s *Service) Enable(ctx context.Context, username string) (Admin, error) {
	username = strings.TrimSpace(username)
	if username == "" {
		return Admin{}, ErrNotFound
	}
	admin, hash, err := s.deps.Store.SetDisabledAt(ctx, username, nil)
	if err != nil {
		return Admin{}, fmt.Errorf("admin enable: %w", err)
	}
	s.deps.Cache.Put(admin, hash)
	return admin, nil
}

// ResetPassword bcrypt-hashes newPassword and replaces the stored
// password_hash. The new password itself is not returned per the
// OpenAPI contract ("delivered out-of-band").
func (s *Service) ResetPassword(ctx context.Context, username, newPassword string) (Admin, error) {
	username = strings.TrimSpace(username)
	if username == "" {
		return Admin{}, ErrNotFound
	}
	if newPassword == "" {
		return Admin{}, fmt.Errorf("%w: password must not be empty", ErrInvalidInput)
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bootstrapBcryptCost)
	if err != nil {
		return Admin{}, fmt.Errorf("admin reset password: hash: %w", err)
	}
	admin, err := s.deps.Store.UpdatePasswordHash(ctx, username, hash)
	if err != nil {
		return Admin{}, fmt.Errorf("admin reset password: %w", err)
	}
	s.deps.Cache.Put(admin, hash)
	return admin, nil
}

// isUniqueViolation reports whether err is a Postgres UNIQUE
// constraint violation. constraintName may be empty to match any
// UNIQUE violation.
func isUniqueViolation(err error, constraintName string) bool {
	var pgErr *pgconn.PgError
	if !errors.As(err, &pgErr) {
		return false
	}
	if pgErr.Code != pgErrCodeUniqueViolation {
		return false
	}
	if constraintName == "" {
		return true
	}
	return pgErr.ConstraintName == constraintName
}
