package store

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"strings"

	"git.vakhrushev.me/av/jellybit/internal/ident"
)

// Recognition — строка таблицы recognition (попытка распознавания).
type Recognition struct {
	ID            string          `db:"id"`
	DownloadID    string          `db:"download_id"`
	AttemptNo     int             `db:"attempt_no"`
	IsCurrent     bool            `db:"is_current"`
	MediaType     sql.NullString  `db:"media_type"`
	Title         sql.NullString  `db:"title"`
	OriginalTitle sql.NullString  `db:"original_title"`
	Year          sql.NullInt64   `db:"year"`
	Provider      sql.NullString  `db:"provider"`
	ProviderID    sql.NullString  `db:"provider_id"`
	Confidence    sql.NullFloat64 `db:"confidence"`
	Reasons       string          `db:"reasons"` // JSON-массив строк
	RawLLM        sql.NullString  `db:"raw_llm"`
	Plan          sql.NullString  `db:"plan"` // JSON recognize.Plan
	CreatedAt     string          `db:"created_at"`
}

// ReasonList разбирает JSON-поле reasons в срез строк.
func (r Recognition) ReasonList() []string {
	if r.Reasons == "" {
		return nil
	}
	var out []string
	_ = json.Unmarshal([]byte(r.Reasons), &out)
	return out
}

