// Package auth provides MCIAS token validation with caching for
// Metacircular services.
//
// Every Metacircular service delegates authentication to MCIAS. This
// package handles the login flow, token validation (with a 30-second
// SHA-256-keyed cache), and logout. It communicates directly with the
// MCIAS REST API.
//
// Security: bearer tokens are never logged or included in error messages.
package auth

import (
	"bytes"
	"context"
	"crypto/sha256"
	"crypto/tls"
	"crypto/x509"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"
	"strings"
	"time"
)

const cacheTTL = 30 * time.Second

// Errors returned by the Authenticator.
var (
	// ErrInvalidToken indicates the token is expired, revoked, or otherwise
	// invalid.
	ErrInvalidToken = errors.New("auth: invalid token")

	// ErrInvalidCredentials indicates that the username/password combination
	// was rejected by MCIAS.
	ErrInvalidCredentials = errors.New("auth: invalid credentials")

	// ErrForbidden indicates that MCIAS login policy denied access to this
	// service (HTTP 403).
	ErrForbidden = errors.New("auth: forbidden by policy")

	// ErrUnavailable indicates that MCIAS could not be reached.
	ErrUnavailable = errors.New("auth: MCIAS unavailable")
)

// Config holds MCIAS connection settings. This matches the standard [mcias]
// TOML section used by all Metacircular services.
type Config struct {
	// ServerURL is the base URL of the MCIAS server
	// (e.g., "https://mcias.metacircular.net:8443").
	ServerURL string `toml:"server_url"`

	// CACert is an optional path to a PEM-encoded CA certificate for
	// verifying the MCIAS server's TLS certificate.
	CACert string `toml:"ca_cert"`

	// ServiceName is this service's identity as registered in MCIAS. It is
	// sent with every login request so MCIAS can evaluate service-context
	// login policy rules.
	ServiceName string `toml:"service_name"`

	// Tags are sent with every login request. MCIAS evaluates auth:login
	// policy against these tags (e.g., ["env:restricted"]).
	Tags []string `toml:"tags"`
}

// TokenInfo holds the validated identity of an authenticated caller.
type TokenInfo struct {
	// Username is the MCIAS username (the "sub" claim).
	Username string

	// AccountType is the MCIAS account type: "human" or "system".
	// Used by policy engines that need to distinguish interactive users
	// from service accounts.
	AccountType string

	// Roles is the set of MCIAS roles assigned to the account.
	Roles []string

	// IsAdmin is true if the account has the "admin" role.
	IsAdmin bool
}

// Authenticator validates MCIAS bearer tokens with a short-lived cache.
type Authenticator struct {
	httpClient  *http.Client
	baseURL     string
	serviceName string
	tags        []string
	logger      *slog.Logger
	cache       *validationCache
}

// New creates an Authenticator that talks to the MCIAS server described
// by cfg. TLS 1.3 is required for all HTTPS connections. If cfg.CACert
// is set, that CA certificate is added to the trust pool.
//
// For plain HTTP URLs (used in tests), TLS configuration is skipped.
func New(cfg Config, logger *slog.Logger) (*Authenticator, error) {
	if cfg.ServerURL == "" {
		return nil, fmt.Errorf("auth: server_url is required")
	}

	transport := &http.Transport{}

	if !strings.HasPrefix(cfg.ServerURL, "http://") {
		tlsCfg := &tls.Config{
			MinVersion: tls.VersionTLS13,
		}

		if cfg.CACert != "" {
			pem, err := os.ReadFile(cfg.CACert) //nolint:gosec // CA cert path from operator config
			if err != nil {
				return nil, fmt.Errorf("auth: read CA cert %s: %w", cfg.CACert, err)
			}
			pool := x509.NewCertPool()
			if !pool.AppendCertsFromPEM(pem) {
				return nil, fmt.Errorf("auth: no valid certificates in %s", cfg.CACert)
			}
			tlsCfg.RootCAs = pool
		}

		transport.TLSClientConfig = tlsCfg
	}

	return &Authenticator{
		httpClient: &http.Client{
			Transport: transport,
			Timeout:   10 * time.Second,
		},
		baseURL:     strings.TrimRight(cfg.ServerURL, "/"),
		serviceName: cfg.ServiceName,
		tags:        cfg.Tags,
		logger:      logger,
		cache:       newCache(cacheTTL),
	}, nil
}

