package handler

import (
	"context"
	"errors"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

	"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
	"log/slog"
)

type contextKey string

// EmailKey is the context key for the authenticated user's email.
const EmailKey contextKey = "email"

// DeciderKey is the context key for the request's policy decider.
// Set by ACLMiddleware so handlers deep in the stack can issue policy
// queries without taking the decider as an explicit parameter. Although
// the decider is an app-wide singleton (not per-request state), routing
// it through context keeps the call-site signatures stable across the
// "swap internal evaluator for external OPA" plumbing change.
const DeciderKey contextKey = "policy-decider"

// ElevatedKey is the context key for the per-request elevation flag.
// Drives zddc.Principal{Elevated} for admin-authority checks. Set by
// ACLMiddleware according to the request's auth shape:
//   - Bearer tokens are implicitly elevated (machine clients can't
//     toggle a cookie; they're expected to act with the bearer's full
//     authority).
//   - Header-auth (browser) sessions elevate iff the request carries
//     a `zddc-elevate=1` cookie. The cookie is set/cleared by the
//     elevation toggle UI in the tool headers.
const ElevatedKey contextKey = "elevated"

// elevationCookieName is the cookie clients set to elevate their admin
// powers for header-auth (browser) sessions. Value "1" = elevated; any
// other value (or absent) = treat as non-admin even if the email is
// named in admin lists.
const elevationCookieName = "zddc-elevate"

// adminQueryParam reads the ?admin= elevation toggle, returning a pointer to
// the requested state (true = elevate, false = drop) or nil when the param is
// absent or unrecognised. Recognised values mirror shared/elevation.js so the
// URL toggle behaves identically whether elevation.js sets the cookie or the
// server honors the bare param: true/1/on/yes and false/0/off/no
// (case-insensitive).
func adminQueryParam(r *http.Request) *bool {
	v := strings.ToLower(r.URL.Query().Get("admin"))
	switch v {
	case "true", "1", "on", "yes":
		t := true
		return &t
	case "false", "0", "off", "no":
		f := false
		return &f
	}
	return nil
}

// ACLMiddleware extracts the user email and stores it (along with the
// policy decider) in the request context. It does NOT enforce ACL
// itself — each handler performs its own ACL check via
// policy.AllowFromChain.
//
// Two email sources, in order:
//
//  1. `Authorization: Bearer <token>` — if present, the token is
//     validated against the supplied auth.Store. On success, the
//     request runs as the token-file's email. On failure (invalid /
//     expired / no validator configured), the middleware short-circuits
//     with 401 — silently falling back to header-based auth would let
//     a misconfigured client masquerade as anonymous.
//  2. Otherwise, the email is read from cfg.EmailHeader, exactly as
//     before. This is the upstream-auth-proxy path (oauth2-proxy,
//     Caddy auth, etc.) that injects the header on validated requests.
//
// `tokens` may be nil — deployments without the token system simply
// reject any Bearer attempts with 401. This keeps Bearer-vs-no-Bearer
// trust paths decoupled from the operator's choice to issue tokens.
func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var email string
		var elevated bool
		if bearer := bearerToken(r); bearer != "" {
			if tokens == nil {
				http.Error(w, "Unauthorized", http.StatusUnauthorized)
				return
			}
			tok, err := tokens.Validate(bearer)
			if err != nil {
				if !errors.Is(err, auth.ErrInvalidToken) {
					slog.Warn("token validation error", "err", err)
				}
				http.Error(w, "Unauthorized", http.StatusUnauthorized)
				return
			}
			email = tok.Email
			// Bearer-token callers (CLI tools, scripts, mirror clients)
			// can't toggle a cookie — they're expected to operate with
			// the bearer's full authority. Implicit elevation keeps the
			// admin functions usable from the machine-client path.
			elevated = true
		} else {
			email = r.Header.Get(cfg.EmailHeader)
			// Browser sessions opt in to admin powers via the UI's
			// elevation toggle, which sets a `zddc-elevate=1` cookie.
			// Absent / any other value → treat as non-admin even when
			// the email is named in admin lists.
			if c, err := r.Cookie(elevationCookieName); err == nil && c.Value == "1" {
				elevated = true
			}
			// ?admin=true|1|on|yes elevates this request directly, and
			// ?admin=false|0|off|no drops it — mirroring the URL toggle in
			// shared/elevation.js, but honored at the server so the param
			// works on EVERY endpoint (raw directory listings, zip browsing,
			// the file API), not just HTML pages where elevation.js runs to
			// set the cookie. elevation.js still sets the cookie for sticky
			// persistence across navigation; this just makes the bare param
			// effective on a single direct request too. Elevation only grants
			// powers to a caller who already holds admin authority (every
			// admin call site re-checks the cascade via IsActiveAdmin), so
			// honoring the param for a non-admin is a harmless no-op.
			if v := adminQueryParam(r); v != nil {
				elevated = *v
			}
		}
		// DEBUG-level header dump for diagnosing proxy / SSO header
		// passthrough. Off by default (LogLevel info); enable with
		// ZDDC_LOG_LEVEL=debug. Logs the configured header name, the
		// observed value at that name, and the full request header
		// map so an operator can see exactly what reached the binary.
		// Note: at debug level this also captures auth tokens, cookies,
		// and anything else upstream proxies forward — only enable in
		// trusted environments.
		slog.Debug("request headers",
			"configured", cfg.EmailHeader,
			"observed", email,
			"headers", r.Header)
		ctx := context.WithValue(r.Context(), EmailKey, email)
		ctx = context.WithValue(ctx, ElevatedKey, elevated)
		if decider != nil {
			ctx = context.WithValue(ctx, DeciderKey, decider)
		}
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// bearerToken returns the token value from the Authorization header
// (case-insensitive on the "Bearer" scheme per RFC 6750), or the empty
// string when no Bearer credential is present.
func bearerToken(r *http.Request) string {
	v := r.Header.Get("Authorization")
	if v == "" {
		return ""
	}
	const prefix = "bearer "
	if len(v) <= len(prefix) || !strings.EqualFold(v[:len(prefix)], prefix) {
		return ""
	}
	return strings.TrimSpace(v[len(prefix):])
}

