// Package store is the durable state of a book project: schema migrations,
// the spend ledger (reserve/settle), chunk checkpoints and the request_log —
// one SQLite file per project (modernc.org/sqlite, CGO-free, no native
// extensions — Р3/Р6).
//
// Семантика денег и миграций портирована из vojo store.go; сам код написан
// заново под SQLite (advisory-локов здесь нет — сериализацию даёт write-пул
// из одного соединения + BEGIN IMMEDIATE; 03-implementation-notes §2 п.5).
//
// Рецепт конкурентности (§3.6): на файл БД два пула — write (MaxOpenConns=1,
// транзакции immediate) и read (N соединений); прагмы на каждое соединение
// через DSN: WAL, busy_timeout, foreign_keys, synchronous(NORMAL — переживает
// kill -9; отключение питания вне модели угроз MVP).
package store

import (
	"context"
	"database/sql"
	"fmt"
	"net/url"
	"os"
	"syscall"
	"time"

	_ "modernc.org/sqlite"
)

// opTimeout bounds every store operation: SQLite is a local file and
// effectively never blocks; the cap keeps a wedged filesystem from hanging a
// pipeline goroutine forever.
const opTimeout = 10 * time.Second

// Store is one project database.
type Store struct {
	w    *sql.DB // single-connection write pool; all mutations go through it
	r    *sql.DB // read pool
	lock *os.File
}

// Open opens (or creates) the project database at path, applies pending
// migrations and recovers stale reservations from a crashed run.
//
// Ownership: Open takes an EXCLUSIVE flock on <path>.lock — «один процесс
// владеет файлом проекта» здесь принуждается, а не предполагается. Без замка
// параллельный `tmctl report` во время перевода обнулил бы recovery-проходом
// живые резервы бегущего процесса и ослепил бы его потолки (находка ревью).
func Open(path string) (*Store, error) {
	lock, err := acquireLock(path + ".lock")
	if err != nil {
		return nil, err
	}
	dsn := func(txlock string) string {
		v := url.Values{}
		v.Add("_pragma", "journal_mode(WAL)")
		v.Add("_pragma", "busy_timeout(5000)")
		v.Add("_pragma", "foreign_keys(1)")
		v.Add("_pragma", "synchronous(NORMAL)")
		if txlock != "" {
			v.Set("_txlock", txlock)
		}
		return "file:" + path + "?" + v.Encode()
	}

	w, err := sql.Open("sqlite", dsn("immediate"))
	if err != nil {
		releaseLock(lock)
		return nil, fmt.Errorf("store: open write pool: %w", err)
	}
	// One writer connection: SQLite has a single writer per file anyway;
	// serializing in Go turns SQLITE_BUSY storms into a plain queue.
	w.SetMaxOpenConns(1)

	r, err := sql.Open("sqlite", dsn(""))
	if err != nil {
		w.Close()
		releaseLock(lock)
		return nil, fmt.Errorf("store: open read pool: %w", err)
	}
	r.SetMaxOpenConns(4)

	s := &Store{w: w, r: r, lock: lock}
	ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
	defer cancel()
	if err := s.migrate(ctx); err != nil {
		s.Close()
		return nil, err
	}
	if err := s.recoverReservations(ctx); err != nil {
		s.Close()
		return nil, err
	}
	return s, nil
}

