package migrations

import (
	"fmt"

	"github.com/pocketbase/pocketbase/core"

	"git.vakhrushev.me/av/transcriber/internal/entity"
)

// up202608140002 перестраивает модель вокруг аудиозаписи.
//
// Прежняя коллекция задач уходит целиком: сервис на сервере остановлен, а
// прежние данные удалены решением владельца 2026-08-14 — переноса эта работа не
// делает, и оставленная пустая коллекция висела бы в панели вторым домом для
// того же понятия.
//
// Порядок заведения задан связями, а не вкусом: приложения ссылаются на запись,
// а запись — на них, поэтому запись заводится первой без обратных ссылок, потом
// приложения, и только потом ссылки дописываются.
//
// Правила доступа у новых коллекций остаются **незаданными**, то есть «только
// владелец панели». Содержимое записи отдаёт собственный адрес сервиса, а не
// поверхность хранилища; непустое правило открыло бы перечисление коллекции
// впрок, а норма проекта велит держать эту поверхность закрытой.
func up202608140002(app core.App) error {
	users, err := app.FindCollectionByNameOrId(UsersCollection)
	if err != nil {
		return fmt.Errorf("failed to find users collection: %w", err)
	}

	files, err := app.FindCollectionByNameOrId(FilesCollection)
	if err != nil {
		return fmt.Errorf("failed to find files collection: %w", err)
	}

	// Формат и длительность у копии: по ним видно, чем запись была, не открывая
	// её. Расширение наружу выходит только приведённым к перечню известных.
	files.Fields.Add(
		&core.TextField{Name: "format"},
		&core.NumberField{Name: "duration_ms", OnlyInt: true},
	)
	if err := app.Save(files); err != nil {
		return fmt.Errorf("failed to extend files: %w", err)
	}

	topics, err := createTopics(app, users.Id)
	if err != nil {
		return err
	}

	records, err := createAudioRecords(app, users.Id, files.Id, topics.Id)
	if err != nil {
		return err
	}

	texts, err := createTexts(app, records.Id)
	if err != nil {
		return err
	}

	structures, err := createStructures(app, records.Id)
	if err != nil {
		return err
	}

	recognitions, err := createRecognitions(app, records.Id)
	if err != nil {
		return err
	}

	if err := createRecordEvents(app, records.Id); err != nil {
		return err
	}

	// Обратные ссылки дописываются последними: раньше коллекций-целей ещё нет.
	records.Fields.Add(
		&core.RelationField{Name: "transcript_text", CollectionId: texts.Id, MaxSelect: 1},
		&core.RelationField{Name: "literary_text", CollectionId: texts.Id, MaxSelect: 1},
		&core.RelationField{Name: "structure", CollectionId: structures.Id, MaxSelect: 1},
		&core.RelationField{Name: "recognition", CollectionId: recognitions.Id, MaxSelect: 1},
	)
	if err := app.Save(records); err != nil {
		return fmt.Errorf("failed to link audio records to their appendices: %w", err)
	}

	jobs, err := app.FindCollectionByNameOrId(JobsCollection)
	if err != nil {
		return fmt.Errorf("failed to find jobs collection: %w", err)
	}
	if err := app.Delete(jobs); err != nil {
		return fmt.Errorf("failed to drop the former jobs collection: %w", err)
	}

	return nil
}

