// Command panel is the SelfPost control panel. This single binary combines
// several roles (architecture.md § Image and processes) as a supervised
// process: the HTTP panel server, the journal-milter, the mail.log tailer and
// the rate-limit checks.
//
// Copyright (C) 2026 Mikhail Yenuchenko
package main

import (
	"context"
	"flag"
	"fmt"
	"log"
	"net"
	"os"
	"os/signal"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"syscall"

	"github.com/mixeme/selfpost/internal/backup"
	"github.com/mixeme/selfpost/internal/buildinfo"
	"github.com/mixeme/selfpost/internal/dnscheck"
	"github.com/mixeme/selfpost/internal/logtail"
	"github.com/mixeme/selfpost/internal/store"
)

func main() {
	if isDMARCIngestInvocation() {
		log.SetFlags(log.LstdFlags | log.LUTC)
		log.SetPrefix("dmarc-ingest: ")
		if err := runDMARCIngestMode(); err != nil {
			log.Printf("ingest failed: %v", err)
			os.Exit(75) // EX_TEMPFAIL — ask Postfix to defer
		}
		return
	}

	showVersion := flag.Bool("version", false, "print version and exit")
	flag.Parse()

	if *showVersion {
		fmt.Println(buildinfo.Version)
		return
	}

	log.SetFlags(log.LstdFlags | log.LUTC)
	log.SetPrefix("panel: ")

	if err := run(); err != nil {
		log.Fatalf("fatal: %v", err)
	}
}

// config holds the runtime knobs the panel reads from the environment. Every
// value has a safe default so the binary also runs outside the container.
type config struct {
	httpAddr      string
	journalSocket string
	mailLog       string
	retentionDays int

	dataDir                string
	dbPath                 string
	manifestPath           string
	setupTokenPath         string
	hostname               string
	cookieSecure           bool
	submissionEnabled      bool
	trustedProxies         []*net.IPNet
	sessionIdleDays        int
	dnsResolvers           []string
	rateLimitMessagesPerIP int
	rateLimitWindowSeconds int

	// Read-only inputs to the panel's status page: the certificate Postfix
	// serves and the two milter sockets it connects to. The defaults mirror
	// build/postfix-config.sh, so the status page checks exactly what Postfix
	// was configured with.
	tlsCertFile    string
	opendkimSocket string

	opendkimDir     string
	dkimSelectorDef string

	saslDBPath string
	saslRealm  string
	postfixDir string
	deployRoot string

	inboundEnabled bool
	dmarcEnabled   bool
}

func loadConfig() config {
	dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
	return config{
		httpAddr:      envDefault("PANEL_HTTP_ADDR", ":8080"),
		journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"),
		// Postfix's delivery log, under /data so the lines that resolve a
		// "queued" send-log row outlive the container. The default must match
		// maillog_file in build/postfix-config.sh.
		mailLog: envDefault("MAIL_LOG", "/data/log/mail.log"),
		// Send-log retention window (architecture.md § Persistence).
		// Non-positive/invalid falls back to the 90-day default inside the
		// log-tailer.
		retentionDays: envInt("SEND_LOG_RETENTION_DAYS", 90),

		dataDir:        dataDir,
		dbPath:         envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db")),
		manifestPath:   filepath.Join(dataDir, backup.ManifestName),
		setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")),
		hostname:       os.Getenv("SELFPOST_HOSTNAME"),
		// Secure cookies by default (security.md); PANEL_COOKIE_SECURE=false is a
		// development-only escape hatch for testing over plain HTTP.
		cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false",
		// Whether this deployment also runs the 587 submission listener. The
		// panel only displays it as a client connection setting; the comparison
		// matches postfix-config.sh, which enables the listener on "true" alone.
		submissionEnabled: os.Getenv("SUBMISSION_ENABLE") == "true",
		// Reverse-proxy addresses allowed to supply X-Forwarded-For for
		// rate-limiting. Empty by default: an untrusted peer's
		// XFF header is trivially forgeable, so it's ignored unless the panel is
		// told which proxy to trust.
		trustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDR")),
		// Sliding session idle timeout (security.md, plan B.1). Non-positive/invalid
		// falls back to the 7-day default inside internal/web.
		sessionIdleDays: envInt("PANEL_SESSION_IDLE_DAYS", 7),
		// Recursive resolvers the deliverability checks query directly. Empty
		// means dnscheck's public defaults; a closed network names its own here.
		dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")),

		// Level-1 anvil defaults match build/postfix-config.sh / guide.md.
		rateLimitMessagesPerIP: envInt("RATE_LIMIT_MESSAGES_PER_IP", 100),
		rateLimitWindowSeconds: envInt("RATE_LIMIT_WINDOW_SECONDS", 3600),

		tlsCertFile:    envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
		opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),

		// Per-domain DKIM state (architecture.md § OpenDKIM). The directory layout
		// matches what entrypoint.sh prepares (setgid, shared `selfpost` group).
		opendkimDir:     envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")),
		dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"),

		// Application SASL accounts and the Postfix sender map (architecture.md §
		// Mail path), both under /data so they survive restarts. The SASL realm
		// defaults to the server hostname so account identities line up with
		// Postfix's SASL configuration; it falls back to localhost outside the
		// container.
		saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
		saslRealm:  saslRealm(),
		postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
		deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
		// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
		inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true",
		// Optional DMARC aggregate ingest on port 25. Off unless exactly "true".
		dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true",
	}
}

