package config

import (
	"errors"
	"fmt"
	"net/netip"
	"os"
	"strings"
	"time"

	"github.com/BurntSushi/toml"

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

type Config struct {
	Server   ServerConfig   `toml:"server"`
	Storage  StorageConfig  `toml:"storage"`
	Pipeline PipelineConfig `toml:"pipeline"`
	Yandex   YandexConfig   `toml:"yandex"`
	Auth     AuthConfig     `toml:"auth"`
}

// PipelineConfig — настройки конвейера расшифровки.
type PipelineConfig struct {
	// Workers — число одинаковых воркеров. Ноль — законное значение: сервис
	// поднимается, записи принимаются и не двигаются. Это режим, а не поломка.
	Workers int `toml:"workers"`
	// OwnWorkLimitMinutes — предел простоя записи там, где работу делаем мы
	// сами. Сторож ловит **зависание**, а не долгую работу: живой шаг
	// наблюдается по самому процессу, а остановка обратима — снятие признака
	// возвращает запись на её рубеж.
	OwnWorkLimitMinutes int `toml:"own_work_limit_minutes"`
	// ForeignWorkLimitMinutes — предел простоя там, где ждём чужую операцию.
	// Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибка
	// идёт в сторону долгого: ложная остановка хуже поздней.
	ForeignWorkLimitMinutes int `toml:"foreign_work_limit_minutes"`
}

// StuckLimits переводит настройки в пределы простоя.
func (c PipelineConfig) StuckLimits() entity.StuckLimits {
	return entity.StuckLimits{
		Own:     time.Duration(c.OwnWorkLimitMinutes) * time.Minute,
		Foreign: time.Duration(c.ForeignWorkLimitMinutes) * time.Minute,
	}
}

// Validate проверяет числа конвейера. Отрицательное число воркеров — ошибка
// настройки, а не режим: ноль объявлен законным значением, и отличать его от
// опечатки обязан старт.
func (c PipelineConfig) Validate() error {
	if c.Workers < 0 {
		return errors.New("pipeline: число воркеров не может быть отрицательным")
	}
	if c.OwnWorkLimitMinutes <= 0 || c.ForeignWorkLimitMinutes <= 0 {
		return errors.New("pipeline: пределы простоя задаются положительным числом минут")
	}
	return nil
}

type ServerConfig struct {
	Port                 int `toml:"port"`
	ShutdownTimeout      int `toml:"shutdown_timeout"`
	ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
	// Debug — предохранитель отладочного запуска. Умолчание — «выключено»:
	// отсутствие ключа читается как боевой прогон, а не как отладочный.
	//
	// Означает он одно: прогон идёт на машине разработчика, и сервису позволено
	// подставить то, что в бою даёт окружение. Сегодня подставляется ровно одна
	// вещь — заголовки входа, — и перечень следствий закрыт: уровня журнала,
	// текстов внутренних отказов, ограничителя частоты, подмены распознавателя и
	// проверок старта признак не касается. Новое следствие вешается на него
	// только отдельной нормой спеки `access`.
	Debug bool `toml:"debug"`
}

// StorageConfig — хранилище сервиса: каталог данных и числа его базы.
//
// Каталог единственный: под ним лежат и база, и файлы записей. Двух путей, как
// было раньше, у хранилища не бывает.
type StorageConfig struct {
	DataDir string `toml:"data_dir"`
	// BusyTimeoutMs — сколько ждать занятую базу, миллисекунды.
	//
	// Ключом, а не константой кода: крутят его при отказе «база занята» под
	// несколькими воркерами, и подбор ответа на такой отказ не должен требовать
	// пересборки образа.
	BusyTimeoutMs int `toml:"busy_timeout_ms"`
	// ReadConnections — сколько соединений держит читающий пул. Пишущее
	// соединение при этом всегда одно, и настройкой оно не делается: драйвер
	// пишет единственным соединением, и второе означало бы отказы по занятости.
	ReadConnections int `toml:"read_connections"`
}

// Validate проверяет настройки хранилища. Ноль и отрицательное — опечатка, а не
// режим: нулевое ожидание отдаёт «база занята» первому же воркеру, а нулевой пул
// чтения означает пул без предела, то есть настройку, которой не управляют.
func (c StorageConfig) Validate() error {
	if strings.TrimSpace(c.DataDir) == "" {
		return errors.New("storage: не заполнен ключ data_dir: сервису негде держать базу и файлы записей")
	}
	if c.BusyTimeoutMs <= 0 {
		return errors.New("storage: busy_timeout_ms задаётся положительным числом миллисекунд")
	}
	if c.ReadConnections <= 0 {
		return errors.New("storage: read_connections задаётся положительным числом соединений")
	}
	return nil
}

type YandexConfig struct {
	FolderID             string `toml:"folder_id"`
	SpeechKitAPIKey      string `toml:"speech_kit_api_key"`
	ObjStorageAccessKey  string `toml:"object_storage_access_key_id"`
	ObjStorageSecretKey  string `toml:"object_storage_secret_access_key"`
	ObjStorageBucketName string `toml:"object_storage_bucket_name"`
	ObjStorageRegion     string `toml:"object_storage_region"`
	ObjStorageEndpoint   string `toml:"object_storage_endpoint"`
}

// AuthConfig — кому сервис верит на входе.
//
// Своего входа у сервиса нет: кто пришёл, называет обратный прокси заголовком,
// сходив к провайдеру. Секция поэтому свелась к одному ключу — перечню адресов,
// чьему заголовку верить. Ни адресов провайдера, ни идентификатора клиента, ни
// его секрета здесь больше нет: обменивать код не на что, и секрет ушёл из
// конфига вместе с протоколом.
type AuthConfig struct {
	// TrustedProxies — адреса и подсети, чьему заголовку верят. Значение
	// сверяется с адресом самого соединения, а не с пересылаемым заголовком:
	// пересылаемым распоряжается тот, кто шлёт запрос, и барьер, подделываемый
	// той же строкой, которой он обходится, не барьер вовсе.
	TrustedProxies []string `toml:"trusted_proxies"`

	// TestHeaders — имитация заголовков, которые в бою ставит обратный прокси.
	// Ключ — имя заголовка, значение — то, чем сервис назовёт пришедшего сам.
	//
	// Работает только при включённом предохранителе `[server] debug`, и
	// заполненная секция без него роняет старт: состояние «имитация есть,
	// предохранителя нет» не читается никак, а обе его прочтения — поломка.
	//
	// Умолчание — пустая секция. Непустой она считается по наличию ключа, каким
	// бы ни было его значение: `Remote-User = ""` — заполненная имитация, а не
	// отсутствие её.
	TestHeaders map[string]string `toml:"test_headers"`
}

// TrustedNetworks разбирает перечень доверенных адресов.
//
// Одиночный адрес принимается наравне с подсетью и превращается в подсеть на
// один адрес: писать `/32` руками значит помнить разрядность, а перечень читает
// человек.
func (c AuthConfig) TrustedNetworks() ([]netip.Prefix, error) {
	networks := make([]netip.Prefix, 0, len(c.TrustedProxies))

	for _, raw := range c.TrustedProxies {
		value := strings.TrimSpace(raw)

		if prefix, err := netip.ParsePrefix(value); err == nil {
			networks = append(networks, prefix.Masked())
			continue
		}

		addr, err := netip.ParseAddr(value)
		if err != nil {
			return nil, fmt.Errorf("auth: %s не читается как адрес или подсеть: %q", trustedProxiesKey, value)
		}
		networks = append(networks, netip.PrefixFrom(addr, addr.BitLen()))
	}

	return networks, nil
}

// trustedProxiesKey — имя ключа в отказах старта. Литерал один на файл: два
// разошлись бы молча, и владелец искал бы в конфиге ключ, которого там нет.
const trustedProxiesKey = "trusted_proxies"

// Validate проверяет, что сервису есть кому верить.
//
// Пустой перечень роняет старт. Он значит «не верить никому», то есть сервис,
// поднявшийся никого не узнающим, — и молчать об этом старт не вправе: узнать о
// такой поломке было бы неоткуда, все адреса приложения просто отвечали бы
// отказом.
//
// Нечитаемая строка роняет старт по той же причине: перечень с опечаткой
// проверяется только тем, что кто-то не смог войти.
func (c AuthConfig) Validate() error {
	if len(c.TrustedProxies) == 0 {
		return fmt.Errorf("auth: не заполнен ключ %s: сервису некому верить, и узнать он никого не сможет", trustedProxiesKey)
	}

	if _, err := c.TrustedNetworks(); err != nil {
		return err
	}

	return nil
}

// DefaultConfig returns a Config with default values
func defaultConfig() *Config {
	return &Config{
		Server: ServerConfig{
			Port:                 8080,
			ShutdownTimeout:      5,
			ForceShutdownTimeout: 20,
		},
		Storage: StorageConfig{
			DataDir: "data",
			// Пять секунд ожидания и четыре читающих соединения: числа выведены
			// из числа воркеров по умолчанию, а не из замера. Смысл ожидания —
			// пережить чужую запись, а не чужую работу: пишет сервис короткими
			// операциями, и очередь из трёх воркеров укладывается в него с
			// запасом.
			BusyTimeoutMs:   5000,
			ReadConnections: 4,
		},
		Pipeline: PipelineConfig{
			Workers: 3,
			// Час на свою работу и сутки на чужую. Час меньше времени, которое
			// многочасовая запись занимает на приведении, и это принято
			// сознательно: сторож ловит зависание, живой шаг наблюдается по
			// самому процессу, а остановка обратима.
			OwnWorkLimitMinutes:     60,
			ForeignWorkLimitMinutes: 24 * 60,
		},
		Yandex: YandexConfig{
			FolderID:             "",
			SpeechKitAPIKey:      "",
			ObjStorageAccessKey:  "",
			ObjStorageSecretKey:  "",
			ObjStorageBucketName: "",
			ObjStorageRegion:     "ru-central1",
			ObjStorageEndpoint:   "https://storage.yandexcloud.net/",
		},
		// Умолчания у перечня доверенных адресов нет намеренно: подставленное
		// значение соврало бы ровно там, где по нему решают, кого пускать.
		Auth: AuthConfig{},
	}
}

// LoadConfig loads configuration from a TOML file
func LoadConfig(path string) (*Config, error) {
	// Check if file exists
	if _, err := os.Stat(path); os.IsNotExist(err) {
		return nil, fmt.Errorf("config file not found: %s", path)
	}

	config := defaultConfig()

	// Load configuration from file
	if _, err := toml.DecodeFile(path, &config); err != nil {
		return nil, decodeError(path, err)
	}

	return config, nil
}

// decodeError переводит отказ разбора на свои слова. Пересказывать библиотеку
// нельзя: она собирает текст отказа из разбираемого куска файла, и оборванная
// строка секретного ключа уехала бы в журнал вместе со значением.
//
// Разрез идёт по семейству отказа, и значения несёт только одно:
//
//   - `toml.ParseError` — сюда сведены отказы лексера и разбора значения, а его
//     `Message` собран из разбираемого куска («Invalid float value: %q»,
//     «invalid duration: %q»). Берём строку, столбец и последний ключ — они
//     безопасны, — а `Message` не берём;
//   - прочие отказы декодера собраны из имён ключей и имён типов, значений в них
//     нет вовсе. Их текст берём как есть: выбросив его, мы заплатили бы
//     разборчивостью отказа там, где платить не за что.
//
// Две ветки не сводятся в одну намеренно. Сведённая к общему знаменателю, она
// либо вернёт утечку, либо оставит несовпадение типов без единого намёка.
func decodeError(path string, err error) error {
	var parseErr toml.ParseError
	if errors.As(err, &parseErr) {
		if parseErr.LastKey != "" {
			return fmt.Errorf("config file %s: разбор оборвался на строке %d, столбце %d, последний ключ %q",
				path, parseErr.Position.Line, parseErr.Position.Col, parseErr.LastKey)
		}
		return fmt.Errorf("config file %s: разбор оборвался на строке %d, столбце %d",
			path, parseErr.Position.Line, parseErr.Position.Col)
	}

	return fmt.Errorf("failed to decode config file %s: %w", path, err)
}
