package lib

import (
	"bytes"
	"compress/gzip"
	"encoding/base64"
	"errors"
	"fmt"
	"math/rand"
	"net/http"
	"net/url"
	"regexp"
	"strings"
	"time"

	"github.com/TecharoHQ/anubis"
	"github.com/TecharoHQ/anubis/internal"
	"github.com/TecharoHQ/anubis/internal/glob"
	"github.com/TecharoHQ/anubis/lib/challenge"
	"github.com/TecharoHQ/anubis/lib/localization"
	"github.com/TecharoHQ/anubis/lib/policy"
	"github.com/TecharoHQ/anubis/web"
	"github.com/TecharoHQ/anubis/xess"
	"github.com/a-h/templ"
	"github.com/golang-jwt/jwt/v5"
	"golang.org/x/net/publicsuffix"
)

var domainMatchRegexp = regexp.MustCompile(`^((xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`)

var (
	ErrActualAnubisBug = errors.New("this is an actual bug in Anubis, please file an issue with the magic string 'taco bell'")
)

// matchRedirectDomain returns true if host matches any of the allowed redirect
// domain patterns. Patterns may contain '*' which are matched using the
// internal glob matcher. Matching is case-insensitive on hostnames.
func matchRedirectDomain(allowed []string, host string) bool {
	h := strings.ToLower(strings.TrimSpace(host))
	for _, pat := range allowed {
		p := strings.ToLower(strings.TrimSpace(pat))
		if strings.Contains(p, glob.GLOB) {
			if glob.Glob(p, h) {
				return true
			}
			continue
		}
		if p == h {
			return true
		}
	}
	return false
}

type CookieOpts struct {
	Value  string
	Host   string
	Path   string
	Name   string
	Expiry time.Duration
}

func (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
	var domain = s.opts.CookieDomain
	var name = anubis.CookieName
	var path = "/"
	var sameSite = s.opts.CookieSameSite

	if cookieOpts.Name != "" {
		name = cookieOpts.Name
	}
	if cookieOpts.Path != "" {
		path = cookieOpts.Path
	}
	if s.opts.CookieDynamicDomain && domainMatchRegexp.MatchString(cookieOpts.Host) {
		if etld, err := publicsuffix.EffectiveTLDPlusOne(cookieOpts.Host); err == nil {
			domain = etld
		}
	}

	if cookieOpts.Expiry == 0 {
		cookieOpts.Expiry = s.opts.CookieExpiration
	}

	if s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure {
		sameSite = http.SameSiteLaxMode
	}

	http.SetCookie(w, &http.Cookie{
		Name:        name,
		Value:       cookieOpts.Value,
		Expires:     time.Now().Add(cookieOpts.Expiry),
		SameSite:    sameSite,
		Domain:      domain,
		Secure:      s.opts.CookieSecure,
		Partitioned: s.opts.CookiePartitioned,
		Path:        path,
	})
}

func (s *Server) ClearCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
	var domain = s.opts.CookieDomain
	var name = anubis.CookieName
	var path = "/"
	var sameSite = s.opts.CookieSameSite

	if cookieOpts.Name != "" {
		name = cookieOpts.Name
	}
	if cookieOpts.Path != "" {
		path = cookieOpts.Path
	}
	if s.opts.CookieDynamicDomain && domainMatchRegexp.MatchString(cookieOpts.Host) {
		if etld, err := publicsuffix.EffectiveTLDPlusOne(cookieOpts.Host); err == nil {
			domain = etld
		}
	}
	if s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure {
		sameSite = http.SameSiteLaxMode
	}

	http.SetCookie(w, &http.Cookie{
		Name:        name,
		Value:       "",
		MaxAge:      -1,
		Expires:     time.Now().Add(-1 * time.Minute),
		SameSite:    sameSite,
		Partitioned: s.opts.CookiePartitioned,
		Domain:      domain,
		Secure:      s.opts.CookieSecure,
		Path:        path,
	})
}

// https://github.com/oauth2-proxy/oauth2-proxy/blob/master/pkg/upstream/http.go#L124
type UnixRoundTripper struct {
	Transport *http.Transport
}

// set bare minimum stuff
func (t UnixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	req = req.Clone(req.Context())
	if req.Host == "" {
		req.Host = "localhost"
	}
	req.URL.Host = req.Host // proxy error: no Host in request URL
	req.URL.Scheme = "http" // make http.Transport happy and avoid an infinite recursion
	return t.Transport.RoundTrip(req)
}

func randomChance(n int) bool {
	return rand.Intn(n) == 0
}

// XXX(Xe): generated by ChatGPT
func rot13(s string) string {
	rotated := make([]rune, len(s))
	for i, c := range s {
		switch {
		case c >= 'A' && c <= 'Z':
			rotated[i] = 'A' + ((c - 'A' + 13) % 26)
		case c >= 'a' && c <= 'z':
			rotated[i] = 'a' + ((c - 'a' + 13) % 26)
		default:
			rotated[i] = c
		}
	}
	return string(rotated)
}

