package db

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

	"github.com/jackc/pgx/v5"
)

// UserRow is a user record.
type UserRow struct {
	ID           int64
	Email        string
	PasswordHash string // empty for Steam-only users
	SteamID      string // SteamID64 (17-digit); empty if not linked
	Role         string // "admin" | "user"
	Disabled     bool
	CreatedAt    time.Time
	UpdatedAt    time.Time
}

// SessionRow is a valid (or expired) browser session.
type SessionRow struct {
	Token      string
	UserID     int64
	CreatedAt  time.Time
	ExpiresAt  time.Time
	LastSeenIP string
	LastSeenAt time.Time
}

func (db *DB) CountUsers(ctx context.Context) (int, error) {
	var n int
	if err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
		return 0, fmt.Errorf("count users: %w", err)
	}
	return n, nil
}

func (db *DB) CreateUser(ctx context.Context, email, passwordHash, role string) (int64, error) {
	var id int64
	err := db.pool.QueryRow(ctx, `
		INSERT INTO users (email, password_hash, role)
		VALUES ($1, $2, $3)
		RETURNING id
	`, email, passwordHash, role).Scan(&id)
	if err != nil {
		return 0, fmt.Errorf("create user: %w", err)
	}
	return id, nil
}

// CreateSteamUser provisions a new panel user with only a SteamID
// linked — no password, sign-in via Steam OpenID only. Used by the
// admin-side "Add user by SteamID" flow so a teammate can be granted
// access without giving them a panel email/password.
func (db *DB) CreateSteamUser(ctx context.Context, email, steamID, role string) (int64, error) {
	var id int64
	err := db.pool.QueryRow(ctx, `
		INSERT INTO users (email, password_hash, steam_id, role)
		VALUES ($1, NULL, $2, $3)
		RETURNING id
	`, email, steamID, role).Scan(&id)
	if err != nil {
		return 0, fmt.Errorf("create steam user: %w", err)
	}
	return id, nil
}

// ErrUserNotFound / ErrSessionNotFound are sentinel errors for the middleware
// to distinguish "not logged in" from real DB errors.
var (
	ErrUserNotFound    = errors.New("user not found")
	ErrSessionNotFound = errors.New("session not found")
)

// scanSteamAware is the generic row scan that handles the nullable
// password_hash + steam_id columns the 009 migration introduced. Used by
// every GetUser* helper so their SQL SELECT clauses stay aligned.
func scanSteamAware(row pgx.Row, u *UserRow) error {
	var pwHash, steamID *string
	err := row.Scan(&u.ID, &u.Email, &pwHash, &steamID, &u.Role, &u.Disabled, &u.CreatedAt, &u.UpdatedAt)
	if err != nil {
		return err
	}
	if pwHash != nil {
		u.PasswordHash = *pwHash
	}
	if steamID != nil {
		u.SteamID = *steamID
	}
	return nil
}

func (db *DB) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) {
	var u UserRow
	err := scanSteamAware(db.pool.QueryRow(ctx, `
		SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at
		FROM users WHERE email = $1
	`, email), &u)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, ErrUserNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get user: %w", err)
	}
	return &u, nil
}

func (db *DB) GetUserByID(ctx context.Context, id int64) (*UserRow, error) {
	var u UserRow
	err := scanSteamAware(db.pool.QueryRow(ctx, `
		SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at
		FROM users WHERE id = $1
	`, id), &u)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, ErrUserNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get user: %w", err)
	}
	return &u, nil
}

// GetUserBySteamID looks up a user by their linked SteamID64. Returns
// ErrUserNotFound if nobody on this panel has that Steam account linked.
func (db *DB) GetUserBySteamID(ctx context.Context, steamID string) (*UserRow, error) {
	var u UserRow
	err := scanSteamAware(db.pool.QueryRow(ctx, `
		SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at
		FROM users WHERE steam_id = $1
	`, steamID), &u)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, ErrUserNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get user by steam: %w", err)
	}
	return &u, nil
}

