// Package web implements the SelfPost control panel's HTTP surface: the
// one-time administrator setup flow (spec 7.6.1), login/session handling
// (spec 7.6.5-6) and the authenticated shell the later phases build on.
package web

import (
	"embed"
	"log"
	"net"
	"net/http"
	"strings"
	"time"

	"codeberg.org/mix/selfpost/internal/app"
	"codeberg.org/mix/selfpost/internal/dnscheck"
	"codeberg.org/mix/selfpost/internal/domain"
	"codeberg.org/mix/selfpost/internal/store"
)

//go:embed templates/*.html static/*
var assetsFS embed.FS

// Config holds the panel's HTTP-facing configuration.
type Config struct {
	// Hostname is the server's external hostname, used to build the absolute
	// setup link shown in the logs (spec 7.6.1, 8: SELFPOST_HOSTNAME).
	Hostname string
	// CookieSecure sets the Secure attribute on the session cookie. It defaults
	// to true (spec 7.6.6); it exists as a knob only so the panel can be tested
	// over plain HTTP in development, never for production.
	CookieSecure bool
	// SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also
	// runs the 587/STARTTLS submission listener next to the primary 465 one
	// (spec 5). The panel only reports it on the domain page's connection
	// settings; it is a deploy-time flag, not something the panel can verify.
	SubmissionEnabled bool
	// MailLogPath is where Postfix's delivery log lives, read by the mail.log
	// monitoring view (spec 7.2.13). It is the same path the log-tailer role
	// follows in cmd/panel.
	MailLogPath string
	// DataDir and DBPath locate the persistent state a full backup archives
	// (spec 7.5.A); Version is stamped into the backup manifest. They mirror the
	// panel's own configuration.
	DataDir string
	DBPath  string
	Version string
	// TrustedProxyCIDRs are the reverse-proxy addresses allowed to supply
	// X-Forwarded-For (env TRUSTED_PROXY_CIDR). A request whose
	// direct peer (RemoteAddr) is not in this list never has its XFF header
	// honoured, so the header can't be spoofed by anyone but a trusted proxy.
	// Empty (the default) keeps rate-limiting keyed on RemoteAddr only.
	TrustedProxyCIDRs []*net.IPNet
	// TLSCertFile is the certificate Postfix serves on 465/587 (spec 8), read
	// read-only by the status page to report how much validity is left.
	TLSCertFile string
	// OpenDKIMSocket and JournalSocket are the two milter sockets Postfix
	// connects to. The status page stats them: the first is required for mail
	// to leave at all (OpenDKIM runs with default_action=tempfail), the second
	// only for the send log (the journal-milter fails open).
	OpenDKIMSocket string
	JournalSocket  string
	// SessionIdleDays is the sliding inactivity window after which a login
	// session expires (env PANEL_SESSION_IDLE_DAYS, plan B.1). Non-positive
	// falls back to the 7-day default.
	SessionIdleDays int
	// DNSResolvers are the recursive resolvers the deliverability checks query
	// (env SELFPOST_DNS_RESOLVERS). Empty uses dnscheck.DefaultResolvers. The
	// checks must not go through the system resolver — see dnscheck's
	// externalResolver — so this is how a closed network points them at its own.
	DNSResolvers []string
}

// Server is the panel HTTP application.
type Server struct {
	store    *store.Store
	domains  *domain.Service
	apps     *app.Service
	cfg      Config
	tmpl     *templates
	sessions *sessionStore
	setup    *setupManager
	dns      *dnscheck.Checker

	loginLimiter *rateLimiter
	setupLimiter *rateLimiter

	trustedProxies []*net.IPNet
}

// New builds the panel server. setupTokenPath is where the current setup token
// is mirrored on disk (spec 7.6.1); domains is the sending-domain service that
// owns DKIM keys and the OpenDKIM tables (spec 6); apps owns application SASL
// accounts and the Postfix sender map (spec 5.1).
func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) {
	tmpl, err := loadTemplates()
	if err != nil {
		return nil, err
	}
	idleDays := cfg.SessionIdleDays
	if idleDays <= 0 {
		idleDays = 7
	}
	s := &Server{
		store:    st,
		domains:  domains,
		apps:     apps,
		cfg:      cfg,
		tmpl:     tmpl,
		sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour),
		// Published-DNS checks for the status page and the domain pages. The
		// checker caches its own results, so page views do not each pay for a
		// round of lookups (phase 13).
		dns: dnscheck.New(cfg.DNSResolvers),
		// Setup: a handful of attempts per minute per IP is plenty for a
		// legitimate admin and blunts automated probing (spec 7.6.1).
		setupLimiter: newRateLimiter(10, time.Minute),
		// Login: throttle brute-force by IP (spec 7.6.5).
		loginLimiter: newRateLimiter(10, 15*time.Minute),

		trustedProxies: cfg.TrustedProxyCIDRs,
	}
	s.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
	return s, nil
}

