package http

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"sync"
	"time"

	"github.com/pocketbase/pocketbase/apis"
	"github.com/pocketbase/pocketbase/core"
	"github.com/pocketbase/pocketbase/tools/router"
	"github.com/pocketbase/pocketbase/tools/security"

	pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
)

const (
	// SessionCookieName — имя куки сессии. Имя нормативно: его смена молча
	// выкидывает всех вошедших.
	SessionCookieName = "transcriber_session"
	// stateCookieName — носитель состояния и проверочного кода PKCE. Живёт
	// один вход и убирается на возврате, каким бы тот ни был.
	stateCookieName = "transcriber_login"

	// stateCookieMaxAge — потолок времени на вход у провайдера. Дольше носитель
	// не нужен, а вечный носитель надолго фиксирует состояние.
	stateCookieMaxAge = 10 * 60

	// exchangeTimeout — потолок обмена кода у провайдера. Без него молчащий
	// провайдер держит обработчик возврата открытым неограниченно долго, и «медленный»
	// становится неотличим от «отказал».
	exchangeTimeout = 15 * time.Second
)

// AuthHandler ведёт вход, возврат от провайдера и выход.
//
// Разбор ответа провайдера остаётся за хранилищем — решение от 2026-08-11.
// Обмен кода библиотека наружу не отдаёт: он живёт за её собственным адресом,
// поэтому обработчик возврата зовёт этот адрес внутри процесса, через её же
// роутер. Цена петли принята решением владельца от 2026-08-12: взамен учётные
// записи заводит хранилище и они видны в панели.
type AuthHandler struct {
	app          core.App
	logger       *slog.Logger
	authURL      string
	redirectURL  string
	clientID     string
	secureCookie bool

	// storageMux — роутер хранилища, через который идёт обмен кода. Собирается
	// один раз: сборка вешает обработчики на само приложение и без
	// идентификатора, поэтому повторная не заменяет прежние, а добавляет к ним.
	// Собранный на каждый вход, он копил бы их без предела — и копил бы по
	// запросу анонима, потому что обмен исполняется раньше обращения к
	// провайдеру.
	storageMux     http.Handler
	storageMuxOnce sync.Once
	storageMuxErr  error
}

type AuthHandlerConfig struct {
	AuthURL      string
	RedirectURL  string
	ClientID     string
	SecureCookie bool
}

func NewAuthHandler(app core.App, cfg AuthHandlerConfig, logger *slog.Logger) *AuthHandler {
	if logger == nil {
		logger = slog.Default()
	}
	return &AuthHandler{
		app:          app,
		logger:       logger,
		authURL:      cfg.AuthURL,
		redirectURL:  cfg.RedirectURL,
		clientID:     cfg.ClientID,
		secureCookie: cfg.SecureCookie,
	}
}

// Register вешает адреса входа вне пространства `/api`: оно поделено с
// собственными адресами хранилища.
func (h *AuthHandler) Register(r *router.Router[*core.RequestEvent]) {
	// Продление сессии закрывается на всём роутере: адрес приносит хранилище
	// своим, и перехватить его можно только слоем.
	r.Bind(BlockSessionRefresh())

	// Адреса вешаются группой корня, а не литералами: корень объявлен единой
	// точкой адресного пространства, и порознь записанный он разошёлся бы с ней
	// молча — раздача приложения начала бы отдавать разметку на возврат от
	// провайдера, без единой ошибки в журнале.
	auth := r.Group(AuthRoot)

	auth.GET("/login", h.Login)
	auth.GET("/callback", h.Callback)
	// Выход берёт POST намеренно: по GET его срабатывание уносится переходом по
	// чужой ссылке.
	//
	// Слой предъявления нужен и здесь: без него выход не знает, чью сессию
	// обесценивать, — он убрал бы куку и отчитался успехом, оставив унесённое
	// значение годным. Требования сессии при этом нет: выход без неё убирает
	// куку и молчит.
	auth.POST("/logout", h.Logout).Bind(SessionFromCookie())
}

