package service

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/golang-jwt/jwt/v5"

	"wucher/internal/shared/pkg/uuidv7"
)

type WOPIPermission string

const (
	WOPIPermissionRead  WOPIPermission = "read"
	WOPIPermissionWrite WOPIPermission = "write"
)

type WOPIConfig struct {
	TokenSecret   string
	ProofSecret   string
	DiscoveryURL  string
	PublicBaseURL string
	EditActionURL string
	TokenTTL      time.Duration
	MaxBodyBytes  int
	RequireProof  bool
}

func LoadWOPIConfigFromEnv() WOPIConfig {
	return WOPIConfig{
		TokenSecret:   strings.TrimSpace(os.Getenv("WOPI_TOKEN_SECRET")),
		ProofSecret:   strings.TrimSpace(os.Getenv("WOPI_PROOF_SECRET")),
		DiscoveryURL:  strings.TrimSpace(os.Getenv("WOPI_DISCOVERY_URL")),
		PublicBaseURL: strings.TrimSpace(os.Getenv("WOPI_PUBLIC_BASE_URL")),
		EditActionURL: strings.TrimSpace(os.Getenv("WOPI_EDIT_ACTION_URL")),
		TokenTTL:      envDuration("WOPI_TOKEN_TTL", 30*time.Minute),
		MaxBodyBytes:  envInt("WOPI_MAX_BODY_BYTES", 8*1024*1024),
		RequireProof:  envBool("WOPI_REQUIRE_PROOF", false),
	}
}

type WOPIClaims struct {
	jwt.RegisteredClaims
	FileID     string `json:"file_id"`
	UserID     string `json:"user_id"`
	TakeoverID string `json:"takeover_id"`
	Permission string `json:"permission"`
}

type wopiDiscoveryCache struct {
	URLSrc string
	Loaded time.Time
}

type WOPIService struct {
	cfg       WOPIConfig
	http      *http.Client
	mu        sync.RWMutex
	discovery wopiDiscoveryCache
}

func NewWOPIService(cfg WOPIConfig) *WOPIService {
	if cfg.TokenTTL <= 0 {
		cfg.TokenTTL = 30 * time.Minute
	}
	if cfg.MaxBodyBytes <= 0 {
		cfg.MaxBodyBytes = 8 * 1024 * 1024
	}
	return &WOPIService{
		cfg: cfg,
		http: &http.Client{
			Timeout: 10 * time.Second,
		},
	}
}

func (s *WOPIService) TokenTTL() time.Duration {
	if s == nil || s.cfg.TokenTTL <= 0 {
		return 30 * time.Minute
	}
	return s.cfg.TokenTTL
}

func (s *WOPIService) MaxBodyBytes() int {
	if s == nil || s.cfg.MaxBodyBytes <= 0 {
		return 8 * 1024 * 1024
	}
	return s.cfg.MaxBodyBytes
}

func (s *WOPIService) MintAccessToken(fileID, userID, takeoverID []byte, perm WOPIPermission) (string, int64, error) {
	if s == nil || strings.TrimSpace(s.cfg.TokenSecret) == "" {
		return "", 0, errors.New("wopi token secret is not configured")
	}
	if len(fileID) != 16 || len(userID) != 16 {
		return "", 0, errors.New("invalid wopi token claims")
	}
	takeoverClaim := ""
	if len(takeoverID) != 0 {
		if len(takeoverID) != 16 {
			return "", 0, errors.New("invalid wopi token claims")
		}
		takeoverClaim = uuidBytesString(takeoverID)
	}
	jti, err := randomToken(24)
	if err != nil {
		return "", 0, err
	}
	now := time.Now().UTC()
	expiresAt := now.Add(s.TokenTTL())
	claims := WOPIClaims{
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   uuidBytesString(userID),
			ID:        jti,
			IssuedAt:  jwt.NewNumericDate(now),
			ExpiresAt: jwt.NewNumericDate(expiresAt),
			NotBefore: jwt.NewNumericDate(now.Add(-1 * time.Minute)),
			Audience:  jwt.ClaimStrings{"wopi"},
		},
		FileID:     uuidBytesString(fileID),
		UserID:     uuidBytesString(userID),
		TakeoverID: takeoverClaim,
		Permission: string(perm),
	}
	token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.cfg.TokenSecret))
	if err != nil {
		return "", 0, err
	}
	// WOPI/Collabora expects access_token_ttl as an ABSOLUTE expiry time in
	// epoch milliseconds (equal to the JWT exp claim), not a duration. Returning
	// the bare duration made Collabora read it as "1970-01-01 + ttl" and show
	// "session expired" immediately.
	return token, expiresAt.UnixMilli(), nil
}

