package handler

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)

// ProfilePathPrefix is the URL prefix at which the user-profile page is
// served. The dot-prefix keeps the namespace out of project-name space
// (resolvePath rejects dot-prefixed user paths) and matches the `.zddc`
// / `.archive` reserved-prefix convention.
const ProfilePathPrefix = "/.profile"

// ServeProfile is the entry point for /.profile/* routes. The top-level
// page and the access-summary JSON are reachable to anyone (anonymous
// included); admin-only sub-resources (whoami / config / logs / projects /
// the .zddc editor / reindex) keep their existing per-resource 404 leakage
// gates.
func ServeProfile(cfg config.Config, ring *LogRing, idx *archive.Index, w http.ResponseWriter, r *http.Request) {
	sub := strings.TrimPrefix(r.URL.Path, ProfilePathPrefix)
	if sub == "" {
		sub = "/"
	}

	// Delegated to ServeZddc; that handler has its own hasAnyAdminScope gate.
	if sub == "/zddc" || strings.HasPrefix(sub, "/zddc/") {
		ServeZddc(cfg, w, r)
		return
	}

	email := EmailFromContext(r)

	switch sub {
	case "/", "":
		serveProfilePage(cfg, w, r)
	case "/access":
		writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, email))
	case "/projects":
		serveProfileProjectsCreate(cfg, w, r)
	case "/whoami":
		if !zddc.IsAdmin(cfg.Root, email) {
			http.NotFound(w, r)
			return
		}
		serveProfileWhoami(cfg, email, w, r)
	case "/config":
		if !zddc.IsAdmin(cfg.Root, email) {
			http.NotFound(w, r)
			return
		}
		serveProfileConfig(cfg, w, r)
	case "/logs":
		if !zddc.IsAdmin(cfg.Root, email) {
			http.NotFound(w, r)
			return
		}
		serveProfileLogs(ring, w, r)
	case "/effective-policy":
		if !zddc.IsAdmin(cfg.Root, email) {
			http.NotFound(w, r)
			return
		}
		serveProfileEffectivePolicy(cfg, w, r)
	case "/reindex":
		if !zddc.IsAdmin(cfg.Root, email) {
			http.NotFound(w, r)
			return
		}
		serveProfileReindex(cfg, idx, email, w, r)
	default:
		http.NotFound(w, r)
	}
}

// serveProfileReindex is an admin-only POST endpoint that triggers an
// immediate full re-walk of the archive index. Useful when a write has
// landed on the share via a path the local watcher can't see (other SMB
// clients, the just-restarted dev pod hitting prod data, etc.) and the
// operator wants the index updated without waiting for the next periodic
// rescan.
func serveProfileReindex(cfg config.Config, idx *archive.Index, email string, w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.Header().Set("Allow", http.MethodPost)
		http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
		return
	}
	dur, projects, tracking, err := idx.Rebuild(cfg.Root)
	if err != nil {
		slog.Warn("admin reindex failed", "email", email, "err", err, "duration", dur)
		http.Error(w, fmt.Sprintf("reindex failed: %v", err), http.StatusInternalServerError)
		return
	}
	slog.Info("admin reindex ok", "email", email, "duration", dur, "projects", projects, "tracking", tracking)
	writeJSON(w, map[string]any{
		"duration_ms":    dur.Milliseconds(),
		"project_count":  projects,
		"tracking_count": tracking,
	})
}

// AccessView is the data the profile page lazy-loads from /.profile/access
// after first paint. The HTML shell renders only Email/EmailHeader/
// IsSuperAdmin (all cheap); Projects + AdminSubtrees + HasAnyAdminScope come
// in via JS. EditableParentChoices is what the create-project form's
// parent-selector renders — derived from AdminSubtrees on the client.
type AccessView struct {
	Email                 string        `json:"email"`
	EmailHeader           string        `json:"email_header"`
	IsSuperAdmin          bool          `json:"is_super_admin"`
	HasAnyAdminScope      bool          `json:"has_any_admin_scope"`
	Projects              []ProjectInfo `json:"projects"`
	AdminSubtrees         []treeEntry   `json:"admin_subtrees"`
	EditableParentChoices []treeEntry   `json:"editable_parent_choices"`
}