// GetFirstAdmin returns the earliest-created admin user, or ErrUserNotFound
// if no admin exists. Used by --initial-admin-steam-id to locate the
// account to link a Steam ID to when no explicit target is specified.
func (db *DB) GetFirstAdmin(ctx context.Context) (*UserRow, error) {
	var u UserRow
	err := scanSteamAware(db.pool.QueryRow(ctx, `
		SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at
		FROM users WHERE role = 'admin' AND disabled = FALSE
		ORDER BY created_at ASC
		LIMIT 1
	`), &u)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, ErrUserNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get first admin: %w", err)
	}
	return &u, nil
}

// LinkSteamIDToUser sets/replaces the steam_id on an existing user.
// If another user already owns that Steam ID, returns an error (the
// unique constraint enforces one-user-per-Steam-account).
func (db *DB) LinkSteamIDToUser(ctx context.Context, userID int64, steamID string) error {
	_, err := db.pool.Exec(ctx, `
		UPDATE users SET steam_id = $2, updated_at = NOW() WHERE id = $1
	`, userID, steamID)
	if err != nil {
		return fmt.Errorf("link steam id: %w", err)
	}
	return nil
}

// UnlinkSteamID nulls the steam_id on a user. Operator pulled the
// Steam link from the panel UI.
func (db *DB) UnlinkSteamID(ctx context.Context, userID int64) error {
	_, err := db.pool.Exec(ctx, `UPDATE users SET steam_id = NULL, updated_at = NOW() WHERE id = $1`, userID)
	if err != nil {
		return fmt.Errorf("unlink steam id: %w", err)
	}
	return nil
}

// ListUsers returns every user. Admin-only at the API surface; this
// helper has no auth check itself.
func (db *DB) ListUsers(ctx context.Context) ([]UserRow, error) {
	rows, err := db.pool.Query(ctx, `
		SELECT id, email, COALESCE(password_hash, ''), steam_id, role, disabled, created_at, updated_at
		FROM users ORDER BY created_at ASC
	`)
	if err != nil {
		return nil, fmt.Errorf("list users: %w", err)
	}
	defer rows.Close()
	var out []UserRow
	for rows.Next() {
		var u UserRow
		var sid *string
		if err := rows.Scan(&u.ID, &u.Email, &u.PasswordHash, &sid, &u.Role, &u.Disabled, &u.CreatedAt, &u.UpdatedAt); err != nil {
			return nil, fmt.Errorf("scan user: %w", err)
		}
		if sid != nil {
			u.SteamID = *sid
		}
		out = append(out, u)
	}
	return out, rows.Err()
}

func (db *DB) UpdateUserPassword(ctx context.Context, id int64, passwordHash string) error {
	_, err := db.pool.Exec(ctx, `
		UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1
	`, id, passwordHash)
	if err != nil {
		return fmt.Errorf("update user password: %w", err)
	}
	return nil
}

func (db *DB) CreateSession(ctx context.Context, token string, userID int64, expiresAt time.Time, ip string) error {
	_, err := db.pool.Exec(ctx, `
		INSERT INTO sessions (token, user_id, expires_at, last_seen_ip)
		VALUES ($1, $2, $3, $4)
	`, token, userID, expiresAt, ip)
	if err != nil {
		return fmt.Errorf("create session: %w", err)
	}
	return nil
}

// GetSessionUser returns the user associated with a valid, non-expired
// session token. It also bumps last_seen_at so we can show active sessions
// in a future admin UI.
func (db *DB) GetSessionUser(ctx context.Context, token, ip string) (*UserRow, error) {
	var u UserRow
	err := db.pool.QueryRow(ctx, `
		UPDATE sessions SET last_seen_at = NOW(), last_seen_ip = $2
		WHERE token = $1 AND expires_at > NOW()
		RETURNING user_id
	`, token, ip).Scan(&u.ID)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, ErrSessionNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get session: %w", err)
	}
	return db.GetUserByID(ctx, u.ID)
}

func (db *DB) DeleteSession(ctx context.Context, token string) error {
	_, err := db.pool.Exec(ctx, `DELETE FROM sessions WHERE token = $1`, token)
	if err != nil {
		return fmt.Errorf("delete session: %w", err)
	}
	return nil
}