// createAudioRecords заводит центральную сущность.
//
// Ссылки на файлы две и порознь: шаг конвейера больше не переставляет одну на
// свой результат, и исходник остаётся доступным после того, как запись прошла
// конвейер.
func createAudioRecords(app core.App, usersID, filesID, topicsID string) (*core.Collection, error) {
	records := core.NewBaseCollection(RecordsCollection)
	records.Fields.Add(
		ownerField(usersID),
		&core.SelectField{
			Name:      "source",
			Values:    []string{entity.SourceUnknown, entity.SourceApi, entity.SourceTelegram},
			MaxSelect: 1,
			Required:  true,
		},
		// Заголовок и краткое описание читаются вместе со списком, сотней штук
		// разом, и потому лежат колонками записи, а не строками текстов.
		&core.TextField{Name: "title"},
		&core.TextField{Name: "brief"},
		// Перечень рубежей закрыт схемой: запись, заведённая в панели руками, не
		// должна попасть в выборку с рубежом, которого конвейер не знает.
		&core.SelectField{
			Name:      "state",
			Values:    entity.AllStates(),
			MaxSelect: 1,
			Required:  true,
		},
		// Время входа в рубеж — сторож застревания. Ставится только сменой рубежа
		// и возвратом записи в работу; откладывание опроса его не двигает.
		&core.DateField{Name: "state_entered_at"},
		// Остановка — признак, а не рубеж: `state` при ней не стирается, и снятие
		// признака продолжает работу с места остановки.
		&core.DateField{Name: "halted_at"},
		&core.SelectField{
			Name:      "halt_reason",
			Values:    entity.AllHaltReasons(),
			MaxSelect: 1,
		},
		&core.TextField{Name: "error_text"},
		// Признак **этого** захвата: значение уникально для каждого захвата, и
		// запись результата условна по нему, а не по занятости записи.
		&core.TextField{Name: "acquisition_id"},
		// Срок протухания захвата приезжает с рубежом и пишется числом при самом
		// захвате: воркер не привязан к шагу и вывести срок из себя не может.
		&core.DateField{Name: "acquire_expires_at"},
		&core.DateField{Name: "delay_time"},
		// Число отказов ограничивает повторы внутри шага. Время в рубеже мерит
		// отдельный сторож: одно число не справлялось ни с одной из обязанностей.
		&core.NumberField{Name: "attempts", OnlyInt: true, Min: ptr(0.0)},
		&core.RelationField{Name: "original_file", CollectionId: filesID, MaxSelect: 1},
		&core.RelationField{Name: "normalized_file", CollectionId: filesID, MaxSelect: 1},
		&core.RelationField{
			Name:         "topics",
			CollectionId: topicsID,
			MaxSelect:    entity.MaxTopicsPerRecord,
		},
		&core.NumberField{Name: "tg_chat_id", OnlyInt: true},
		&core.NumberField{Name: "tg_reply_message_id", OnlyInt: true},
		&core.AutodateField{Name: "created", OnCreate: true},
		&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
	)

	// Отбор захвата идёт по рубежу, признаку остановки, паузе и сроку протухания
	// захвата — индекс снимает полный перебор.
	records.AddIndex("idx_audio_records_state", false, "state, halted_at", "")

	if err := app.Save(records); err != nil {
		return nil, fmt.Errorf("failed to create audio records: %w", err)
	}
	return records, nil
}

// createTexts заводит тексты записи. Пара «запись и вид» уникальна: повтор
// прерванного шага иначе завёл бы второй комплект строк, и вопрос «какой текст
// отдавать человеку» стал бы вопросом порядка записи, а не состояния.
func createTexts(app core.App, recordsID string) (*core.Collection, error) {
	texts := core.NewBaseCollection(TextsCollection)
	texts.Fields.Add(
		&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
		&core.SelectField{
			Name:      "kind",
			Values:    entity.AllTextKinds(),
			MaxSelect: 1,
			Required:  true,
		},
		// Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме
		// зовут формат файла, и третий смысл у одного слова развёл бы по разным
		// вещам вид текста и формат копии.
		&core.EditorField{Name: "contents"},
		&core.AutodateField{Name: "created", OnCreate: true},
		&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
	)
	texts.AddIndex("idx_texts_record_kind", true, "record, kind", "")

	if err := app.Save(texts); err != nil {
		return nil, fmt.Errorf("failed to create texts: %w", err)
	}
	return texts, nil
}

// createStructures заводит структуру реплик. Номер версии нужен потому, что
// разбор сохранённого ответа изменится раньше, чем архив пересчитают.
func createStructures(app core.App, recordsID string) (*core.Collection, error) {
	structures := core.NewBaseCollection(StructuresCollection)
	structures.Fields.Add(
		&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
		&core.NumberField{Name: "version", OnlyInt: true, Required: true},
		&core.JSONField{Name: "contents", MaxSize: structureContentsMaxSize},
		&core.AutodateField{Name: "created", OnCreate: true},
		&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
	)
	structures.AddIndex("idx_structures_record_version", true, "record, version", "")

	if err := app.Save(structures); err != nil {
		return nil, fmt.Errorf("failed to create structures: %w", err)
	}
	return structures, nil
}