func (s *WOPIService) VerifyAccessToken(token string, fileID []byte) (*WOPIClaims, error) {
	if s == nil || strings.TrimSpace(s.cfg.TokenSecret) == "" {
		return nil, errors.New("wopi token secret is not configured")
	}
	parsed, err := jwt.ParseWithClaims(strings.TrimSpace(token), &WOPIClaims{}, func(t *jwt.Token) (any, error) {
		if t.Method.Alg() != jwt.SigningMethodHS256.Alg() {
			return nil, errors.New("unexpected wopi token algorithm")
		}
		return []byte(s.cfg.TokenSecret), nil
	}, jwt.WithLeeway(30*time.Second))
	if err != nil {
		return nil, err
	}
	claims, ok := parsed.Claims.(*WOPIClaims)
	if !ok || claims == nil || !parsed.Valid {
		return nil, errors.New("invalid wopi access token")
	}
	if !strings.EqualFold(strings.TrimSpace(claims.FileID), uuidBytesString(fileID)) {
		return nil, errors.New("wopi token file mismatch")
	}
	return claims, nil
}

func (s *WOPIService) BuildActionURL(ctx context.Context, wopiSrc string) (string, error) {
	urlSrc, err := s.discoveryURLSrc(ctx)
	if err != nil {
		return "", err
	}
	escaped := url.QueryEscape(strings.TrimSpace(wopiSrc))
	switch {
	case strings.Contains(urlSrc, "{WOPISrc}"):
		return strings.ReplaceAll(urlSrc, "{WOPISrc}", escaped), nil
	case strings.Contains(urlSrc, "WOPISrc="):
		return urlSrc + escaped, nil
	default:
		sep := "?"
		if strings.Contains(urlSrc, "?") {
			sep = "&"
		}
		return urlSrc + sep + "WOPISrc=" + escaped, nil
	}
}

func (s *WOPIService) WOPISrc(fileID string) string {
	fileID = strings.TrimSpace(fileID)
	base := strings.TrimSpace(s.cfg.PublicBaseURL)
	if base == "" {
		return "/wopi/files/" + fileID
	}
	return strings.TrimRight(base, "/") + "/wopi/files/" + fileID
}

// BuildActionURLWithToken builds the Collabora editor action URL for a file.
//
// Per the WOPI/Collabora contract the action URL carries THREE separate query
// parameters: WOPISrc (the BARE file URL, no token), access_token and
// access_token_ttl. Collabora echoes access_token/access_token_ttl back on
// every WOPI callback (CheckFileInfo, GetFile, PutFile, Lock). Embedding the
// token inside the WOPISrc value instead leaves Collabora with an empty
// top-level access_token, which the WOPI host rejects — surfacing as a bogus
// "file is corrupt" prompt on load and "no write-able session" errors on save.
//
// accessTokenTTL is an ABSOLUTE expiry time in epoch milliseconds (matching the
// JWT exp claim), as required by WOPI/Collabora — not a duration.
func (s *WOPIService) BuildActionURLWithToken(ctx context.Context, fileID, token string, accessTokenTTL int64) (string, error) {
	actionURL, err := s.BuildActionURL(ctx, s.WOPISrc(fileID))
	if err != nil {
		return "", err
	}
	token = strings.TrimSpace(token)
	if token == "" {
		return actionURL, nil
	}
	sep := "?"
	if strings.Contains(actionURL, "?") {
		sep = "&"
	}
	actionURL += sep + "access_token=" + url.QueryEscape(token)
	if accessTokenTTL > 0 {
		actionURL += "&access_token_ttl=" + strconv.FormatInt(accessTokenTTL, 10)
	}
	return actionURL, nil
}