func makeCode(err error) string {
	var buf bytes.Buffer
	gzw := gzip.NewWriter(&buf)
	errStr := fmt.Sprintf("internal error: %v", err)

	fmt.Fprintln(gzw, rot13(errStr))
	if err := gzw.Close(); err != nil {
		panic("can't write to gzip in ram buffer")
	}
	const width = 16

	enc := base64.StdEncoding.EncodeToString(buf.Bytes())
	var builder strings.Builder
	for i := 0; i < len(enc); i += width {
		end := i + width
		if end > len(enc) {
			end = len(enc)
		}
		builder.WriteString(enc[i:end])
		builder.WriteByte('\n')
	}
	return builder.String()
}

func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, rule *policy.Bot, returnHTTPStatusOnly bool) {
	localizer := localization.GetLocalizer(r)

	if returnHTTPStatusOnly {
		if s.opts.PublicUrl == "" {
			w.WriteHeader(http.StatusUnauthorized)
			w.Write([]byte(localizer.T("authorization_required")))
		} else {
			redirectURL, err := s.constructRedirectURL(r)
			if err != nil {
				s.respondWithStatus(w, r, err.Error(), "", http.StatusBadRequest)
				return
			}
			http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
		}
		return
	}

	lg := internal.GetRequestLogger(s.logger, r)

	if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && randomChance(64) {
		lg.Error("client was given a challenge but does not in fact support gzip compression")
		s.respondWithError(w, r, localizer.T("client_error_browser"), "")
		return
	}

	challengesIssued.WithLabelValues("embedded").Add(1)
	chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)
	if err != nil {
		lg.Error("can't get challenge", "err", err)
		s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
		s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm), makeCode(err))
		return
	}

	lg = lg.With("challenge", chall.ID)

	var ogTags map[string]string = nil
	if s.opts.OpenGraph.Enabled {
		var err error
		ogTags, err = s.OGTags.GetOGTags(r.Context(), r.URL, r.Host)
		if err != nil {
			lg.Error("failed to get OG tags", "err", err)
		}
	}

	s.SetCookie(w, CookieOpts{
		Value:  chall.ID,
		Host:   r.Host,
		Path:   "/",
		Name:   anubis.TestCookieName,
		Expiry: 30 * time.Minute,
	})

	impl, ok := challenge.Get(chall.Method)
	if !ok {
		lg.Error("check failed", "err", "can't get algorithm", "algorithm", rule.Challenge.Algorithm)
		s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
		s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm), makeCode(err))
		return
	}

	in := &challenge.IssueInput{
		Impressum: s.policy.Impressum,
		Rule:      rule,
		Challenge: chall,
		OGTags:    ogTags,
		Store:     s.store,
	}

	component, err := impl.Issue(w, r, lg, in)
	if err != nil {
		lg.Error("[unexpected] challenge component render failed, please open an issue", "err", err) // This is likely a bug in the template. Should never be triggered as CI tests for this.
		s.respondWithError(w, r, fmt.Sprintf("%s \"RenderIndex\"", localizer.T("internal_server_error")), makeCode(err))
		return
	}

	page := web.BaseWithChallengeAndOGTags(
		localizer.T("making_sure_not_bot"),
		component,
		s.policy.Impressum,
		chall,
		in.Rule.Challenge,
		in.OGTags,
		localizer,
	)

	handler := internal.GzipMiddleware(1, internal.NoStoreCache(templ.Handler(
		page,
		templ.WithStatus(s.opts.Policy.StatusCodes.Challenge),
	)))
	handler.ServeHTTP(w, r)
}

func (s *Server) constructRedirectURL(r *http.Request) (string, error) {
	proto := r.Header.Get("X-Forwarded-Proto")
	host := r.Header.Get("X-Forwarded-Host")
	uri := r.Header.Get("X-Forwarded-Uri")

	localizer := localization.GetLocalizer(r)

	if proto == "" || host == "" || uri == "" {
		return "", errors.New(localizer.T("missing_required_forwarded_headers"))
	}

	switch proto {
	case "http", "https":
		// allowed
	default:
		lg := internal.GetRequestLogger(s.logger, r)
		lg.Warn("invalid protocol in X-Forwarded-Proto", "proto", proto)
		return "", errors.New(localizer.T("invalid_redirect"))
	}

	// Check if host is allowed in RedirectDomains (supports '*' via glob)
	if len(s.opts.RedirectDomains) > 0 && !matchRedirectDomain(s.opts.RedirectDomains, host) {
		lg := internal.GetRequestLogger(s.logger, r)
		lg.Debug("domain not allowed", "domain", host)
		return "", errors.New(localizer.T("redirect_domain_not_allowed"))
	}

	redir := proto + "://" + host + uri
	escapedURL := url.QueryEscape(redir)
	return fmt.Sprintf("%s/.within.website/?redir=%s", s.opts.PublicUrl, escapedURL), nil
}