// Start performs first-run bootstrapping: if there is no administrator yet, it
// generates and announces the setup link (spec 7.6.1). Safe to call once at
// server startup.
func (s *Server) Start() error {
	return s.setup.bootstrap()
}

// Handler returns the panel's HTTP handler (router).
func (s *Server) Handler() http.Handler {
	mux := http.NewServeMux()

	// Health check stays unauthenticated for the container/orchestrator.
	mux.HandleFunc("/healthz", handleHealth)

	// Vendored static assets (HTMX). Served from the embedded FS.
	mux.Handle("/static/", http.FileServer(http.FS(assetsFS)))

	// One-time administrator setup (spec 7.6.1).
	mux.HandleFunc("/setup/", s.handleSetup)

	// Authentication.
	mux.HandleFunc("/login", s.handleLogin)
	mux.HandleFunc("/logout", s.handleLogout)

	// Authenticated panel. Everything not matched by a more specific pattern
	// above falls through to this sub-mux, wrapped once in the auth middleware.
	authed := http.NewServeMux()

	// The landing page is the server status (phase 13.C): the first thing an
	// administrator should see after logging in is whether the service is
	// healthy, not the domain list. handleLogin still redirects to "/".
	authed.HandleFunc("GET /{$}", redirectToStatus)
	authed.HandleFunc("GET /status", s.handleStatus)
	authed.HandleFunc("GET /status/fragment", s.handleStatusFragment)
	authed.HandleFunc("POST /status/recheck", s.handleStatusRecheck)

	authed.HandleFunc("GET /domains", s.handleDashboard)
	authed.HandleFunc("POST /domains", s.handleAddDomain)
	authed.HandleFunc("POST /domains/import", s.handleImportDomain)
	authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail)
	authed.HandleFunc("POST /domains/{id}/dns-recheck", s.handleDomainDNSRecheck)
	authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm)
	authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain)
	authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication)
	authed.HandleFunc("POST /domains/{id}/ratelimit", s.handleDomainRateLimit)
	authed.HandleFunc("POST /domains/{id}/export", s.handleExportDomain)
	authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode)
	authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword)
	authed.HandleFunc("POST /applications/{aid}/ratelimit", s.handleAppRateLimit)
	authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication)
	authed.HandleFunc("POST /reload", s.handleReload)

	// Administrator's own panel credentials.
	authed.HandleFunc("/account", s.handleAccount)

	// Backup and migration: the page with both actions (spec 7.5.A-B), and the
	// full-server backup download itself.
	authed.HandleFunc("GET /backup", s.handleBackupPage)
	authed.HandleFunc("POST /backup", s.handleBackup)

	// Monitoring screens (spec 7.2.11-13): each page and its HTMX polling
	// fragment (spec 7.1 — the /rows and /body endpoints return HTML, not JSON).
	authed.HandleFunc("GET /deliveries", s.handleDeliveries)
	authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows)
	authed.HandleFunc("GET /mail-queue", s.handleMailQueue)
	authed.HandleFunc("GET /mail-queue/body", s.handleMailQueueBody)
	authed.HandleFunc("GET /system-log", s.handleSystemLog)
	authed.HandleFunc("GET /system-log/body", s.handleSystemLogBody)

	mux.Handle("/", s.requireAuth(authed))

	// Security headers and the origin check wrap everything, including the
	// unauthenticated login and setup routes (phase 14.A).
	return s.secure(mux)
}

// redirectToStatus points the panel root at the status page, so there is one
// canonical URL for that content instead of two (phase 13.C).
func redirectToStatus(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, "/status", http.StatusSeeOther)
}

func handleHealth(w http.ResponseWriter, _ *http.Request) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte("ok\n"))
}

// clientIP extracts the peer IP for rate-limiting. By default it is the
// transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches
// one of trustedProxies, the last entry of X-Forwarded-For is used instead —
// that is the address the trusted proxy itself appended, so a client can't
// forge it by sending its own XFF header. With no trusted
// proxies configured, behind a reverse proxy this is the proxy's own address,
// which is an acceptable backstop for a single-admin panel.
func clientIP(r *http.Request, trustedProxies []*net.IPNet) string {
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		host = r.RemoteAddr
	}

	if len(trustedProxies) > 0 {
		if peer := net.ParseIP(host); peer != nil && ipInAny(peer, trustedProxies) {
			if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
				parts := strings.Split(xff, ",")
				if ip := net.ParseIP(strings.TrimSpace(parts[len(parts)-1])); ip != nil {
					return ip.String()
				}
			}
		}
	}

	return host
}

func ipInAny(ip net.IP, nets []*net.IPNet) bool {
	for _, n := range nets {
		if n.Contains(ip) {
			return true
		}
	}
	return false
}

// logf is a thin wrapper so handlers log with a consistent prefix.
func logf(format string, args ...any) {
	log.Printf(format, args...)
}
