package sqlite

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

	"git.vakhrushev.me/av/transcriber/internal/clock"
	"git.vakhrushev.me/av/transcriber/internal/contract"
	"git.vakhrushev.me/av/transcriber/internal/entity"
	"git.vakhrushev.me/av/transcriber/internal/ident"
)

// UserRepository — учётные записи сервиса.
type UserRepository struct {
	db *DB
}

func NewUserRepository(db *DB) *UserRepository {
	return &UserRepository{db: db}
}

// EnsureUser находит учётную запись по логину у провайдера, а не найдя — заводит
// её.
//
// **Дом правила один, и он здесь, а не в транспорте.** Второй способ
// представиться — личные токены — возьмёт этот же метод; правило, уложенное
// куском в слой транспорта, пришлось бы тогда либо дублировать вторым куском,
// либо вытаскивать задним числом.
//
// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью
// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди
// его работы.
//
// **Поиск идёт читающим пулом, и пишущая транзакция открывается только тогда,
// когда запись не нашлась.** Узнавание одето на весь корень приложения, поэтому
// пишущая транзакция, взятая до поиска, доставалась бы всему узнанному потоку —
// опросу карточки и каждому запросу диапазона при проигрывании, — и вставала бы
// в очередь к единственному пишущему соединению. Ждать там нечего: заводится
// учётная запись один раз за жизнь человека.
//
// **Окно между двумя соединениями закрыто повторным поиском внутри
// транзакции.** Между поиском читающим пулом и открытием пишущей транзакции
// запись успевает завести сосед; ветвь ниже находит её и берёт заведённую, а
// уникальность ключа держит схема — не порядок обращений.
func (repo *UserRepository) EnsureUser(identity contract.Identity) (*contract.UserAccount, bool, error) {
	login, ok := entity.AcceptProviderLogin(identity.Login)
	if !ok {
		return nil, false, contract.ErrLoginNotAcceptable
	}

	account, err := findUserByLogin(repo.db.Reader(), login)
	if err != nil {
		return nil, false, err
	}
	if account != nil {
		return account, false, nil
	}

	tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
	if err != nil {
		return nil, false, fmt.Errorf("failed to open a transaction for the user account: %w", err)
	}
	defer func() { _ = tx.Rollback() }()

	// Повторный поиск закрывает окно между читающим пулом и пишущей
	// транзакцией: пока её ждали, запись мог завести сосед.
	account, err = findUserByLogin(tx, login)
	if err != nil {
		return nil, false, err
	}
	if account != nil {
		return account, false, commitAccount(tx, account)
	}

	name := entity.AcceptDisplayName(identity.Name)
	email, _ := entity.AcceptEmail(identity.Email)

	account, err = insertUser(tx, login, name, email)
	switch {
	case err == nil:
		return account, true, commitAccount(tx, account)
	case !isUniqueViolation(err):
		return nil, false, fmt.Errorf("failed to create user account: %w", err)
	}

	// **Два отказа уникальности различаются, и исход у них разный**, а какая
	// колонка не сошлась, код отказа не называет. Различает их повторный поиск
	// по ключу: нашёлся — это гонка двух первых обращений одним логином, и надо
	// просто взять заведённую соседом запись.
	account, err = findUserByLogin(tx, login)
	if err != nil {
		return nil, false, err
	}
	if account != nil {
		return account, false, commitAccount(tx, account)
	}

	// Не нашёлся — значит не сошлась другая колонка: адрес почты, пришедший от
	// провайдера, занят другой учётной записью (общий ящик, семья, группа).
	// Запись заводится **без почты**: она необязательна и ключом не служит. Без
	// этого разреза второй человек с общим адресом не завёлся бы никогда —
	// повторный поиск по логину снова ничего не находит.
	account, err = insertUser(tx, login, name, "")
	if err != nil {
		return nil, false, fmt.Errorf("failed to create user account without email: %w", err)
	}

	return account, true, commitAccount(tx, account)
}

func commitAccount(tx *sql.Tx, account *contract.UserAccount) error {
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("failed to commit the user account %s: %w", account.ID, err)
	}
	return nil
}

func insertUser(tx *sql.Tx, login, name, email string) (*contract.UserAccount, error) {
	id := ident.New()
	now := formatTime(clock.Now())

	_, err := tx.ExecContext(context.Background(),
		`INSERT INTO users (id, provider_login, name, email, created_at, updated_at)
		 VALUES (?, ?, ?, ?, ?, ?)`,
		id, login, name, email, now, now,
	)
	if err != nil {
		return nil, err
	}

	return &contract.UserAccount{ID: id, Name: name}, nil
}

// rowQuerier — то общее, чем поиск учётной записи пользуется у читающего пула и
// у пишущей транзакции. Оба поиска — до транзакции и внутри неё — идут одним
// запросом: второй его копией они разошлись бы молча.
type rowQuerier interface {
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

// findUserByLogin ищет учётную запись по ключу. Значение уходит базе
// **параметром** запроса, а не подстановкой в текст: строка приходит снаружи, и
// подставленная в текст она правила бы сам запрос, а не только его аргумент.
func findUserByLogin(q rowQuerier, login string) (*contract.UserAccount, error) {
	account := &contract.UserAccount{}
	err := q.QueryRowContext(context.Background(),
		"SELECT id, name FROM users WHERE provider_login = ?", login,
	).Scan(&account.ID, &account.Name)
	switch {
	case err == nil:
		return account, nil
	case errors.Is(err, sql.ErrNoRows):
		return nil, nil
	default:
		return nil, fmt.Errorf("failed to look up user account: %w", err)
	}
}