// EmailFromContext extracts the user email from the request context.
func EmailFromContext(r *http.Request) string {
	if v, ok := r.Context().Value(EmailKey).(string); ok {
		return v
	}
	return ""
}

// WithEmail returns a context carrying email under EmailKey. Test seam
// for handlers that look up the authenticated user via EmailFromContext;
// production traffic gets the same value injected by ACLMiddleware.
func WithEmail(ctx context.Context, email string) context.Context {
	return context.WithValue(ctx, EmailKey, email)
}

// ElevatedFromContext reports whether the request has opted into its
// admin powers. False for any request that wasn't tagged by
// ACLMiddleware (including tests that don't install it), so admin
// checks fail closed.
func ElevatedFromContext(r *http.Request) bool {
	if v, ok := r.Context().Value(ElevatedKey).(bool); ok {
		return v
	}
	return false
}

// WithElevation returns a context carrying the elevation flag under
// ElevatedKey. Test seam for the matching PrincipalFromContext lookup.
func WithElevation(ctx context.Context, elevated bool) context.Context {
	return context.WithValue(ctx, ElevatedKey, elevated)
}

// activeAdminForRequest reports whether the elevated principal would
// trigger the decider's admin-bypass branch on the chain at the
// request's target path, AND which chain level conferred that
// authority. Returned level is 0-based (root=0) when authority is
// active, -1 otherwise.
//
// Best-effort: walks the closest existing ancestor (mirroring the
// file API's authorize logic) so a write targeting a not-yet-
// existing file still answers correctly. Returns -1 on anonymous
// or un-elevated requests without touching the filesystem. The
// cascade is mtime-cached upstream, so the per-request cost is one
// map lookup in the common case.
func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, email string) int {
	if !elevated || email == "" || email == "anonymous" {
		return -1
	}
	cleanURL := strings.TrimSuffix(r.URL.Path, "/")
	if cleanURL == "" {
		cleanURL = "/"
	}
	rel := strings.TrimPrefix(cleanURL, "/")
	if rel == "" {
		// Root request: chain is just the root .zddc.
		chain, err := zddc.EffectivePolicy(cfg.Root, cfg.Root)
		if err != nil {
			return -1
		}
		return zddc.AdminLevelInChain(chain, email)
	}
	abs := filepath.Join(cfg.Root, filepath.FromSlash(rel))
	if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root {
		return -1
	}
	probe := abs
	for {
		if info, err := os.Stat(probe); err == nil && info.IsDir() {
			break
		}
		if probe == cfg.Root {
			break
		}
		parent := filepath.Dir(probe)
		if parent == probe {
			break
		}
		probe = parent
	}
	chain, err := zddc.EffectivePolicy(cfg.Root, probe)
	if err != nil {
		return -1
	}
	return zddc.AdminLevelInChain(chain, email)
}

// PrincipalFromContext bundles the request's authenticated email plus
// its elevation flag into a zddc.Principal — the value type the admin
// functions (IsAdmin, IsSubtreeAdmin) consume. One call per admin-check
// site replaces the previous ad-hoc email argument AND the previous
// "did I remember to gate this?" review burden: the type system
// enforces the gate by requiring a Principal value, which can only
// come from ACLMiddleware-tagged contexts.
func PrincipalFromContext(r *http.Request) zddc.Principal {
	return zddc.Principal{
		Email:    EmailFromContext(r),
		Elevated: ElevatedFromContext(r),
	}
}