// createRecognitions заводит попытку распознавания у внешнего провайдера.
//
// Сырой ответ лежит **вложением**, а не колонкой: шаг опроса читает эту строку
// раз в несколько секунд, а хранилище читает запись целиком — ответ на
// многочасовую запись ехал бы в память при каждом опросе.
//
// Поле вложения помечено защищённым: сырой ответ это полный текст речи, и
// умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.
func createRecognitions(app core.App, recordsID string) (*core.Collection, error) {
	recognitions := core.NewBaseCollection(RecognitionsCollection)
	payload := &core.FileField{Name: "payload", MaxSelect: 1, MaxSize: recognitionPayloadMaxSize}
	payload.Protected = true

	recognitions.Fields.Add(
		&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
		&core.TextField{Name: "provider", Required: true},
		&core.TextField{Name: "model"},
		// Идентификатор операции у провайдера — самое провайдерское, что есть в
		// модели, и живёт он здесь, а не колонкой записи.
		&core.TextField{Name: "external_id"},
		// Адрес, по которому провайдер читает аудио. Копия во внешнем хранилище
		// файлом записи не считается: другой провайдер её не потребует.
		&core.TextField{Name: "source_uri"},
		payload,
		&core.DateField{Name: "started_at"},
		&core.DateField{Name: "finished_at"},
		&core.AutodateField{Name: "created", OnCreate: true},
		&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
	)

	if err := app.Save(recognitions); err != nil {
		return nil, fmt.Errorf("failed to create recognitions: %w", err)
	}
	return recognitions, nil
}

// createRecordEvents заводит журнал событий записи.
//
// Колонка текста отказа зовётся `outcome_text`, а не `error_text`: последнее имя
// названо поимённо инвариантом проекта о секрете, и две колонки с этим именем
// сделали бы инвариант двусмысленным.
func createRecordEvents(app core.App, recordsID string) error {
	events := core.NewBaseCollection(RecordEventsCollection)
	events.Fields.Add(
		&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
		&core.SelectField{
			Name:      "origin",
			Values:    entity.AllEventOrigins(),
			MaxSelect: 1,
			Required:  true,
		},
		&core.TextField{Name: "step"},
		&core.SelectField{
			Name:      "outcome",
			Values:    entity.AllEventOutcomes(),
			MaxSelect: 1,
			Required:  true,
		},
		&core.TextField{Name: "outcome_text"},
		&core.NumberField{Name: "duration_ms", OnlyInt: true},
		&core.AutodateField{Name: "created", OnCreate: true},
	)
	events.AddIndex("idx_record_events_record", false, "record", "")

	if err := app.Save(events); err != nil {
		return fmt.Errorf("failed to create record events: %w", err)
	}
	return nil
}

// createTopics заводит словарь тем. Тема уникальна в паре «владелец и название»:
// словарь свой у каждого человека, и общий показал бы одному темы другого.
func createTopics(app core.App, usersID string) (*core.Collection, error) {
	topics := core.NewBaseCollection(TopicsCollection)
	topics.Fields.Add(
		&core.RelationField{
			Name:         "owner",
			CollectionId: usersID,
			MaxSelect:    1,
			Required:     true,
		},
		&core.TextField{Name: "name", Required: true},
		&core.AutodateField{Name: "created", OnCreate: true},
		&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
	)
	topics.AddIndex("idx_topics_owner_name", true, "owner, name", "")

	if err := app.Save(topics); err != nil {
		return nil, fmt.Errorf("failed to create topics: %w", err)
	}
	return topics, nil
}

const (
	// structureContentsMaxSize — потолок разбитой на реплики расшифровки. Число
	// с запасом: шестичасовой разговор даёт порядка мегабайта текста с временем.
	structureContentsMaxSize = 16 << 20
	// recognitionPayloadMaxSize — потолок сохранённого ответа провайдера. Он
	// многословнее самой расшифровки: несёт альтернативы, время каждого слова и
	// разбор говорящих.
	recognitionPayloadMaxSize = 256 << 20
)

// Поля объявляются россыпью, а не помощником, который принимал бы имя доводом:
// сверка перечня колонок со схемой читает литерал `Name:` в шагах, и имя,
// спрятанное за вызовом, она не видит — колонка выпала бы из-под правила молча.

// down202608140002 снимает новые коллекции. Прежнюю коллекцию задач он не
// восстанавливает: данных под ней не было, а пустая копия прежней схемы была бы
// вторым домом для понятия, которого больше нет.
func down202608140002(app core.App) error {
	// Порядок обратный порядку заведения: приложения ссылаются на запись.
	order := []string{
		RecordEventsCollection,
		RecognitionsCollection,
		StructuresCollection,
		TextsCollection,
		RecordsCollection,
		TopicsCollection,
	}
	for _, name := range order {
		collection, err := app.FindCollectionByNameOrId(name)
		if err != nil {
			continue
		}
		if err := app.Delete(collection); err != nil {
			return fmt.Errorf("failed to drop %s: %w", name, err)
		}
	}
	return nil
}