// enumerateAccess builds an AccessView for the given caller. Used by the
// JSON endpoint at /.profile/access; the HTML page no longer calls this on
// the request hot path — it ships a shell first and the client fetches the
// view after first paint.
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, email string) AccessView {
	view := AccessView{
		Email:        email,
		EmailHeader:  cfg.EmailHeader,
		IsSuperAdmin: zddc.IsAdmin(cfg.Root, email),
	}
	view.Projects, _ = EnumerateProjects(ctx, decider, cfg, email)
	view.AdminSubtrees = enumerateAdminSubtrees(cfg, email)
	view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0
	for _, t := range view.AdminSubtrees {
		if t.CanEdit {
			view.EditableParentChoices = append(view.EditableParentChoices, t)
		}
	}
	return view
}

// enumerateAdminSubtrees lists every directory containing a .zddc that the
// caller can see as an admin (super-admin or subtree-admin). Each entry
// carries can_edit so the page can label read-only entries (the file that
// grants the user's own authority).
func enumerateAdminSubtrees(cfg config.Config, email string) []treeEntry {
	dirs, _ := zddc.ScanZddcFiles(cfg.Root)
	out := make([]treeEntry, 0, len(dirs))
	for _, d := range dirs {
		if !zddc.IsSubtreeAdmin(cfg.Root, d, email) && !zddc.IsAdmin(cfg.Root, email) {
			continue
		}
		var title string
		if zf, err := zddc.ParseFile(filepath.Join(d, ".zddc")); err == nil {
			title = zf.Title
		}
		out = append(out, treeEntry{
			Path:    urlPathOf(cfg.Root, d),
			CanEdit: zddc.CanEditZddc(cfg.Root, d, email),
			Title:   title,
		})
	}
	return out
}

// writeJSON writes v as indented JSON. Sets Content-Type and disables caching
// (profile views are always live).
func writeJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")
	_ = enc.Encode(v)
}

// serveProfileWhoami returns the data needed to debug header-passthrough
// problems: which header the server is configured to read, what value (if
// any) arrived under that header, the resolved email, and a dump of every
// header on the request.
func serveProfileWhoami(cfg config.Config, email string, w http.ResponseWriter, r *http.Request) {
	keys := make([]string, 0, len(r.Header))
	for k := range r.Header {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	headers := make(map[string][]string, len(keys))
	for _, k := range keys {
		headers[k] = r.Header.Values(k)
	}

	type response struct {
		ConfiguredEmailHeader string              `json:"configured_email_header"`
		ObservedEmail         string              `json:"observed_email"`
		ResolvedEmail         string              `json:"resolved_email"`
		RemoteAddr            string              `json:"remote_addr"`
		Method                string              `json:"method"`
		URL                   string              `json:"url"`
		Headers               map[string][]string `json:"headers"`
	}
	writeJSON(w, response{
		ConfiguredEmailHeader: cfg.EmailHeader,
		ObservedEmail:         r.Header.Get(cfg.EmailHeader),
		ResolvedEmail:         email,
		RemoteAddr:            r.RemoteAddr,
		Method:                r.Method,
		URL:                   r.URL.String(),
		Headers:               headers,
	})
}

// serveProfileConfig dumps the parsed Config. TLS cert/key paths are echoed,
// not their file contents; nothing else here is secret.
func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Request) {
	type response struct {
		Root        string   `json:"root"`
		Addr        string   `json:"addr"`
		TLSCert     string   `json:"tls_cert"`
		TLSKey      string   `json:"tls_key"`
		TLSMode     string   `json:"tls_mode"`
		LogLevel    string   `json:"log_level"`
		IndexPath   string   `json:"index_path"`
		EmailHeader string   `json:"email_header"`
		CORSOrigins []string `json:"cors_origins"`
		CascadeMode string   `json:"cascade_mode"`
	}
	writeJSON(w, response{
		Root:        cfg.Root,
		Addr:        cfg.Addr,
		TLSCert:     cfg.TLSCert,
		TLSKey:      cfg.TLSKey,
		TLSMode:     cfg.TLSMode,
		LogLevel:    cfg.LogLevel,
		IndexPath:   cfg.IndexPath,
		EmailHeader: cfg.EmailHeader,
		CORSOrigins: cfg.CORSOrigins,
		CascadeMode: cfg.CascadeMode,
	})
}

