package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"

	// Embeds the IANA zone database in the binary. Load-bearing: server/Dockerfile
	// builds on Alpine, which ships no zoneinfo, so without this
	// time.LoadLocation("Europe/London") fails in production and every workflow
	// schedule silently falls back to UTC.
	_ "time/tzdata"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/api"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
	grpcserver "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/monitorsched"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/vulnsched"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
	"github.com/gin-gonic/gin"
)

func main() {
	mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")

	// Two flags, so the schema work can be lifted out of the serving pods.
	//
	// Under Docker Compose neither is set and nothing changes: one process
	// migrates and then serves. Under Kubernetes with more than one replica
	// that is unsafe — every pod would run MigrateOrgToInstance at once, and
	// renaming collections while a sibling reads them is not a race anyone
	// wins. The chart therefore runs a pre-upgrade Job with MIGRATE_ONLY and
	// starts the Deployment with SKIP_MIGRATIONS.
	migrateOnly := boolEnv("VANTAGE_MIGRATE_ONLY")
	skipMigrations := boolEnv("VANTAGE_SKIP_MIGRATIONS")

	// Not required in migrate-only mode: that process never serves gRPC, and
	// demanding it would put an agent-facing address in a Job that has no
	// business knowing one.
	if !migrateOnly && os.Getenv("GRPC_HOST") == "" {
		log.Fatal("GRPC_HOST is required (host:port agents dial for gRPC)")
	}

	// DB name comes from the MONGO_URI path; "vantage" is the fallback.
	if err := db.Connect(mongoURI, "vantage"); err != nil {
		log.Fatalf("failed to connect to MongoDB: %v", err)
	}
	log.Println("connected to MongoDB")

	if migrateOnly || !skipMigrations {
		runSchemaSetup()
	} else {
		log.Println("VANTAGE_SKIP_MIGRATIONS set: assuming migrations ran elsewhere")
	}

	if migrateOnly {
		log.Println("VANTAGE_MIGRATE_ONLY set: schema setup complete, exiting")
		return
	}

	serve()
}

// runSchemaSetup performs every write that must happen exactly once before the
// application serves: migrations, index builders and default step seeding. It
// is fatal on anything that would leave the schema half-moved.
func runSchemaSetup() {
	// Migrations 0001 to 0003 still speak the pre-rename shape (orgs, org_id),
	// so they must run before 0004 renames everything underneath them.
	if err := services.RunMigrations(); err != nil {
		log.Fatalf("migration failed: %v", err)
	}
	// 0002 must precede 0003: 0003 can create a "default" org, which pushes
	// 0002 into its ambiguous multi-org branch.
	if err := services.MigrateSettingsOrg(); err != nil {
		log.Fatalf("settings org migration failed: %v", err)
	}
	if err := services.MigrateMissedOrgScopes(); err != nil {
		log.Fatalf("missed org scope migration failed: %v", err)
	}

	// 0004 renames orgs to instances. It must run BEFORE the index builders:
	// EnsureAuthIndexes creates instances.slug, which would create an empty
	// instances collection and make 0004 refuse to rename onto it.
	migCtx, migCancel := context.WithTimeout(context.Background(), 10*time.Minute)
	migErr := services.MigrateOrgToInstance(migCtx, db.Database)
	migCancel()
	if migErr != nil {
		log.Fatalf("instance rename migration failed: %v", migErr)
	}

	assertCtx, assertCancel := context.WithTimeout(context.Background(), 30*time.Second)
	assertErr := services.AssertNoScopedCollectionMissed(assertCtx, db.Database)
	assertCancel()
	if assertErr != nil {
		log.Fatalf("scoped collection check failed: %v", assertErr)
	}

	if err := services.EnsureAuthIndexes(); err != nil {
		log.Fatalf("failed to ensure auth indexes: %v", err)
	}

	// 0005 runs AFTER EnsureAuthIndexes: the unique (instance_id, provider_id)
	// index must exist before anything inserts providers, or a concurrent
	// re-run could double-insert before the index is there to refuse it.
	if err := services.MigrateAuthProviders(); err != nil {
		log.Fatalf("auth provider migration failed: %v", err)
	}

	if err := services.EnsureSecretIndexes(); err != nil {
		log.Printf("warning: failed to ensure secret indexes: %v", err)
	}

	if err := services.EnsureServerIndexes(); err != nil {
		log.Printf("warning: failed to ensure server indexes: %v", err)
	}

	if err := services.EnsureSettingsIndexes(); err != nil {
		log.Fatalf("failed to ensure settings indexes: %v", err)
	}

	if err := services.EnsureWorkflowIndexes(); err != nil {
		log.Printf("warning: failed to ensure workflow indexes: %v", err)
	}

	if err := services.EnsureVulnIndexes(); err != nil {
		log.Printf("warning: failed to ensure vuln indexes: %v", err)
	}

	if err := services.EnsureWorkloadIndexes(); err != nil {
		log.Printf("warning: failed to ensure workload indexes: %v", err)
	}

	if instanceIDs, err := services.ListInstanceIDs(); err != nil {
		log.Printf("warning: failed to list instances for default step seeding: %v", err)
	} else {
		for _, instanceID := range instanceIDs {
			if created, updated, err := services.SeedDefaultSteps(instanceID); err != nil {
				log.Printf("warning: failed to seed default steps for instance %s: %v", instanceID, err)
			} else {
				log.Printf("default steps seeded for instance %s: %d created, %d updated", instanceID, created, updated)
			}
		}
	}

}