// DeciderFromContext extracts the policy decider from the request
// context. Returns the internal decider as a fallback if none was
// installed — this matches the "no OPA configured" semantics and
// keeps test setups that don't install ACLMiddleware functional.
func DeciderFromContext(r *http.Request) policy.Decider {
	if v, ok := r.Context().Value(DeciderKey).(policy.Decider); ok {
		return v
	}
	return &policy.InternalDecider{}
}

// responseWriter wraps http.ResponseWriter to capture status code and bytes written.
type responseWriter struct {
	http.ResponseWriter
	status int
	bytes  int
	wrote  bool
}

// WriteHeader records the status code and writes it to the underlying ResponseWriter.
func (rw *responseWriter) WriteHeader(code int) {
	rw.status = code
	rw.wrote = true
	rw.ResponseWriter.WriteHeader(code)
}

// Write records the bytes written and writes to the underlying ResponseWriter.
func (rw *responseWriter) Write(b []byte) (int, error) {
	n, err := rw.ResponseWriter.Write(b)
	rw.bytes += n
	return n, err
}

// HSTSMiddleware sets the Strict-Transport-Security response header,
// instructing browsers to refuse plain-HTTP connections to this host
// for the next year (NIST SP 800-52 Rev. 2 § 4.4.6, also DoD STIG
// expectation; OWASP recommendation max-age >= 1 year). Use ONLY when
// zddc-server is itself terminating TLS — when an upstream proxy
// terminates, that proxy should set HSTS instead.
//
// includeSubDomains is set; preload is not (preload requires
// pre-submitting the domain to the browser-vendor list — out of
// scope for this server, and operators who want it can override
// upstream).
//
// max-age = 31536000 = 365 days.
func HSTSMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
		next.ServeHTTP(w, r)
	})
}

// AccessLogMiddleware logs a structured line per HTTP request after the
// response is written.
//
// Always emits to slog.Default() (stderr) so server-lifecycle logs and
// access logs share an output stream by default.
//
// If `auditLogger` is non-nil, the same structured fields are also written
// to it. The intended caller wires up auditLogger with a JSON handler
// pointing at a rotating file (see cmd/zddc-server's setupAccessAuditLog),
// so an operator gets a persisted audit trail on disk in addition to the
// stderr stream — useful when stderr is not journald-captured (e.g.
// container logging where the orchestrator drops stderr after restarts).
func AccessLogMiddleware(cfg config.Config, auditLogger *slog.Logger, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Capture request start time
		start := time.Now()

		// Snapshot the as-typed URL path before downstream handlers may
		// rewrite it (case-insensitive canonicalization). The audit
		// stream records what the client actually sent, not the
		// resolved canonical form.
		requestedPath := r.URL.Path

		// Wrap the ResponseWriter
		wrapped := &responseWriter{ResponseWriter: w, status: 200}

		// Serve the request
		next.ServeHTTP(wrapped, r)

		// Calculate duration
		durationMs := int(time.Since(start).Milliseconds())

		// Get email + elevation from context. `elevated` records the
		// per-request opt-in (sudo-style); `active_admin` says whether
		// the elevated user actually held admin authority on the path
		// the request targeted — i.e., whether the single bypass
		// branch in policy.InternalDecider.Allow would have fired
		// here. Surfacing both lets forensics distinguish:
		//   elevated=false, active_admin=false: normal user
		//   elevated=true,  active_admin=false: tried to elevate but no
		//                                        admin authority on this
		//                                        path (subtree-admin
		//                                        cooled by scope)
		//   elevated=true,  active_admin=true:  admin authority active,
		//                                        WORM/ACL bypassed
		email := EmailFromContext(r)
		if email == "" {
			email = "anonymous"
		}
		elevated := ElevatedFromContext(r)
		// adminLevel: 0-based chain index of the admins: entry that
		// conferred authority on this request, or -1 if no admin
		// authority applies. Lets forensics tell "root admin acted"
		// (level 0) apart from "subtree admin acted" (level N) apart
		// from "not admin" (-1). The active_admin bool is its
		// presence/absence projected to a boolean.
		adminLevel := activeAdminForRequest(cfg, r, elevated, email)

		args := []any{
			"ts", start.Format(time.RFC3339),
			"email", email,
			"elevated", elevated,
			"active_admin", adminLevel >= 0,
			"chain_admin_level", adminLevel,
			"method", r.Method,
			"path", requestedPath,
			"status", wrapped.status,
			"bytes", wrapped.bytes,
			"duration_ms", durationMs,
		}
		if r.URL.Path != requestedPath {
			args = append(args, "resolved_path", r.URL.Path)
		}

		// Stderr stream (existing behavior).
		slog.Info("access", args...)

		// Audit file (when configured). Same fields, separate handler so
		// the file can be JSON-formatted regardless of stderr's handler.
		if auditLogger != nil {
			auditLogger.Info("access", args...)
		}
	})
}