// CreateRecognition вставляет новую попытку распознавания, помечая прежние
// как неактуальные (is_current = 0) и проставляя следующий attempt_no.
// Возвращает id новой записи. reasons сериализуется в JSON.
func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons []string) (string, error) {
	reasonsJSON, err := json.Marshal(reasons)
	if err != nil {
		return "", fmt.Errorf("marshal reasons: %w", err)
	}

	tx, err := s.DB.BeginTxx(ctx, nil)
	if err != nil {
		return "", fmt.Errorf("begin tx: %w", err)
	}
	defer func() { _ = tx.Rollback() }()

	if _, err := tx.ExecContext(ctx,
		`UPDATE recognition SET is_current = 0 WHERE download_id = ?`, r.DownloadID); err != nil {
		return "", fmt.Errorf("clear current recognitions: %w", err)
	}

	var nextAttempt int
	if err := tx.GetContext(ctx, &nextAttempt,
		`SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM recognition WHERE download_id = ?`,
		r.DownloadID); err != nil {
		return "", fmt.Errorf("next attempt_no: %w", err)
	}

	r.ID = ident.NewID()
	const q = `
INSERT INTO recognition
  (id, download_id, attempt_no, is_current, media_type, title, original_title,
   year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at)
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
	if _, err := tx.ExecContext(ctx, q,
		r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
		r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan,
		FormatTime(Now())); err != nil {
		return "", fmt.Errorf("insert recognition: %w", err)
	}
	if err := tx.Commit(); err != nil {
		return "", fmt.Errorf("commit recognition: %w", err)
	}
	return r.ID, nil
}

// GetCurrentRecognition возвращает актуальную попытку распознавания загрузки
// либо (nil, nil), если её ещё нет.
func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID string) (*Recognition, error) {
	var r Recognition
	err := s.DB.GetContext(ctx, &r,
		`SELECT * FROM recognition WHERE download_id = ? AND is_current = 1
		 ORDER BY attempt_no DESC LIMIT 1`, downloadID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("get current recognition: %w", err)
	}
	return &r, nil
}

// --- Подсказки (hint) ---

// AddHint добавляет текстовую подсказку ревьюера к загрузке.
func (s *Store) AddHint(ctx context.Context, downloadID string, text string) error {
	if _, err := s.DB.ExecContext(ctx,
		`INSERT INTO hint (id, download_id, text, created_at) VALUES (?, ?, ?, ?)`,
		ident.NewID(), downloadID, text, FormatTime(Now())); err != nil {
		return fmt.Errorf("add hint: %w", err)
	}
	return nil
}

// ListHints возвращает подсказки загрузки в хронологическом порядке.
func (s *Store) ListHints(ctx context.Context, downloadID string) ([]string, error) {
	var out []string
	if err := s.DB.SelectContext(ctx, &out,
		`SELECT text FROM hint WHERE download_id = ? ORDER BY id`, downloadID); err != nil {
		return nil, fmt.Errorf("list hints: %w", err)
	}
	return out, nil
}

// --- Ручные правки (override) ---

// SetOverride пиннит значение поля (upsert по (download_id, field)).
func (s *Store) SetOverride(ctx context.Context, downloadID string, field, value string) error {
	const q = `
INSERT INTO override (id, download_id, field, value, created_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (download_id, field) DO UPDATE SET value = excluded.value`
	if _, err := s.DB.ExecContext(ctx, q, ident.NewID(), downloadID, field, value, FormatTime(Now())); err != nil {
		return fmt.Errorf("set override %q: %w", field, err)
	}
	return nil
}

// ListOverrides возвращает запиненные правки загрузки как map[field]value.
func (s *Store) ListOverrides(ctx context.Context, downloadID string) (map[string]string, error) {
	rows, err := s.DB.QueryxContext(ctx,
		`SELECT field, value FROM override WHERE download_id = ?`, downloadID)
	if err != nil {
		return nil, fmt.Errorf("list overrides: %w", err)
	}
	defer func() { _ = rows.Close() }()

	out := map[string]string{}
	for rows.Next() {
		var field, value string
		if err := rows.Scan(&field, &value); err != nil {
			return nil, fmt.Errorf("scan override: %w", err)
		}
		out[field] = value
	}
	return out, rows.Err()
}

// --- Ссылки файлов (file_link) ---

// FileLink — строка таблицы file_link (одна созданная/планируемая ссылка).
type FileLink struct {
	ID           string `db:"id"`
	DownloadID   string `db:"download_id"`
	ApplyBatchID string `db:"apply_batch_id"`
	SrcPath      string `db:"src_path"`
	DstPath      string `db:"dst_path"`
	Kind         string `db:"kind"`
	Status       string `db:"status"`
	Size         int64  `db:"size"` // размер файла (байт), зафиксирован при линковке
	CreatedAt    string `db:"created_at"`
}

// CreateFileLinks вставляет батч ссылок одной транзакцией.
func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error {
	if len(links) == 0 {
		return nil
	}
	tx, err := s.DB.BeginTxx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer func() { _ = tx.Rollback() }()

	now := FormatTime(Now())
	const q = `
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
	for _, l := range links {
		if _, err := tx.ExecContext(ctx, q,
			ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status, l.Size, now); err != nil {
			return fmt.Errorf("insert file_link: %w", err)
		}
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit file_links: %w", err)
	}
	return nil
}

// SupersedeForeignLinks помечает статусом superseded ссылки ДРУГИХ загрузок,
// разложенные по тем же dst_path, что и переданные. Реализует инвариант
// «один целевой путь — один владелец» (см. state-reconciliation): свежая
// раскладка забирает владение освободившимся путём, прежние записи перестают
// считаться целью при сверке. Затрагивает только активные статусы раскладки
// (linked/copied/exists) и не трогает саму загрузку (download_id != ?).
func (s *Store) SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error {
	if len(dstPaths) == 0 {
		return nil
	}
	ph := make([]string, len(dstPaths))
	args := make([]any, 0, len(dstPaths)+1)
	args = append(args, downloadID)
	for i, p := range dstPaths {
		ph[i] = "?"
		args = append(args, p)
	}
	q := fmt.Sprintf(`
UPDATE file_link SET status = 'superseded'
WHERE download_id != ?
  AND status IN ('linked', 'copied', 'exists')
  AND dst_path IN (%s)`, strings.Join(ph, ", "))
	if _, err := s.DB.ExecContext(ctx, q, args...); err != nil {
		return fmt.Errorf("supersede foreign links: %w", err)
	}
	return nil
}

// LatestBatchID возвращает apply_batch_id последнего применённого батча
// загрузки (для undo) либо пустую строку, если ссылок нет.
func (s *Store) LatestBatchID(ctx context.Context, downloadID string) (string, error) {
	var batch string
	err := s.DB.GetContext(ctx, &batch,
		`SELECT apply_batch_id FROM file_link WHERE download_id = ?
		 ORDER BY id DESC LIMIT 1`, downloadID)
	if errors.Is(err, sql.ErrNoRows) {
		return "", nil
	}
	if err != nil {
		return "", fmt.Errorf("latest batch id: %w", err)
	}
	return batch, nil
}

// ListFileLinksByBatch возвращает ссылки батча.
func (s *Store) ListFileLinksByBatch(ctx context.Context, batchID string) ([]FileLink, error) {
	var out []FileLink
	if err := s.DB.SelectContext(ctx, &out,
		`SELECT * FROM file_link WHERE apply_batch_id = ? ORDER BY id`, batchID); err != nil {
		return nil, fmt.Errorf("list file_links by batch: %w", err)
	}
	return out, nil
}

// LayoutSizeByDownload возвращает суммарный размер разложенных файлов по каждой
// из загрузок ids: карта download_id → сумма байт. Учитываются только активные
// статусы раскладки (linked/copied/exists); superseded-пути принадлежат уже
// другой загрузке. Каждый dst_path считается один раз (повторный apply даёт
// новую строку 'exists' с тем же путём) — сначала схлопываем строки по пути,
// потом суммируем, иначе размер задвоился бы. Служит фолбэком размера раздачи в
// карточке, когда торрента нет в живом снимке. Загрузки без разложенных файлов
// в карте отсутствуют (читатель берёт 0).
func (s *Store) LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error) {
	if len(ids) == 0 {
		return map[string]int64{}, nil
	}
	var args []any
	ph := placeholders(&args, ids)
	q := `SELECT download_id, SUM(size) AS total FROM (
	        SELECT download_id, dst_path, MAX(size) AS size FROM file_link
	        WHERE download_id IN (` + ph + `)
	          AND status IN ('linked', 'copied', 'exists')
	        GROUP BY download_id, dst_path
	      ) GROUP BY download_id`
	rows, err := s.DB.QueryxContext(ctx, q, args...)
	if err != nil {
		return nil, fmt.Errorf("layout size by download: %w", err)
	}
	defer func() { _ = rows.Close() }()

	out := make(map[string]int64, len(ids))
	for rows.Next() {
		var id string
		var total int64
		if err := rows.Scan(&id, &total); err != nil {
			return nil, fmt.Errorf("layout size by download: scan: %w", err)
		}
		out[id] = total
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("layout size by download: %w", err)
	}
	return out, nil
}