// Login authenticates a user against MCIAS and returns a bearer token.
// totpCode may be empty for accounts without TOTP configured.
//
// The service name and tags from Config are included in the login request
// so MCIAS can evaluate service-context login policy.
func (a *Authenticator) Login(username, password, totpCode string) (token string, expiresAt time.Time, err error) {
	reqBody := map[string]interface{}{
		"username": username,
		"password": password,
	}
	if totpCode != "" {
		reqBody["totp_code"] = totpCode
	}
	if a.serviceName != "" {
		reqBody["service_name"] = a.serviceName
	}
	if len(a.tags) > 0 {
		reqBody["tags"] = a.tags
	}

	var resp struct {
		Token     string `json:"token"`
		ExpiresAt string `json:"expires_at"`
	}
	status, err := a.doJSON(http.MethodPost, "/v1/auth/login", reqBody, &resp)
	if err != nil {
		return "", time.Time{}, fmt.Errorf("auth: MCIAS login: %w", ErrUnavailable)
	}

	switch status {
	case http.StatusOK:
		// Parse the expiry time.
		exp, parseErr := time.Parse(time.RFC3339, resp.ExpiresAt)
		if parseErr != nil {
			exp = time.Now().Add(1 * time.Hour) // fallback
		}
		return resp.Token, exp, nil
	case http.StatusForbidden:
		return "", time.Time{}, ErrForbidden
	default:
		return "", time.Time{}, ErrInvalidCredentials
	}
}

// ValidateToken checks a bearer token against MCIAS. Results are cached
// by the SHA-256 hash of the token for 30 seconds.
//
// Returns ErrInvalidToken if the token is expired, revoked, or otherwise
// not valid.
func (a *Authenticator) ValidateToken(token string) (*TokenInfo, error) {
	h := sha256.Sum256([]byte(token))
	tokenHash := hex.EncodeToString(h[:])

	if info, ok := a.cache.get(tokenHash); ok {
		return info, nil
	}

	var resp struct {
		Valid       bool     `json:"valid"`
		Sub         string   `json:"sub"`
		Username    string   `json:"username"`
		AccountType string   `json:"account_type"`
		Roles       []string `json:"roles"`
	}
	status, err := a.doJSON(http.MethodPost, "/v1/token/validate",
		map[string]string{"token": token}, &resp)
	if err != nil {
		return nil, fmt.Errorf("auth: MCIAS validate: %w", ErrUnavailable)
	}

	if status != http.StatusOK || !resp.Valid {
		return nil, ErrInvalidToken
	}

	info := &TokenInfo{
		Username:    resp.Username,
		AccountType: resp.AccountType,
		Roles:       resp.Roles,
		IsAdmin:     hasRole(resp.Roles, "admin"),
	}
	if info.Username == "" {
		info.Username = resp.Sub
	}

	a.cache.put(tokenHash, info)
	return info, nil
}

// ClearCache removes all cached token validation results. This should be
// called when the service transitions to a state where cached tokens may
// no longer be valid (e.g., Metacrypt sealing).
func (a *Authenticator) ClearCache() {
	a.cache.clear()
}

// Logout revokes a token on the MCIAS server.
func (a *Authenticator) Logout(token string) error {
	req, err := http.NewRequestWithContext(context.Background(),
		http.MethodPost, a.baseURL+"/v1/auth/logout", nil)
	if err != nil {
		return fmt.Errorf("auth: build logout request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+token)

	resp, err := a.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("auth: MCIAS logout: %w", ErrUnavailable)
	}
	_ = resp.Body.Close()
	return nil
}

// doJSON makes a JSON request to the MCIAS server and decodes the response.
// It returns the HTTP status code and any transport error.
func (a *Authenticator) doJSON(method, path string, body, out interface{}) (int, error) {
	var reqBody io.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			return 0, fmt.Errorf("marshal request: %w", err)
		}
		reqBody = bytes.NewReader(b)
	}

	req, err := http.NewRequestWithContext(context.Background(),
		method, a.baseURL+path, reqBody)
	if err != nil {
		return 0, fmt.Errorf("build request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")

	resp, err := a.httpClient.Do(req)
	if err != nil {
		return 0, err
	}
	defer func() { _ = resp.Body.Close() }()

	if out != nil && resp.StatusCode == http.StatusOK {
		respBytes, readErr := io.ReadAll(resp.Body)
		if readErr != nil {
			return resp.StatusCode, fmt.Errorf("read response: %w", readErr)
		}
		if len(respBytes) > 0 {
			if decErr := json.Unmarshal(respBytes, out); decErr != nil {
				return resp.StatusCode, fmt.Errorf("decode response: %w", decErr)
			}
		}
	}

	return resp.StatusCode, nil
}

func hasRole(roles []string, target string) bool {
	for _, r := range roles {
		if r == target {
			return true
		}
	}
	return false
}