// OpenReadOnly opens an EXISTING project database for the $0 read-only projections
// (`tmctl status`/`report`, D15.3/D20.4) WITHOUT the exclusive flock, migrations or
// the reservation-recovery pass. Раньше эти команды шли через Open → эксклюзивный
// flock, и оператор был заперт от status РОВНО на время живого прогона — «висит
// 300-я глава, а посмотреть нечем» (боль smoke-прогона пакета №4). WAL штатно
// допускает конкурентных читателей при живом писателе; замок нужен ТОЛЬКО из-за
// recoverReservations (обнулил бы живые резервы бегущего процесса) — а read-путь
// этот проход не выполняет, поэтому и замок ему не нужен.
//
// Гарантии: соединения жёстко read-only (PRAGMA query_only=1 — случайная запись
// падает громко, а не портит состояние живого писателя); отсутствующая БД — громкая
// ошибка (не создаём пустую); схема старше бинаря — громкая ошибка «прогоните
// write-команду» (миграции применяет только писатель). Известная честная граница:
// после краха прогона reserved_usd остаётся ненулевым до следующей WRITE-команды
// (recovery-проход только там) — status покажет этот хвост как reserved.
func OpenReadOnly(path string) (*Store, error) {
	if _, err := os.Stat(path); err != nil {
		return nil, fmt.Errorf("store: project database %s does not exist yet — run `tmctl translate` first: %w", path, err)
	}
	// Без journal_mode(WAL): режим журнала — свойство ФАЙЛА (Open всегда создаёт
	// WAL, флаг персистентен), а попытка его выставить на query_only-соединении
	// была бы записью. foreign_keys/synchronous читателю нерелевантны.
	v := url.Values{}
	v.Add("_pragma", "busy_timeout(5000)")
	v.Add("_pragma", "query_only(1)")
	r, err := sql.Open("sqlite", "file:"+path+"?"+v.Encode())
	if err != nil {
		return nil, fmt.Errorf("store: open read-only pool: %w", err)
	}
	r.SetMaxOpenConns(4)
	// w намеренно указывает на тот же query_only-пул: заблудившаяся запись через
	// read-only Store вернёт SQLITE_READONLY громко, а не nil-panic и не тихий успех.
	s := &Store{w: r, r: r, lock: nil}
	ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
	defer cancel()
	var current int
	if err := s.r.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(&current); err != nil {
		s.Close()
		return nil, fmt.Errorf("store: read schema version of %s (пустая/битая база?): %w", path, err)
	}
	if current < len(migrations) {
		s.Close()
		return nil, fmt.Errorf("store: %s is at schema v%d, this binary expects v%d — run a write command (`tmctl translate`/`redrive`) to migrate first (read-only open never migrates)",
			path, current, len(migrations))
	}
	if current > len(migrations) {
		s.Close()
		return nil, fmt.Errorf("store: %s is at schema v%d, NEWER than this binary's v%d — обновите tmctl (читать новую схему старым кодом небезопасно)",
			path, current, len(migrations))
	}
	return s, nil
}

func (s *Store) Close() error {
	err1 := s.w.Close()
	var err2 error
	if s.r != s.w {
		err2 = s.r.Close()
	}
	releaseLock(s.lock)
	if err1 != nil {
		return err1
	}
	return err2
}

// acquireLock takes a non-blocking exclusive flock. Kernel releases it on
// process death (kill -9 включительно), so a crashed run never wedges the
// project.
func acquireLock(path string) (*os.File, error) {
	f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
	if err != nil {
		return nil, fmt.Errorf("store: open lock file: %w", err)
	}
	if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
		f.Close()
		return nil, fmt.Errorf("store: project database is in use by another tmctl process (lock %s): %w", path, err)
	}
	return f, nil
}

func releaseLock(f *os.File) {
	if f == nil {
		return
	}
	_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
	_ = f.Close()
}

func opContext() (context.Context, context.CancelFunc) {
	return context.WithTimeout(context.Background(), opTimeout)
}

// queryAll runs a read query and scans every row into a slice (пакет №4 — пять
// изоморфных scan-циклов; новые read-models D15.2/аннотатора получают его даром).
// Гарантии, которые рукописный цикл забывает: rows.Err() всегда проверен (ошибка
// драйвера ПОСРЕДИ итерации не может молча усечь результат), строки
// МАТЕРИАЛИЗУЮТСЯ под op-таймаутом (ленивая итерация у вызывающего пережила бы
// defer cancel() и рвалась «context canceled» посреди чтения — находка реальной
// приёмки), rows.Close всегда закрыт.
func queryAll[T any](db *sql.DB, query string, scan func(*sql.Rows) (T, error), args ...any) ([]T, error) {
	ctx, cancel := opContext()
	defer cancel()
	rows, err := db.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []T
	for rows.Next() {
		v, err := scan(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, v)
	}
	return out, rows.Err()
}

// recoverReservations zeroes reserved_usd left over by a crashed process. The
// project file is owned by ONE process at a time (CLI), so any reservation
// present at open belongs to a run that never settled — the recovery pass из
// implementation-notes §3.3.
func (s *Store) recoverReservations(ctx context.Context) error {
	res, err := s.w.ExecContext(ctx, `UPDATE spend SET reserved_usd = 0 WHERE reserved_usd > 0`)
	if err != nil {
		return fmt.Errorf("store: recover reservations: %w", err)
	}
	if n, _ := res.RowsAffected(); n > 0 {
		// stderr, НЕ stdout: stdout — это полезный вывод перевода/отчёта, и
		// эта строка не должна засорять перенаправленный результат (находка
		// ревью). Логгер сюда не прокинут (Open — до его построения).
		fmt.Fprintf(os.Stderr, "store: recovered %d stale reservation row(s) from a previous run\n", n)
	}
	return nil
}