func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) {
	localizer := localization.GetLocalizer(r)

	templ.Handler(
		web.Base(localizer.T("benchmarking_anubis"), web.Bench(localizer), s.policy.Impressum, localizer),
	).ServeHTTP(w, r)
}

func (s *Server) respondWithError(w http.ResponseWriter, r *http.Request, message, code string) {
	s.respondWithStatus(w, r, message, code, http.StatusInternalServerError)
}

func (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg, code string, status int) {
	localizer := localization.GetLocalizer(r)

	templ.Handler(web.Base(localizer.T("oh_noes"), web.ErrorPage(msg, s.opts.WebmasterEmail, code, localizer), s.policy.Impressum, localizer), templ.WithStatus(status)).ServeHTTP(w, r)
}

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if strings.HasPrefix(r.URL.Path, anubis.BasePrefix+anubis.StaticPath) {
		s.mux.ServeHTTP(w, r)
		return
	} else if strings.HasPrefix(r.URL.Path, anubis.BasePrefix+xess.BasePrefix) {
		s.mux.ServeHTTP(w, r)
		return
	}

	// Forward robots.txt requests to mux when ServeRobotsTXT is enabled
	if s.opts.ServeRobotsTXT {
		path := strings.TrimPrefix(r.URL.Path, anubis.BasePrefix)
		if path == "/robots.txt" || path == "/.well-known/robots.txt" {
			s.mux.ServeHTTP(w, r)
			return
		}
	}

	s.maybeReverseProxyOrPage(w, r)
}

func (s *Server) stripBasePrefixFromRequest(r *http.Request) *http.Request {
	if !s.opts.StripBasePrefix || s.opts.BasePrefix == "" {
		return r
	}

	basePrefix := strings.TrimSuffix(s.opts.BasePrefix, "/")
	path := r.URL.Path

	if !strings.HasPrefix(path, basePrefix) {
		return r
	}

	trimmedPath := strings.TrimPrefix(path, basePrefix)
	if trimmedPath == "" {
		trimmedPath = "/"
	}

	// Clone the request and URL
	reqCopy := r.Clone(r.Context())
	urlCopy := *r.URL
	urlCopy.Path = trimmedPath
	reqCopy.URL = &urlCopy

	return reqCopy
}

func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
	if s.next == nil {
		localizer := localization.GetLocalizer(r)

		redir := r.FormValue("redir")
		urlParsed, err := url.ParseRequestURI(redir)
		if err != nil {
			// if ParseRequestURI fails, try as relative URL
			urlParsed, err = r.URL.Parse(redir)
			if err != nil {
				s.respondWithStatus(w, r, localizer.T("redirect_not_parseable"), makeCode(err), http.StatusBadRequest)
				return
			}
		}

		// validate URL scheme to prevent javascript:, data:, file:, tel:, etc.
		switch urlParsed.Scheme {
		case "", "http", "https":
			// allowed: empty scheme means relative URL
		default:
			lg := internal.GetRequestLogger(s.logger, r)
			lg.Warn("XSS attempt blocked, invalid redirect scheme", "scheme", urlParsed.Scheme, "redir", redir)
			s.respondWithStatus(w, r, localizer.T("invalid_redirect"), "", http.StatusBadRequest)
			return
		}

		hostNotAllowed := len(urlParsed.Host) > 0 &&
			len(s.opts.RedirectDomains) != 0 &&
			!matchRedirectDomain(s.opts.RedirectDomains, urlParsed.Host)
		hostMismatch := r.URL.Host != "" && urlParsed.Host != "" && urlParsed.Host != r.URL.Host

		if hostNotAllowed || hostMismatch {
			lg := internal.GetRequestLogger(s.logger, r)
			lg.Debug("domain not allowed", "domain", urlParsed.Host)
			s.respondWithStatus(w, r, localizer.T("redirect_domain_not_allowed"), makeCode(err), http.StatusBadRequest)
			return
		}

		if redir != "" {
			http.Redirect(w, r, redir, http.StatusFound)
			return
		}

		templ.Handler(
			web.Base(localizer.T("you_are_not_a_bot"), web.StaticHappy(localizer), s.policy.Impressum, localizer),
		).ServeHTTP(w, r)
	} else {
		requestsProxied.WithLabelValues(r.Host).Inc()
		r = s.stripBasePrefixFromRequest(r)
		s.next.ServeHTTP(w, r)
	}
}

func (s *Server) signJWT(claims jwt.MapClaims) (string, error) {
	claims["iat"] = time.Now().Unix()
	claims["nbf"] = time.Now().Add(-1 * time.Minute).Unix()
	claims["exp"] = time.Now().Add(s.opts.CookieExpiration).Unix()

	if len(s.hs512Secret) == 0 {
		return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(s.ed25519Priv)
	} else {
		return jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString(s.hs512Secret)
	}
}