// Login уводит человека к провайдеру, запомнив состояние и проверочный код
// PKCE у браузера.
func (h *AuthHandler) Login(e *core.RequestEvent) error {
	state := security.RandomString(32)
	verifier := security.RandomString(43)

	e.SetCookie(&http.Cookie{
		Name:     stateCookieName,
		Value:    state + ":" + verifier,
		Path:     "/",
		MaxAge:   stateCookieMaxAge,
		HttpOnly: true,
		Secure:   h.secureCookie,
		SameSite: http.SameSiteLaxMode,
	})

	query := url.Values{}
	query.Set("response_type", "code")
	query.Set("client_id", h.clientID)
	query.Set("redirect_uri", h.redirectURL)
	query.Set("scope", "openid profile email")
	query.Set("state", state)
	query.Set("code_challenge", security.S256Challenge(verifier))
	query.Set("code_challenge_method", "S256")

	separator := "?"
	if strings.Contains(h.authURL, "?") {
		separator = "&"
	}

	return e.Redirect(http.StatusFound, h.authURL+separator+query.Encode())
}

// Callback принимает возврат от провайдера, сверяет состояние и меняет код на
// сессию средствами хранилища.
func (h *AuthHandler) Callback(e *core.RequestEvent) error {
	// Носитель убирается всегда — и на успехе, и на отказе, — и убирается
	// **до** записи ответа. Отложенная уборка не работает вовсе: заголовки
	// фиксируются в момент, когда ответ начинают писать, и позднейшая правка их
	// карты до браузера не доезжает. Состояние одноразовое ровно этим: пока
	// носитель жив, переигранный возврат проходит сверку.
	h.clearStateCookie(e)

	query := e.Request.URL.Query()

	// Всё, что ниже до обмена, — негодный ввод от пришедшего, а не поломка
	// сервиса: владельцу разбирать нечего, и уровень здесь отладочный. Иначе
	// обычный отказ человека у провайдера стал бы неотличим от «провайдер лежит».
	if providerError := query.Get("error"); providerError != "" {
		h.logger.Debug("Login rejected by provider",
			"reason", knownProviderError(providerError), "capability", "access", "transport", "http")
		return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
	}

	state, verifier, err := h.readStateCookie(e)
	if err != nil {
		h.logger.Debug("Login state is missing or malformed",
			"error", err, "capability", "access", "transport", "http")
		return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
	}

	if query.Get("state") != state {
		h.logger.Debug("Login state mismatch", "capability", "access", "transport", "http")
		return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
	}

	code := query.Get("code")
	if code == "" {
		h.logger.Debug("Provider returned no code", "capability", "access", "transport", "http")
		return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
	}

	token, err := h.exchange(e.Request.Context(), code, verifier)
	if err != nil {
		// Отказ обмена — уже про сервис и его связь с провайдером, поэтому
		// уровень выше. Код провайдера в журнал не идёт: он и есть предъявитель
		// входа.
		h.logger.Error("Failed to exchange provider code",
			"error", err, "capability", "access", "transport", "http")
		return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
	}

	h.setSessionCookie(e, token)

	return e.Redirect(http.StatusFound, "/")
}

// Logout обесценивает выданные учётной записи сессии и убирает куку.
//
// Порядок обязателен: сперва обесценивание, потом уборка. При обратном порядке
// выход, разошедшийся с одновременным входом, оставил бы годную сессию, а
// человек был бы уверен, что вышел.
func (h *AuthHandler) Logout(e *core.RequestEvent) error {
	if e.Auth != nil {
		// Ключ токенов обновляется у свежей записи: между чтением и записью
		// могла пройти чужая правка, и полное сохранение устаревшей записи
		// затёрло бы её.
		record, err := h.app.FindRecordById(e.Auth.Collection().Id, e.Auth.Id)
		if err != nil {
			h.logger.Error("Failed to load account for logout", "error", err, "transport", "http")
			return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
		}

		record.RefreshTokenKey()
		if err := h.app.Save(record); err != nil {
			h.logger.Error("Failed to revoke sessions", "error", err, "transport", "http")
			return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
		}
	}

	h.clearSessionCookie(e)

	return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
}

