package entity

import (
	"strings"
	"time"
	"unicode"

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

// Рубежи конвейера. Рубеж называет **достигнутое**, а не предстоящее: по нему
// видно, что с записью уже сделано, и потому остановленная запись продолжает с
// места остановки, а не с начала.
//
// Конечный рубеж зовётся `done`: доставка ответа отправителю в конвейер не
// входит, и слово описывает пройденный конвейер, а не полученный человеком
// текст.
const (
	StateUploaded    = "uploaded"
	StateNormalized  = "normalized"
	StateSubmitted   = "submitted"
	StateTranscribed = "transcribed"
	StateDone        = "done"
)

// Причины остановки. Прежние состояния `failed` и `dead` схлопнуты сюда: обе
// восстанавливаются одинаково — снятием признака, — и различие между ними
// перестало быть структурным.
const (
	// HaltReasonStepFailed — шаг рассудил об этой записи окончательно.
	HaltReasonStepFailed = "step_failed"
	// HaltReasonAttempts — мы повторяли и перестали.
	HaltReasonAttempts = "attempts_exhausted"
	// HaltReasonStuck — запись простояла в рубеже дольше предела.
	HaltReasonStuck = "stuck"
)

// AudioRecord — аудиозапись, центральная сущность сервиса.
//
// Приложения к ней — файлы, тексты, структура реплик, темы, журнал событий и
// попытка распознавания — живут своими строками и адресуются ссылками. Поля
// очереди соседствуют с доменом, но не с содержимым: расшифровка лежит строкой
// `texts`, и чтение очереди её не тянет.
type AudioRecord struct {
	Id string
	// OwnerID — учётная запись, от имени которой запись принята. Обязателен:
	// колонка владельца пустого значения не принимает, и ничьей записи в
	// хранилище не бывает. Назначается один раз, при приёме, и конвейером не
	// меняется.
	OwnerID string

	// Title и Brief читаются вместе со списком, сотней штук разом, и потому
	// лежат колонками записи, а не строками `texts`.
	Title *string
	Brief *string

	// OriginalFilename — имя файла, данное отправителем. Лежит **отдельно от
	// заголовка**: заголовок несёт название, которое дал человек либо посчитала
	// языковая модель, а имя файла — то, по чему человек узнаёт свою запись, пока
	// заголовка нет. Одной колонкой на оба смысла посчитанное название затирало бы
	// имя, и вернуть затёртое было бы неоткуда.
	//
	// Значение приходит извне: приём режет его по MaxOriginalFilenameLen и убирает
	// управляющие знаки. В имя файла хранилища и в журнал оно не идёт — инвариант
	// приватности.
	OriginalFilename *string

	// DurationMs и SizeBytes — величины **принятого**, снимок с момента приёма.
	// Со строкой файла они намеренно не сверяются: там лежат величины той копии,
	// которой файл является сейчас, и уточнение длительности меняет их, не трогая
	// эти. Нужны колонками записи, потому что показываются в списке.
	//
	// Указатели здесь не выражают «неизвестно»: числовая колонка хранилища
	// пустого значения не держит, и пустое кладётся нулём. Обе величины ставит
	// приём и ставит всегда — запись с непрочитанными метаданными отвергается
	// отказом и не заводится вовсе. Решение владельца 2026-08-15.
	DurationMs *int64
	SizeBytes  *int64

	// TopicIDs — темы записи. Ни приём, ни конвейер их не пишут: место заведено
	// вперёд, заполняет его задача, считающая темы языковой моделью.
	TopicIDs []string

	State string
	// StateEnteredAt ставится только сменой рубежа и возвратом записи в работу.
	// Откладывание опроса его не двигает — иначе застревание в чужой операции
	// не наступало бы никогда.
	StateEnteredAt time.Time

	// Остановка — признак, а не рубеж: `State` при ней не стирается.
	HaltedAt   *time.Time
	HaltReason *string
	ErrorText  *string

	// AcquisitionID — признак **этого** захвата, значение уникальное для каждого.
	// Запись результата условна по нему, а не по занятости записи: захват,
	// перевыданный другому — по протуханию срока или после снятия остановки
	// человеком, — обязан обратить запись первого в отказ.
	AcquisitionID    *string
	AcquireExpiresAt *time.Time
	DelayTime        *time.Time
	// Attempts считает **отказы** и ограничивает повторы внутри шага. Время в
	// рубеже мерит StateEnteredAt: одно число не справлялось ни с одной из двух
	// обязанностей.
	Attempts int

	// Ссылки на файлы живут порознь и не переставляются: исходник остаётся
	// доступным после того, как запись прошла конвейер.
	OriginalFileID   *string
	NormalizedFileID *string

	StructureID      *string
	TranscriptTextID *string
	LiteraryTextID   *string
	RecognitionID    *string

	CreatedAt time.Time
	UpdatedAt time.Time
}

// MaxOriginalFilenameLen — потолок длины имени файла, данного отправителем.
//
// Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому длина
// назначается сервисом. Число выведено из предела длины имени в распространённых
// файловых системах: имя длиннее 255 знаков не приходит от системного диалога
// выбора файла вовсе, и всё, что длиннее, — либо самодельный запрос, либо
// попытка раздуть строку записи.
const MaxOriginalFilenameLen = 255

// SanitizeOriginalFilename приводит имя, данное отправителем, к пригодному для
// хранения виду: убирает управляющие знаки и режет по потолку длины.
//
// Живёт в домене, а не в транспорте: имя доходит до колонки записи одним путём,
// и правило чистки обязано быть одно. Управляющие знаки убираются потому, что
// иначе доезжают до экрана; резка идёт **после** уборки,
// иначе потолок съедали бы знаки, которых в сохранённом имени всё равно не будет.
//
// Режется по знакам, а не по байтам: имя русское чаще, чем латинское, и обрезка
// по байтам разрубила бы знак пополам.
func SanitizeOriginalFilename(name string) string {
	cleaned := strings.Map(func(r rune) rune {
		if unicode.IsControl(r) {
			return -1
		}
		return r
	}, name)

	runes := []rune(cleaned)
	if len(runes) > MaxOriginalFilenameLen {
		runes = runes[:MaxOriginalFilenameLen]
	}
	return string(runes)
}

// MoveToState двигает запись на новый рубеж и чистит служебные поля прошлого.
//
// Время входа в рубеж ставится заново: с этой минуты идёт отсчёт застревания.
// Число отказов обнуляется — шаг, дошедший до перехода, завершился без отказа, а
// отказы считают именно отказавшие: иначе запись, прошедшая конвейер целиком,
// накопила бы их поштучно и остановилась бы здоровой.
func (r *AudioRecord) MoveToState(state string) {
	now := clock.Now()
	r.State = state
	r.StateEnteredAt = now
	r.DelayTime = nil
	r.AcquisitionID = nil
	r.AcquireExpiresAt = nil
	r.Attempts = 0
	r.UpdatedAt = now
}

// Postpone откладывает работу над записью: ставит паузу и снимает захват.
//
// Переходом это не является и потому не трогает ни рубеж, ни время входа в
// него. Число отказов обнуляется по прежнему доводу — ожидание чужой операции
// отказом не является.
//
// Прежде шаг опроса звал переход с **тем же** состоянием, и мнимость этого
// перехода обнуляла сторожа. Без разделения время входа в рубеж сбрасывалось бы
// на каждом опросе и повторило бы ровно тот промах, ради которого заводится.
func (r *AudioRecord) Postpone(until time.Time) {
	r.DelayTime = &until
	r.AcquisitionID = nil
	r.AcquireExpiresAt = nil
	r.Attempts = 0
	r.UpdatedAt = clock.Now()
}

// RetryAfter освобождает отказавшую запись для повтора: захват снимается, пауза
// ставится, а число отказов сохраняется — по нему растёт пауза и наступает
// предел.
func (r *AudioRecord) RetryAfter(delay time.Time) {
	r.AcquisitionID = nil
	r.AcquireExpiresAt = nil
	r.DelayTime = &delay
	r.UpdatedAt = clock.Now()
}

// Halt останавливает запись признаком, сохраняя достигнутый рубеж.
//
// Число отказов сохраняется: по нему видно, сколько раз пробовали. Захват
// снимается — остановленная запись всё равно не выдаётся, а оставленный признак
// захвата помешал бы первому же захвату после снятия остановки.
func (r *AudioRecord) Halt(reason, errText string) {
	now := clock.Now()
	r.HaltedAt = &now
	r.HaltReason = &reason
	r.ErrorText = &errText
	r.AcquisitionID = nil
	r.AcquireExpiresAt = nil
	r.DelayTime = nil
	r.UpdatedAt = now
}

// Resume возвращает остановленную запись в работу с сохранённого рубежа.
//
// Сбрасываются все три сторожа. Время входа в рубеж — тоже, и это не
// избыточность: запись, простоявшая остановленной дольше предела, иначе
// останавливалась бы снова первым же захватом, и перезапуск не работал бы вовсе.
func (r *AudioRecord) Resume() {
	now := clock.Now()
	r.HaltedAt = nil
	r.HaltReason = nil
	r.ErrorText = nil
	r.Attempts = 0
	r.DelayTime = nil
	r.AcquisitionID = nil
	r.AcquireExpiresAt = nil
	r.StateEnteredAt = now
	r.UpdatedAt = now
}

// IsHalted — стоит ли на записи признак остановки.
func (r *AudioRecord) IsHalted() bool {
	return r.HaltedAt != nil
}