// serveProfileLogs returns the ring buffer's current contents. Optional query
// params: level=debug|info|warn|error and since=<RFC3339>.
func serveProfileLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) {
	if ring == nil {
		writeJSON(w, []LogEntry{})
		return
	}

	entries := ring.Snapshot()

	if levelStr := r.URL.Query().Get("level"); levelStr != "" {
		min := levelRank(levelStr)
		out := entries[:0]
		for _, e := range entries {
			if levelRank(strings.ToLower(e.Level)) >= min {
				out = append(out, e)
			}
		}
		entries = out
	}

	if sinceStr := r.URL.Query().Get("since"); sinceStr != "" {
		if since, err := time.Parse(time.RFC3339, sinceStr); err == nil {
			out := entries[:0]
			for _, e := range entries {
				if !e.Time.Before(since) {
					out = append(out, e)
				}
			}
			entries = out
		}
	}

	writeJSON(w, entries)
}

func levelRank(s string) int {
	switch strings.ToLower(s) {
	case "debug":
		return 0
	case "info":
		return 1
	case "warn", "warning":
		return 2
	case "error":
		return 3
	default:
		return 1 // unknown → info
	}
}

// serveProfileEffectivePolicy is the cascade-tracer endpoint:
// /.profile/effective-policy?path=<URL-path>&email=<email>
// returns the resolved policy chain plus the allow/deny decision the
// active decider produces, in JSON. Eliminates the need for operators
// to manual-trace .zddc files when debugging "why can't alice see
// /Project-X?" reports.
//
// Both query params are required. The endpoint is admin-only (404 to
// non-admins via the dispatch gate).
//
// Response shape (each chain level is a directory along the walk
// from ZDDC_ROOT down to the requested path):
//
//	{
//	  "path": "/Project-X/sub/",
//	  "email": "alice@mycompany.com",
//	  "decision": true,
//	  "decider_kind": "*policy.InternalDecider",
//	  "chain": {
//	    "has_any_file": true,
//	    "levels": [
//	      {"path": "/", "exists": true, "acl": {"allow": [...]}, "admins": [...]},
//	      {"path": "/Project-X/", "exists": false},
//	      {"path": "/Project-X/sub/", "exists": true, "acl": {"allow": [...]}}
//	    ]
//	  }
//	}
//
// Note: this evaluates the same input the production hot path would
// build for a request from <email> to <path>; if zddc-server is
// configured for external OPA, the decision goes through that OPA
// (so this endpoint is also a useful smoke test for the OPA wiring).
func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	probePath := q.Get("path")
	probeEmail := q.Get("email")
	if probePath == "" || probeEmail == "" {
		http.Error(w, "both ?path= and ?email= are required", http.StatusBadRequest)
		return
	}
	if !strings.HasPrefix(probePath, "/") {
		http.Error(w, "path must start with /", http.StatusBadRequest)
		return
	}

	// Resolve the URL path to a filesystem directory the same way the
	// dispatch hot path does.
	rel := strings.TrimPrefix(probePath, "/")
	rel = strings.TrimSuffix(rel, "/")
	absDir, ok := safeJoin(cfg.Root, rel)
	if !ok {
		http.Error(w, "path escapes ZDDC_ROOT", http.StatusBadRequest)
		return
	}

	chain, err := zddc.EffectivePolicy(cfg.Root, absDir)
	if err != nil {
		http.Error(w, "policy chain error: "+err.Error(), http.StatusInternalServerError)
		return
	}

	// Evaluate the decision through whatever decider is wired into the
	// request — internal in commercial deployments, an external OPA in
	// federal ones. The returned bool is "allow".
	ctx := r.Context()
	decider := DeciderFromContext(r)
	allow, _ := policy.AllowFromChain(ctx, decider, chain, probeEmail, probePath)

	type levelView struct {
		Index     int             `json:"index"`
		ZddcPath  string          `json:"zddc_path"`
		Exists    bool            `json:"exists"`
		Acl       *zddc.ACLRules  `json:"acl,omitempty"`
		Admins    []string        `json:"admins,omitempty"`
		AnyMatch  bool            `json:"matches_email"`
		Decision  string          `json:"decision_at_level"`
		// Inherit is the level's explicit inherit setting if present
		// (nil for absent — defaults to "inherit normally"). When
		// false, this level fences ancestors above it from descendants.
		Inherit  *bool `json:"inherit,omitempty"`
	}

	// Build the per-level breakdown by walking the chain levels in
	// the same order the cascade does (root → leaf in the data, but
	// the live evaluator walks bottom-up). For each level we report
	// whether the file actually existed (HasAnyFile is global; we
	// don't have per-level existence, but ZddcFile.Admins/ACL being
	// non-empty is a reasonable proxy).
	out := struct {
		Path        string      `json:"path"`
		Email       string      `json:"email"`
		Decision    bool        `json:"decision"`
		DeciderKind string      `json:"decider_kind"`
		CascadeMode string      `json:"cascade_mode"`
		Chain       struct {
			HasAnyFile bool        `json:"has_any_file"`
			// VisibleStart is the lowest chain index whose grants are
			// visible to evaluation at the leaf, accounting for any
			// inherit:false fence in delegated mode. In strict mode it
			// is always 0 (fences are ignored under AC-6).
			VisibleStart int         `json:"visible_start"`
			Levels       []levelView `json:"levels"`
		} `json:"chain"`
	}{
		Path:        probePath,
		Email:       probeEmail,
		Decision:    allow,
		DeciderKind: deciderKind(decider),
		CascadeMode: cfg.CascadeMode,
	}
	out.Chain.HasAnyFile = chain.HasAnyFile
	mode, _ := zddc.ParseCascadeMode(cfg.CascadeMode)
	out.Chain.VisibleStart = chain.VisibleStart(len(chain.Levels)-1, mode)

	// Reconstruct level paths from cfg.Root. This mirrors how
	// zddc.EffectivePolicy builds the chain (see cascade.go).
	levelPaths := []string{cfg.Root}
	if rel != "" {
		current := cfg.Root
		for _, seg := range strings.Split(rel, "/") {
			if seg == "" {
				continue
			}
			current = current + "/" + seg
			levelPaths = append(levelPaths, current)
		}
	}

	for i, lvl := range chain.Levels {
		var lp string
		if i < len(levelPaths) {
			// Map filesystem path back to a URL-style path under
			// cfg.Root for legibility in the response.
			fsPath := levelPaths[i]
			urlPath := strings.TrimPrefix(fsPath, cfg.Root)
			if urlPath == "" {
				urlPath = "/"
			}
			lp = urlPath + "/.zddc"
		}
		entry := levelView{
			Index:    i,
			ZddcPath: lp,
			Exists:   len(lvl.Admins) > 0 || len(lvl.ACL.Allow) > 0 || len(lvl.ACL.Deny) > 0 || len(lvl.ACL.Permissions) > 0 || lvl.ACL.Inherit != nil,
			Inherit:  lvl.ACL.Inherit,
		}
		if entry.Exists {
			entry.Acl = &lvl.ACL
			entry.Admins = lvl.Admins
		}
		// Per-level email match: would this level's deny or allow
		// patterns hit the email if checked? Reuses the same
		// MatchesPattern code the live evaluator does.
		anyMatch := false
		decisionAtLevel := "no_match"
		for _, p := range lvl.ACL.Deny {
			if zddc.MatchesPattern(p, probeEmail) {
				anyMatch = true
				decisionAtLevel = "deny"
				break
			}
		}
		if !anyMatch {
			for _, p := range lvl.ACL.Allow {
				if zddc.MatchesPattern(p, probeEmail) {
					anyMatch = true
					decisionAtLevel = "allow"
					break
				}
			}
		}
		entry.AnyMatch = anyMatch
		entry.Decision = decisionAtLevel
		out.Chain.Levels = append(out.Chain.Levels, entry)
	}

	writeJSON(w, out)
}

// deciderKind returns a short string label for the active decider.
// Mirrors the helper used in policy package tests; duplicated here
// to avoid a cross-package import that would only exist for one
// debug-endpoint string.
func deciderKind(d policy.Decider) string {
	if d == nil {
		return "nil"
	}
	t := fmt.Sprintf("%T", d)
	return t
}