func serve() {
	redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
	redisUser := os.Getenv("REDIS_USERNAME")
	redisPass := os.Getenv("REDIS_PASSWORD")
	if err := auth.InitRedis(redisAddr, redisUser, redisPass); err != nil {
		log.Fatalf("failed to connect to Redis: %v", err)
	}
	log.Println("connected to Redis")

	// The bus carries agent commands and step results between replicas. It is
	// not optional even on a single-replica deployment: dispatch takes the same
	// path either way, so the code exercised in production is the code
	// exercised everywhere.
	if err := bus.Init(redisAddr, redisUser, redisPass); err != nil {
		log.Fatalf("failed to connect the message bus: %v", err)
	}
	log.Printf("message bus ready as node %s", bus.NodeID())

	// Cancelled on SIGTERM/SIGINT. Everything below that takes a context — the
	// housekeeping jobs, the leader lock — stops when the pod is asked to.
	ctx, shutdown := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer shutdown()

	stopGRPC, err := grpcserver.StartGRPC(9090)
	if err != nil {
		log.Fatalf("gRPC server error: %v", err)
	}

	// Everything below runs on exactly one replica at a time.
	//
	// These are cluster-singleton jobs, not per-pod work: N replicas would mean
	// every monitor check firing N times, every incident notification delivered
	// to the customer N times, every retention sweep deleting concurrently, and
	// N reapers racing to purge the same instance. They share one lock rather
	// than holding four, because they are one role — housekeeping — and
	// splitting them would only spread that role across pods for no benefit.
	bus.RunAsLeader(ctx, "housekeeping", func(jobCtx context.Context) {
		services.StartLogSweeper(jobCtx)
		services.StartAuditSweeper(jobCtx)
		services.StartReaper(jobCtx)
		monitorsched.Start(jobCtx)
		workflowsched.Start(jobCtx, workflowsched.Deps{
			TriggerWorkflow: services.TriggerWorkflow,
			LogEvent:        services.LogEvent,
		})

		vulnsched.Start(jobCtx, vulnsched.Deps{
			LogEvent:   services.LogEvent,
			SendDigest: services.SendVulnDigest,
		})
		services.StartVulnSweeper(jobCtx)

		ticker := time.NewTicker(2 * time.Minute)
		defer ticker.Stop()
		for {
			select {
			case <-jobCtx.Done():
				return
			case <-ticker.C:
				if err := services.MarkOfflineServers(); err != nil {
					log.Printf("mark offline error: %v", err)
				}
			}
		}
	})

	r := gin.New()
	r.Use(gin.Recovery())
	r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}}))
	r.Use(corsMiddleware())
	api.RegisterRoutes(r)

	srv := &http.Server{Addr: ":8080", Handler: r}
	go func() {
		log.Println("REST server listening on :8080")
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("REST server error: %v", err)
		}
	}()

	<-ctx.Done()
	log.Println("shutdown signal received")

	// gRPC first, and this ordering is the point of the whole exercise. Stopping
	// it runs each CommandStream handler's deferred release, which clears that
	// agent's presence claim; until that happens another replica will keep
	// dispatching commands to this process. Draining HTTP first would leave the
	// claims held for the length of the drain.
	stopGRPC()

	drainCtx, cancelDrain := context.WithTimeout(context.Background(), httpDrainTimeout)
	defer cancelDrain()
	if err := srv.Shutdown(drainCtx); err != nil {
		log.Printf("REST server shutdown: %v", err)
	}
	log.Println("shutdown complete")
}

// How long in-flight REST requests are given to finish. Console tunnels are
// long-lived WebSockets that will not end on their own, so this is a ceiling
// rather than a target; the relays behind them are already gone by this point.
const httpDrainTimeout = 10 * time.Second

func corsMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Header("Access-Control-Allow-Origin", "*")
		c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
		c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
		if c.Request.Method == "OPTIONS" {
			c.AbortWithStatus(204)
			return
		}
		c.Next()
	}
}

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

// boolEnv reads a flag env var. Anything other than a recognised truthy value
// is false, so a typo leaves the safe default (migrate here, serve here) rather
// than silently skipping schema setup.
func boolEnv(key string) bool {
	switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
	case "1", "true", "yes", "on":
		return true
	}
	return false
}