// exchange зовёт собственный адрес хранилища внутри процесса. По сети запрос не
// идёт: роутер поднимается тот же, что обслуживает внешние запросы.
func (h *AuthHandler) exchange(ctx context.Context, code, verifier string) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, exchangeTimeout)
	defer cancel()

	body, err := json.Marshal(map[string]string{
		"provider":     pbrepo.ProviderName,
		"code":         code,
		"codeVerifier": verifier,
		"redirectURL":  h.redirectURL,
	})
	if err != nil {
		return "", fmt.Errorf("failed to build exchange request: %w", err)
	}

	request, err := http.NewRequestWithContext(
		ctx,
		http.MethodPost,
		"/api/collections/users/auth-with-oauth2",
		strings.NewReader(string(body)),
	)
	if err != nil {
		return "", fmt.Errorf("failed to build exchange request: %w", err)
	}
	request.Header.Set("Content-Type", "application/json")
	// Адрес запросу нужен, хотя запрос внутрипроцессный и наружу не идёт.
	//
	// Ограничитель частоты хранилища ключует клиента адресом, а у собранного
	// руками запроса его нет вовсе — и вырожденное значение библиотека отдаёт не
	// пустой строкой, а литералом. Её собственный страж «пустой ключ пропускаем»
	// такое значение не ловит, поэтому **все** внутренние обмены кода схлопнулись
	// бы в один счётчик: третий вход в пределах трёх секунд — чей угодно —
	// получал бы отказ ограничителя, неотличимый от настоящего отказа провайдера.
	//
	// Прежде этого не случалось: ограничитель был выключен целиком. Он включается
	// вместе с правилом под корнем приложения, и цена названа здесь.
	request.RemoteAddr = "127.0.0.1:0"

	handler, err := h.storageHandler()
	if err != nil {
		return "", err
	}

	recorder := httptest.NewRecorder()
	handler.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		// Тело ответа наружу не выносится: в нём приезжает описание отказа
		// провайдера, а оно принадлежит журналу, а не человеку.
		return "", fmt.Errorf("storage rejected the exchange with code %d", recorder.Code)
	}

	var response struct {
		Token string `json:"token"`
	}
	if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
		return "", fmt.Errorf("failed to read exchange response: %w", err)
	}
	if response.Token == "" {
		return "", errors.New("exchange response carries no session")
	}

	return response.Token, nil
}

// knownProviderError приводит причину отказа к перечню известных.
//
// Значение приходит строкой запроса и целиком задаётся тем, кто её шлёт: без
// приведения аноним пишет в журнал что угодно и сколько угодно — предел один,
// размер заголовков. Журнал же единственное место, где наблюдаются инварианты о
// молчаливой потере задачи, и вытеснять его чужим текстом нельзя.
//
// Приём тот же, каким расширение записи приводится к перечню форматов.
func knownProviderError(value string) string {
	switch value {
	case "access_denied", "invalid_request", "invalid_scope", "server_error",
		"temporarily_unavailable", "unauthorized_client", "unsupported_response_type",
		"interaction_required", "login_required", "consent_required":
		return value
	default:
		return "other"
	}
}

// storageHandler собирает роутер хранилища один раз и отдаёт его всем
// последующим обменам.
func (h *AuthHandler) storageHandler() (http.Handler, error) {
	h.storageMuxOnce.Do(func() {
		router, err := apis.NewRouter(h.app)
		if err != nil {
			h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
			return
		}
		mux, err := router.BuildMux()
		if err != nil {
			h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
			return
		}
		h.storageMux = mux
	})

	return h.storageMux, h.storageMuxErr
}

func (h *AuthHandler) readStateCookie(e *core.RequestEvent) (state, verifier string, err error) {
	cookie, err := e.Request.Cookie(stateCookieName)
	if err != nil {
		return "", "", fmt.Errorf("login state cookie is missing: %w", err)
	}

	state, verifier, found := strings.Cut(cookie.Value, ":")
	if !found || state == "" || verifier == "" {
		return "", "", errors.New("login state cookie is malformed")
	}

	return state, verifier, nil
}

func (h *AuthHandler) setSessionCookie(e *core.RequestEvent, token string) {
	e.SetCookie(&http.Cookie{
		Name:     SessionCookieName,
		Value:    token,
		Path:     "/",
		MaxAge:   pbrepo.SessionDuration,
		HttpOnly: true,
		Secure:   h.secureCookie,
		SameSite: http.SameSiteLaxMode,
	})
}

func (h *AuthHandler) clearSessionCookie(e *core.RequestEvent) {
	e.SetCookie(&http.Cookie{
		Name:     SessionCookieName,
		Value:    "",
		Path:     "/",
		MaxAge:   -1,
		HttpOnly: true,
		Secure:   h.secureCookie,
		SameSite: http.SameSiteLaxMode,
	})
}

func (h *AuthHandler) clearStateCookie(e *core.RequestEvent) {
	e.SetCookie(&http.Cookie{
		Name:     stateCookieName,
		Value:    "",
		Path:     "/",
		MaxAge:   -1,
		HttpOnly: true,
		Secure:   h.secureCookie,
		SameSite: http.SameSiteLaxMode,
	})
}
