package config

import (
	"errors"
	"fmt"
	"net/url"
	"os"
	"sort"
	"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"`
}

// StorageConfig — единственный каталог данных: под ним лежат и база, и файлы
// записей. Двух путей, как было раньше, у хранилища не бывает.
type StorageConfig struct {
	DataDir string `toml:"data_dir"`
}

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 — вход через внешнего провайдера OIDC. Адреса, идентификатор
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
// секрет, положенный однажды шагом, не пережил бы ротации.
type AuthConfig struct {
	AuthURL      string `toml:"auth_url"`
	TokenURL     string `toml:"token_url"`
	UserInfoURL  string `toml:"user_info_url"`
	ClientID     string `toml:"client_id"`
	ClientSecret string `toml:"client_secret"`
	// RedirectURL — адрес возврата, тот же, что записан клиенту у провайдера.
	RedirectURL string `toml:"redirect_url"`
	// SecureCookie — признак `Secure` у куки сессии. Умолчание «включено»;
	// выключается только для локального запуска по `http://localhost`, где
	// браузер такую куку не сохранит.
	SecureCookie bool `toml:"secure_cookie"`
}

// Validate проверяет, что вход настроен целиком и что адреса — адреса. Пустое
// или негодное поле роняет старт: с молча выключенным входом сервис поднялся бы
// открытым наружу, а узнать об этом было бы неоткуда.
//
// Форма адреса проверяется здесь, а не только хранилищем, потому что хранилище
// отвергает негодный адрес позже — из хука подъёма, до регистрации пробы
// здоровья и метрик. Тогда владелец не получает даже кода состояния: сервис
// молча падает целиком, вместе с ботом и воркерами.
func (c AuthConfig) Validate() error {
	values := map[string]string{
		"auth_url":      c.AuthURL,
		"token_url":     c.TokenURL,
		"user_info_url": c.UserInfoURL,
		"client_id":     c.ClientID,
		"client_secret": c.ClientSecret,
		"redirect_url":  c.RedirectURL,
	}

	missing := make([]string, 0, len(values))
	for name, value := range values {
		if value == "" {
			missing = append(missing, name)
		}
	}
	if len(missing) > 0 {
		sort.Strings(missing)
		// Названы имена ключей, а не значения: значение `client_secret` в
		// сообщение об ошибке попасть не должно, оно уедет в журнал.
		return fmt.Errorf("auth: не заполнены ключи: %s", strings.Join(missing, ", "))
	}

	malformed := make([]string, 0, 4)
	for _, name := range []string{"auth_url", "token_url", "user_info_url", "redirect_url"} {
		parsed, err := url.Parse(values[name])
		if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
			malformed = append(malformed, name)
		}
	}
	if len(malformed) > 0 {
		return fmt.Errorf("auth: ключи не похожи на адрес: %s", strings.Join(malformed, ", "))
	}

	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",
		},
		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{
			SecureCookie: true,
		},
	}
}

// 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)
}
