package pocketbase

import (
	"fmt"

	"github.com/pocketbase/dbx"
	"github.com/pocketbase/pocketbase/core"

	"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
	"git.vakhrushev.me/av/transcriber/internal/contract"
	"git.vakhrushev.me/av/transcriber/internal/entity"
)

// defaultListLimit — умолчание, к которому приводится непозитивный предел.
// Значение своё, а не занятое у транспорта: адаптер о транспорте не знает.
const defaultListLimit = 30

// List отдаёт страницу записей владельца, новыми сверху.
//
// **Страница берётся ключом, а не смещением.** Приём пишет в голову той же
// ленты, которую читает список, и человек, загрузивший запись и листающий свой
// архив, — штатный сценарий. Смещение сдвинуло бы окно на единицу: последний
// элемент первой страницы пришёл бы вторым разом первым элементом второй, а один
// элемент между ними не пришёл бы никогда — и оба раза молча.
//
// Ключ полный: пара «время заведения и идентификатор». Одного времени мало — у
// записей, принятых одним запросом, оно совпадает, и порядок между ними иначе не
// определён вовсе.
//
// Ни расшифровки, ни структуры реплик выборка не читает: обе лежат порознь от
// записи ровно затем, чтобы список их не тянул. Длительность и размер берутся
// колонками самой записи.
func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.RecordPage, error) {
	// Пустой владелец не совпадает ни с одной записью. Правило записано со
	// стороны спрашивающего: обязательность, которую держит одна лишь схема,
	// пустую строку пропустила бы.
	if q.OwnerID == "" {
		return &contract.RecordPage{Items: []*entity.AudioRecord{}}, nil
	}

	// Непозитивный предел приводится к умолчанию, а не роняет процесс: ниже
	// стоит обращение по индексу `q.Limit-1`, и нулевой предел дал бы индекс −1.
	// Сегодня отсекает его обработчик, но метод — часть интерфейса, и второй
	// вызывающий с забытым полем структуры получил бы панику, а восстановления у
	// воркеров нет вовсе.
	if q.Limit <= 0 {
		q.Limit = defaultListLimit
	}

	collection, err := findCollection(repo.app, migrations.RecordsCollection)
	if err != nil {
		return nil, err
	}

	filter := dbx.HashExp{"owner": q.OwnerID}
	conditions := []dbx.Expression{filter}

	state, err := stateCondition(q.Filter)
	if err != nil {
		return nil, err
	}
	if state != nil {
		conditions = append(conditions, state)
	}

	total, err := repo.countRecords(conditions)
	if err != nil {
		return nil, err
	}

	// Курсор режет ленту по паре: строго раньше по времени, а при равном времени
	// — строго меньше по идентификатору. Идентификаторы хранилища монотонны в
	// пределах одной миллисекунды не всегда, но сравнение по ним устойчиво, и
	// этого довольно: задача ключа — не пропустить и не повторить.
	if q.Cursor != nil {
		conditions = append(conditions, dbx.Or(
			dbx.NewExp("created < {:created}", dbx.Params{"created": q.Cursor.CreatedAt}),
			dbx.And(
				dbx.NewExp("created = {:created}", dbx.Params{"created": q.Cursor.CreatedAt}),
				dbx.NewExp("id < {:id}", dbx.Params{"id": q.Cursor.ID}),
			),
		))
	}

	// Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли
	// следующая страница» без второго запроса и без вычислений по общему числу,
	// которое к этому моменту могло измениться.
	records := []*core.Record{}
	err = repo.app.RecordQuery(collection).
		AndWhere(dbx.And(conditions...)).
		OrderBy("created DESC", "id DESC").
		Limit(int64(q.Limit) + 1).
		All(&records)
	if err != nil {
		return nil, fmt.Errorf("failed to list audio records: %w", err)
	}

	page := &contract.RecordPage{TotalItems: total}
	if len(records) > q.Limit {
		last := records[q.Limit-1]
		page.NextCursor = &contract.RecordCursor{
			CreatedAt: last.GetDateTime("created").String(),
			ID:        last.Id,
		}
		records = records[:q.Limit]
	}

	page.Items = make([]*entity.AudioRecord, 0, len(records))
	for _, record := range records {
		page.Items = append(page.Items, recordToAudioRecord(record))
	}

	return page, nil
}

// stateCondition переводит состояние отбора в условие запроса.
//
// Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор
// списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе
// молча поменял бы состав всех трёх состояний.
func stateCondition(filter *entity.ListFilter) (dbx.Expression, error) {
	if filter == nil {
		return nil, nil
	}

	notHalted := dbx.NewExp("halted_at = ''")
	halted := dbx.NewExp("halted_at != ''")

	switch *filter {
	case entity.ListFilterHalted:
		return halted, nil
	case entity.ListFilterWorking:
		return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.WorkingStages())...)), nil
	case entity.ListFilterDone:
		return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.TerminalStages())...)), nil
	}

	// Ветвь отказа, а не молчаливое «без сужения»: значение, добавленное в
	// перечень состояний и забытое здесь, иначе вернуло бы человеку весь архив
	// под именем отбора — и заметить это было бы нечем.
	return nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter)
}

func stageNameValues(stages []entity.Stage) []any {
	names := entity.StageNames(stages)
	out := make([]any, 0, len(names))
	for _, n := range names {
		out = append(out, n)
	}
	return out
}

func (repo *AudioRecordRepository) countRecords(conditions []dbx.Expression) (int, error) {
	var counter struct {
		Total int `db:"total"`
	}
	err := repo.app.RecordQuery(migrations.RecordsCollection).
		Select("count(*) as total").
		AndWhere(dbx.And(conditions...)).
		One(&counter)
	if err != nil {
		return 0, fmt.Errorf("failed to count audio records: %w", err)
	}
	return counter.Total, nil
}

// ResolveTopicNames разрешает темы названиями **одним запросом на страницу**, а
// не по запросу на запись: страница в сотню записей иначе стоила бы сотни
// обращений к хранилищу.
//
// Названия, а не идентификаторы, потому что экран показывает названия: отдай мы
// ссылки, форму ответа переделывала бы задача языковой модели — ровно то, ради
// чего контракт согласуется один раз.
func (repo *AudioRecordRepository) ResolveTopicNames(ownerID string, ids []string) (map[string]string, error) {
	out := map[string]string{}
	if len(ids) == 0 || ownerID == "" {
		return out, nil
	}

	values := make([]any, 0, len(ids))
	for _, id := range ids {
		values = append(values, id)
	}

	// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека —
	// пара «владелец и название» уникальна, — и разрешение без сужения отдало бы
	// название чужой темы, как только темы начнёт писать языковая модель.
	records := []*core.Record{}
	err := repo.app.RecordQuery(migrations.TopicsCollection).
		AndWhere(dbx.HashExp{"owner": ownerID}).
		AndWhere(dbx.In("id", values...)).
		All(&records)
	if err != nil {
		return nil, fmt.Errorf("failed to resolve topics: %w", err)
	}

	for _, record := range records {
		out[record.Id] = record.GetString("name")
	}
	return out, nil
}
