package main

// Orchestrates opnfwd port-forward bookkeeping when instances are created
// and deleted. Sits between the HTTP/gRPC create+delete handlers and the
// `controller/internal/opnfwd` HTTP client.
//
// Design constraints:
//   - Off by default. Settings.opnfwd_enabled = "true" to opt in. Without
//     it the orchestrator is a complete no-op (zero opnfwd traffic, zero
//     DB writes to instance_forwards).
//   - Per-instance failures don't fail the create/delete itself. opnfwd
//     could be unreachable, the agent's LAN IP could be unset, the public
//     port range could be unset — none of those should block the operator
//     from spinning up a server. Failures land as log warnings + a
//     stored-but-error status in instance_forwards (future: surface in UI).
//   - Best-effort delete. If opnfwd's delete returns an error we still
//     drop the local instance_forwards rows — the orphan in opnfwd is
//     visible in the Issues tab for a manual heal, and our local view
//     stays consistent with the now-deleted instance.

import (
	"context"
	"fmt"
	"log/slog"
	"strings"
	"time"

	"github.com/dbledeez/panel/controller/internal/db"
	"github.com/dbledeez/panel/controller/internal/opnfwd"
	modulepkg "github.com/dbledeez/panel/pkg/module"
)

// opnfwdOrchestrator wraps the opnfwd client + the panel DB + the module
// registry. nil receiver methods are safe (orchestrator is optional —
// httpServer ctor passes nil if the relevant deps aren't available).
type opnfwdOrchestrator struct {
	log     *slog.Logger
	db      *db.DB
	modules *modulepkg.Registry
}

func newOpnfwdOrchestrator(log *slog.Logger, dbHandle *db.DB, modules *modulepkg.Registry) *opnfwdOrchestrator {
	return &opnfwdOrchestrator{log: log, db: dbHandle, modules: modules}
}

// loadConfig pulls the (enabled, url, token) tuple from panel_settings.
// Returns enabled=false on any read error or any missing field — fail
// closed.
func (o *opnfwdOrchestrator) loadConfig(ctx context.Context) (enabled bool, client *opnfwd.Client) {
	if o == nil || o.db == nil {
		return false, nil
	}
	settings, err := o.db.ListSettings(ctx)
	if err != nil {
		o.log.Warn("opnfwd: load settings", "err", err)
		return false, nil
	}
	if !strings.EqualFold(settings["opnfwd_enabled"], "true") {
		return false, nil
	}
	url := strings.TrimSpace(settings["opnfwd_url"])
	token := strings.TrimSpace(settings["opnfwd_token"])
	if url == "" || token == "" {
		return false, nil
	}
	insecure := strings.EqualFold(settings["opnfwd_insecure_tls"], "true")
	return true, &opnfwd.Client{
		BaseURL:               url,
		Token:                 token,
		Timeout:               30 * time.Second,
		InsecureTLSSkipVerify: insecure,
	}
}

// publicPortsFor returns the manifest ports for moduleID that should be
// auto-forwarded. Filters out internal-only ports (RCON, telnet, control)
// — those are loopback inside the container and would never need a NAT.
func (o *opnfwdOrchestrator) publicPortsFor(moduleID string) []modulepkg.Port {
	if o == nil || o.modules == nil {
		return nil
	}
	m, ok := o.modules.Get(moduleID)
	if !ok || m == nil {
		return nil
	}
	out := make([]modulepkg.Port, 0, len(m.Ports))
	for _, p := range m.Ports {
		if p.Internal {
			continue
		}
		if p.Name == "" || p.Default == 0 {
			continue
		}
		out = append(out, p)
	}
	return out
}