func (s *WOPIService) discoveryURLSrc(ctx context.Context) (string, error) {
	if s == nil {
		return "", errors.New("wopi service is nil")
	}
	s.mu.RLock()
	cached := s.discovery
	s.mu.RUnlock()
	if strings.TrimSpace(cached.URLSrc) != "" && time.Since(cached.Loaded) < 15*time.Minute {
		return cached.URLSrc, nil
	}
	if strings.TrimSpace(s.cfg.EditActionURL) != "" {
		s.mu.Lock()
		s.discovery = wopiDiscoveryCache{URLSrc: strings.TrimSpace(s.cfg.EditActionURL), Loaded: time.Now().UTC()}
		s.mu.Unlock()
		return s.cfg.EditActionURL, nil
	}
	if strings.TrimSpace(s.cfg.DiscoveryURL) == "" {
		return "", errors.New("wopi discovery url is not configured")
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.cfg.DiscoveryURL, nil)
	if err != nil {
		return "", err
	}
	resp, err := s.http.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return "", fmt.Errorf("wopi discovery returned %s", resp.Status)
	}
	body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
	if err != nil {
		return "", err
	}
	urlSrc := parseWOPIDiscoveryURLSrc(string(body))
	if urlSrc == "" {
		return "", errors.New("wopi discovery missing urlsrc")
	}
	s.mu.Lock()
	s.discovery = wopiDiscoveryCache{URLSrc: urlSrc, Loaded: time.Now().UTC()}
	s.mu.Unlock()
	return urlSrc, nil
}

func parseWOPIDiscoveryURLSrc(raw string) string {
	start := strings.Index(raw, "urlsrc=\"")
	if start < 0 {
		return ""
	}
	start += len(`urlsrc="`)
	end := strings.Index(raw[start:], `"`)
	if end < 0 {
		return ""
	}
	return htmlUnescape(raw[start : start+end])
}

func htmlUnescape(v string) string {
	v = strings.ReplaceAll(v, "&amp;", "&")
	v = strings.ReplaceAll(v, "&lt;", "<")
	v = strings.ReplaceAll(v, "&gt;", ">")
	v = strings.ReplaceAll(v, "&#39;", "'")
	v = strings.ReplaceAll(v, "&quot;", "\"")
	return v
}

func (s *WOPIService) VerifyProof(method, path, accessToken, timestamp, proof, proofOld string) error {
	if s == nil || !s.cfg.RequireProof {
		return nil
	}
	if strings.TrimSpace(s.cfg.ProofSecret) == "" {
		return errors.New("wopi proof secret is not configured")
	}
	ts, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64)
	if err != nil {
		return errors.New("invalid wopi timestamp")
	}
	reqTime := time.Unix(0, ts*int64(time.Millisecond))
	if d := time.Since(reqTime); d > 10*time.Minute || d < -10*time.Minute {
		return errors.New("stale wopi request")
	}
	base := strings.Join([]string{
		strings.ToUpper(strings.TrimSpace(method)),
		strings.TrimSpace(path),
		strings.TrimSpace(accessToken),
		strings.TrimSpace(timestamp),
	}, "\n")
	if verifyWOPIProofSignature(s.cfg.ProofSecret, base, proof) {
		return nil
	}
	if verifyWOPIProofSignature(s.cfg.ProofSecret, base, proofOld) {
		return nil
	}
	return errors.New("invalid wopi proof")
}

func verifyWOPIProofSignature(secret, payload, proof string) bool {
	if strings.TrimSpace(proof) == "" {
		return false
	}
	sum := hmac.New(sha256.New, []byte(secret))
	_, _ = sum.Write([]byte(payload))
	expected := base64.RawURLEncoding.EncodeToString(sum.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(strings.TrimSpace(proof)))
}

func envDuration(key string, fallback time.Duration) time.Duration {
	raw := strings.TrimSpace(os.Getenv(key))
	if raw == "" {
		return fallback
	}
	v, err := time.ParseDuration(raw)
	if err != nil {
		return fallback
	}
	return v
}

func envInt(key string, fallback int) int {
	raw := strings.TrimSpace(os.Getenv(key))
	if raw == "" {
		return fallback
	}
	v, err := strconv.Atoi(raw)
	if err != nil {
		return fallback
	}
	return v
}

func envBool(key string, fallback bool) bool {
	raw := strings.TrimSpace(os.Getenv(key))
	if raw == "" {
		return fallback
	}
	v, err := strconv.ParseBool(raw)
	if err != nil {
		return fallback
	}
	return v
}

func uuidBytesString(id []byte) string {
	if len(id) != 16 {
		return ""
	}
	s, err := uuidv7.BytesToString(id)
	if err != nil {
		return ""
	}
	return s
}