// saslRealm chooses the realm new SASL accounts live under. It mirrors the
// hostname Postfix's SASL layer uses so a client authenticating with a bare
// login resolves to the right account.
func saslRealm() string {
	if r := os.Getenv("SASL_REALM"); r != "" {
		return r
	}
	if h := os.Getenv("SELFPOST_HOSTNAME"); h != "" {
		return h
	}
	return "localhost"
}

func envDefault(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

// envInt reads an integer environment variable, returning def if it is unset or
// not a valid integer.
func envInt(key string, def int) int {
	if v := os.Getenv(key); v != "" {
		if n, err := strconv.Atoi(v); err == nil {
			return n
		}
		log.Printf("ignoring invalid %s=%q, using %d", key, v, def)
	}
	return def
}

// parseTrustedProxies parses a comma-separated list of CIDRs (bare IPs are
// accepted and treated as /32 or /128). Invalid entries are logged and
// skipped rather than failing startup, matching envInt's tolerance of
// misconfiguration.
func parseTrustedProxies(raw string) []*net.IPNet {
	if raw == "" {
		return nil
	}
	var nets []*net.IPNet
	for _, part := range strings.Split(raw, ",") {
		cidr := strings.TrimSpace(part)
		if cidr == "" {
			continue
		}
		if !strings.Contains(cidr, "/") {
			if ip := net.ParseIP(cidr); ip != nil && ip.To4() != nil {
				cidr += "/32"
			} else {
				cidr += "/128"
			}
		}
		_, n, err := net.ParseCIDR(cidr)
		if err != nil {
			log.Printf("ignoring invalid TRUSTED_PROXY_CIDR entry %q: %v", part, err)
			continue
		}
		nets = append(nets, n)
	}
	return nets
}

// run starts the panel's three roles and blocks until a shutdown signal or the
// first fatal error from any role. A signal triggers a clean stop of all
// roles; a role error cancels the others and is returned so the process exits
// non-zero (letting supervisord/Docker see the failure — architecture.md §
// Image and processes).
func run() error {
	cfg := loadConfig()

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	log.Printf("starting selfpost panel %s", buildinfo.Version)

	// Restore version guard (architecture.md § Persistence): if a backup was
	// extracted into /data, its manifest version must match this binary before we
	// touch the database, so schema/format skew between versions cannot corrupt
	// the restored state. A match consumes the manifest; its absence is the
	// normal (non-restore) case.
	restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
	if err != nil {
		return err
	}

	// One database handle shared by every role. The store serialises writes
	// (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer
	// can all use it without stepping on each other under WAL.
	st, err := store.Open(cfg.dbPath)
	if err != nil {
		return err
	}
	defer st.Close()

	if restored {
		log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
		if err := resyncAfterRestore(cfg, st, false); err != nil {
			return err
		}
	} else if cfg.dmarcEnabled {
		ms := newMailStack(cfg, st)
		ms.pf.SetReloadHook(func() error { return nil })
		if err := ms.DMARC.Resync(); err != nil {
			log.Printf("dmarc maps bootstrap: %v", err)
		}
	}

	if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil {
		return fmt.Errorf("bootstrap send-log retention: %w", err)
	}

	var wg sync.WaitGroup
	errc := make(chan error, 4)

	roles := []struct {
		name string
		fn   func(context.Context) error
	}{
		{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
		{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
		{"log-tailer", func(ctx context.Context) error {
			return logtail.Run(ctx, cfg.mailLog, st, func() int {
				days, err := st.GetSendLogRetentionDays(cfg.retentionDays)
				if err != nil {
					log.Printf("send-log retention: %v", err)
					return cfg.retentionDays
				}
				return days
			})
		}},
		{"rate-limit-recalc", func(ctx context.Context) error {
			runAutoRateLimitRecalc(ctx, cfg, st)
			return nil
		}},
	}

	for _, r := range roles {
		wg.Add(1)
		go func(name string, fn func(context.Context) error) {
			defer wg.Done()
			if err := fn(ctx); err != nil {
				errc <- fmt.Errorf("%s: %w", name, err)
			}
		}(r.name, r.fn)
	}

	select {
	case <-ctx.Done():
		log.Printf("shutdown signal received, stopping")
		wg.Wait()
		log.Printf("panel stopped cleanly")
		return nil
	case err := <-errc:
		log.Printf("role failed: %v", err)
		stop() // cancel ctx so the other roles wind down
		wg.Wait()
		return err
	}
}