// onInstanceCreated is the post-create hook. agentID identifies the agent
// the instance landed on (we look up its LANIP for the forward target).
// assignedPorts is the panel-allocator's chosen host ports. Called AFTER
// the create succeeds; failure here doesn't unwind the create.
func (o *opnfwdOrchestrator) onInstanceCreated(ctx context.Context, instanceID, agentID, moduleID string, assignedPorts map[string]uint32) {
	if o == nil {
		return
	}
	enabled, client := o.loadConfig(ctx)
	if !enabled {
		return
	}
	target, err := o.db.GetAgentLANIP(ctx, agentID)
	if err != nil || target == "" {
		o.log.Warn("opnfwd: skip auto-forward (agent has no lan_ip configured)",
			"instance_id", instanceID, "agent_id", agentID, "err", err)
		return
	}
	publicPorts := o.publicPortsFor(moduleID)
	if len(publicPorts) == 0 {
		o.log.Info("opnfwd: skip auto-forward (no public ports declared for module)",
			"instance_id", instanceID, "module_id", moduleID)
		return
	}

	for _, p := range publicPorts {
		hostPort, ok := assignedPorts[p.Name]
		if !ok || hostPort == 0 {
			o.log.Warn("opnfwd: skip port (no panel-allocated host port)",
				"instance_id", instanceID, "port_name", p.Name)
			continue
		}
		descr := fmt.Sprintf("panel-%s-%s", instanceID, p.Name)
		if len(descr) > 64 {
			descr = descr[:64]
		}
		req := opnfwd.AddRequest{
			Name:   descr,
			Port:   fmt.Sprintf("%d", hostPort),
			Target: target,
			Proto:  normalizeProto(p.Proto),
		}
		addCtx, cancel := context.WithTimeout(ctx, 35*time.Second)
		res, err := client.Add(addCtx, req)
		cancel()
		if err != nil {
			o.log.Warn("opnfwd: add forward failed",
				"instance_id", instanceID, "port_name", p.Name, "host_port", hostPort, "err", err)
			continue
		}
		if len(res.Pairs) == 0 {
			o.log.Warn("opnfwd: add returned 0 pairs",
				"instance_id", instanceID, "port_name", p.Name)
			continue
		}
		pair := res.Pairs[0]
		if err := o.db.UpsertInstanceForward(ctx, db.InstanceForwardRow{
			InstanceID: instanceID,
			PortName:   p.Name,
			NatUUID:    pair.NatUUID,
			FilterUUID: pair.FilterUUID,
			TargetIP:   target,
			HostPort:   int32(hostPort),
			Proto:      req.Proto,
			Descr:      descr,
		}); err != nil {
			o.log.Warn("opnfwd: persist instance_forward failed",
				"instance_id", instanceID, "port_name", p.Name, "err", err)
			continue
		}
		o.log.Info("opnfwd: forward added",
			"instance_id", instanceID, "port_name", p.Name,
			"host_port", hostPort, "target", target,
			"nat_uuid", pair.NatUUID, "filter_uuid", pair.FilterUUID)
	}
}

// onInstanceDeleting is the pre-delete hook. Removes every forward the
// panel believes it owns for this instance. Called BEFORE the agent's
// container delete fires, so the OPNsense state reflects the operator's
// intent the moment they click delete.
func (o *opnfwdOrchestrator) onInstanceDeleting(ctx context.Context, instanceID string) {
	if o == nil {
		return
	}
	enabled, client := o.loadConfig(ctx)
	rows, err := o.db.ListInstanceForwards(ctx, instanceID)
	if err != nil {
		o.log.Warn("opnfwd: list instance_forwards failed (continuing delete)",
			"instance_id", instanceID, "err", err)
		return
	}
	if len(rows) == 0 {
		return // nothing the panel owns for this instance — clean exit
	}
	if !enabled {
		// Panel has rows but opnfwd integration is disabled. Surface a
		// loud log line so the operator knows orphans will be left behind
		// if they delete now without re-enabling.
		o.log.Warn("opnfwd: disabled but instance has tracked forwards (will leave orphans on router)",
			"instance_id", instanceID, "forward_count", len(rows))
		return
	}
	items := make([]opnfwd.DeleteItem, 0, len(rows))
	for _, r := range rows {
		items = append(items, opnfwd.DeleteItem{NatUUID: r.NatUUID, FilterUUID: r.FilterUUID})
	}
	delCtx, cancel := context.WithTimeout(ctx, 35*time.Second)
	defer cancel()
	if err := client.BulkDelete(delCtx, items); err != nil {
		o.log.Warn("opnfwd: bulk-delete failed (continuing delete; router may have orphans)",
			"instance_id", instanceID, "count", len(items), "err", err)
		// Intentionally fall through to local cleanup — leaving local
		// rows that point at deleted instances is worse than orphan
		// rules on the router (Issues tab catches the latter).
	} else {
		o.log.Info("opnfwd: forwards removed", "instance_id", instanceID, "count", len(items))
	}
	if err := o.db.DeleteInstanceForwards(ctx, instanceID); err != nil {
		o.log.Warn("opnfwd: drop instance_forwards rows failed",
			"instance_id", instanceID, "err", err)
	}
}

// normalizeProto maps a manifest proto string ("tcp" / "udp" / "TCP" / "")
// to opnfwd's expected enum. Empty defaults to "tcp/udp" (covers both).
func normalizeProto(p string) string {
	switch strings.ToLower(strings.TrimSpace(p)) {
	case "tcp":
		return "tcp"
	case "udp":
		return "udp"
	case "tcp/udp", "both", "":
		return "tcp/udp"
	default:
		return "tcp/udp"
	}
}