// LiveTitleFolders возвращает целевые пути (dst_path) живых ссылок
// (`status IN linked/copied/exists`) загрузок, чьё current recognition имеет тот
// же (provider, provider_id), КРОМЕ excludeDownloadID. Питает правило сходимости
// папки (см. file-layout): вызывающий сводит пути к папкам тайтла, проверяет их
// существование на диске и наследует базу имени. Пустой provider/id → пусто (без
// подтверждённого матча якоря нет). Проверка живости папки на диске —
// ответственность вызывающего (статус ссылки не гарантирует наличие папки).
func (s *Store) LiveTitleFolders(ctx context.Context, provider, providerID, excludeDownloadID string) ([]string, error) {
	if provider == "" || providerID == "" {
		return nil, nil
	}
	const q = `
SELECT fl.dst_path
FROM file_link fl
JOIN recognition r ON r.download_id = fl.download_id AND r.is_current = 1
WHERE fl.status IN ('linked', 'copied', 'exists')
  AND fl.download_id != ?
  AND r.provider = ?
  AND r.provider_id = ?`
	var out []string
	if err := s.DB.SelectContext(ctx, &out, q, excludeDownloadID, provider, providerID); err != nil {
		return nil, fmt.Errorf("live title folders: %w", err)
	}
	return out, nil
}

// DeleteFileLinksByBatch удаляет записи ссылок батча (после undo на ФС).
func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) error {
	if _, err := s.DB.ExecContext(ctx,
		`DELETE FROM file_link WHERE apply_batch_id = ?`, batchID); err != nil {
		return fmt.Errorf("delete file_links by batch: %w", err)
	}
	return nil
}

// --- Кандидаты базы метаданных (metadata_candidate) ---

// MetadataCandidate — строка таблицы metadata_candidate. provider/provider_id
// хранят значения для тега Jellyfin (напр. TVMaze отдаёт внешний TVDB-id —
// см. recognize), а не обязательно нативный id провайдера поиска.
type MetadataCandidate struct {
	ID            string         `db:"id"`
	RecognitionID string         `db:"recognition_id"`
	Provider      string         `db:"provider"`
	ProviderID    string         `db:"provider_id"`
	Title         sql.NullString `db:"title"`
	Year          sql.NullInt64  `db:"year"`
	URL           sql.NullString `db:"url"`
	Chosen        bool           `db:"chosen"`
	CreatedAt     string         `db:"created_at"`
}

// CreateCandidates вставляет кандидатов распознавания одной транзакцией.
func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate) error {
	if len(cands) == 0 {
		return nil
	}
	tx, err := s.DB.BeginTxx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer func() { _ = tx.Rollback() }()

	now := FormatTime(Now())
	const q = `
INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
	for _, c := range cands {
		if _, err := tx.ExecContext(ctx, q,
			ident.NewID(), c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL, now); err != nil {
			return fmt.Errorf("insert candidate: %w", err)
		}
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit candidates: %w", err)
	}
	return nil
}

// ListCandidatesByRecognition возвращает кандидатов попытки распознавания.
func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID string) ([]MetadataCandidate, error) {
	var out []MetadataCandidate
	if err := s.DB.SelectContext(ctx, &out,
		`SELECT * FROM metadata_candidate WHERE recognition_id = ? ORDER BY id`, recognitionID); err != nil {
		return nil, fmt.Errorf("list candidates: %w", err)
	}
	return out, nil
}

// GetCandidate возвращает кандидата по id либо (nil, nil).
func (s *Store) GetCandidate(ctx context.Context, id string) (*MetadataCandidate, error) {
	var c MetadataCandidate
	err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("get candidate %s: %w", id, err)
	}
	return &c, nil
}

// SetCandidateChosen помечает кандидата выбранным, снимая отметку с прочих в
// той же попытке распознавания.
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID string) error {
	tx, err := s.DB.BeginTxx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer func() { _ = tx.Rollback() }()

	if _, err := tx.ExecContext(ctx,
		`UPDATE metadata_candidate SET chosen = 0 WHERE recognition_id = ?`, recognitionID); err != nil {
		return fmt.Errorf("clear chosen: %w", err)
	}
	if _, err := tx.ExecContext(ctx,
		`UPDATE metadata_candidate SET chosen = 1 WHERE id = ? AND recognition_id = ?`,
		candidateID, recognitionID); err != nil {
		return fmt.Errorf("set chosen: %w", err)
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit chosen: %w", err)
	}
	return nil
}
