package main

// Per-agent host-port allocator.
//
// Why this exists: a probe-only "is the port currently bound?" check
// fails when servers are stopped — their intent isn't visible. Two ARK
// servers, one running on 7777, one stopped (also wanted 7777). Probe
// sees 7777 bound and bumps the new instance to 7778; later, the
// stopped server starts and now ITS 7777 collides with whatever now
// owns 7778. Cascading misery.
//
// The fix is to make the panel the source of truth for port assignments:
// every instance row carries its `assigned_ports` jsonb, and at create
// time the allocator considers BOTH live socket probes AND the
// per-agent reservation set from the DB.
//
// Operators set a per-agent port window (start, end) via the agent edit
// modal — typically 7000-7999 for one box, 8000-8999 for another. New
// instances get ports from that window in declaration order. Falls
// back to the global default 7000-9999 if the agent has no window set.

import (
	"context"
	"fmt"
	"net"

	"github.com/dbledeez/panel/controller/internal/db"
	modulepkg "github.com/dbledeez/panel/pkg/module"
	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

// Default window when an agent has no port range configured. Wide
// enough to comfortably host ~50 game servers, narrow enough to keep
// out of OS-reserved high ranges.
const (
	defaultPortRangeStart = 7000
	defaultPortRangeEnd   = 9999
)

// allocatePorts picks host ports for every port the manifest declares,
// respecting:
//   - the agent's configured port window (or default 7000-9999)
//   - host ports already reserved by OTHER instances on this same agent
//     (read from the DB — captures stopped instances too, the whole point)
//   - any operator-supplied overrides in `requested` (which usually come
//     from the changePorts UI; preserved unless they collide)
//   - live socket probes (catches non-panel processes squatting a port)
//
// Returns a fresh PortMap slice the caller passes to the agent in
// InstanceCreate.Ports — the agent uses these verbatim. Also returns
// a name→hostport map for persistence.
//
// Returns an error if the window is exhausted for any port — operator
// must widen the range or delete some instances.
func allocatePorts(
	ctx context.Context,
	dbHandle *db.DB,
	agentID, instanceID string,
	manifest *modulepkg.Manifest,
	requested []*panelv1.PortMap,
) ([]*panelv1.PortMap, map[string]uint32, error) {
	if manifest == nil || manifest.Runtime.Docker == nil {
		return nil, map[string]uint32{}, nil
	}

	// Agent port range — falls back to global default.
	rangeStart, rangeEnd := defaultPortRangeStart, defaultPortRangeEnd
	if dbHandle != nil {
		if s, e, err := dbHandle.GetAgentPortRange(ctx, agentID); err == nil && s > 0 && e > 0 {
			rangeStart, rangeEnd = int(s), int(e)
		}
	}

	// Build the "already-taken" set from every OTHER instance on this
	// agent. Includes stopped ones — that's the whole point.
	taken := map[string]bool{} // key: "tcp:7777"
	if dbHandle != nil {
		rows, err := dbHandle.ListInstances(ctx, agentID)
		if err == nil {
			for _, r := range rows {
				if r.InstanceID == instanceID {
					continue // exclude self
				}
				for _, hp := range r.AssignedPorts {
					if hp == 0 {
						continue
					}
					// Mark BOTH protocols as taken — we don't know from
					// AssignedPorts alone whether it was tcp or udp, and
					// at the host level a port is either bound or not.
					taken[fmt.Sprintf("tcp:%d", hp)] = true
					taken[fmt.Sprintf("udp:%d", hp)] = true
				}
			}
		}
	}

	// Operator-supplied requested overrides — usually only on changePorts.
	// We honor them when they fit the window AND aren't taken; otherwise
	// fall back to allocator. Letting an operator pin a port outside the
	// window would defeat the whole "this agent uses 7000-7999" guarantee.
	requestedByName := map[string]uint32{}
	for _, p := range requested {
		if p != nil && p.Name != "" && p.HostPort != 0 {
			requestedByName[p.Name] = p.HostPort
		}
	}

	out := make([]*panelv1.PortMap, 0, len(manifest.Ports))
	assigned := map[string]uint32{}
	for _, p := range manifest.Ports {
		if p.Name == "" || p.Default == 0 {
			continue
		}
		proto := p.Proto
		if proto != "tcp" && proto != "udp" {
			proto = "tcp"
		}
		// Try the operator's requested port first if it's available.
		var chosen uint32
		if want, ok := requestedByName[p.Name]; ok {
			if isAvailable(int(want), proto, p.Internal, taken) {
				chosen = want
			}
		}
		// Otherwise scan the agent's window for a free port. We include
		// the manifest default in the scan (so single-instance setups
		// keep their canonical 7777) by starting at max(rangeStart, default)
		// only when the default falls inside the window — otherwise just
		// scan the window from start.
		if chosen == 0 {
			scanStart := rangeStart
			if int(p.Default) >= rangeStart && int(p.Default) <= rangeEnd {
				// Try the canonical default first (preserves ports for
				// the first instance); if taken, fall through to scan.
				if isAvailable(int(p.Default), proto, p.Internal, taken) {
					chosen = uint32(p.Default)
				}
			}
			if chosen == 0 {
				for port := scanStart; port <= rangeEnd; port++ {
					if isAvailable(port, proto, p.Internal, taken) {
						chosen = uint32(port)
						break
					}
				}
			}
		}
		if chosen == 0 {
			return nil, nil, fmt.Errorf("no free %s port for %q in agent window %d-%d (all reserved or in use)", proto, p.Name, rangeStart, rangeEnd)
		}
		// Mark the chosen port as taken so sibling ports in this same
		// allocation cycle don't re-pick it.
		taken[fmt.Sprintf("tcp:%d", chosen)] = true
		taken[fmt.Sprintf("udp:%d", chosen)] = true
		assigned[p.Name] = chosen
		out = append(out, &panelv1.PortMap{
			Name:          p.Name,
			Proto:         proto,
			ContainerPort: uint32(p.Default), // container-side stays at the manifest default
			HostPort:      chosen,
			Internal:      p.Internal,
		})
	}
	return out, assigned, nil
}

// isAvailable returns true when port is not already in `taken` AND can
// actually be bound right now (catches non-panel processes squatting it).
func isAvailable(port int, proto string, internal bool, taken map[string]bool) bool {
	if port < 1 || port > 65535 {
		return false
	}
	key := fmt.Sprintf("%s:%d", proto, port)
	if taken[key] {
		return false
	}
	host := "0.0.0.0"
	if internal {
		host = "127.0.0.1"
	}
	addr := fmt.Sprintf("%s:%d", host, port)
	if proto == "udp" {
		l, err := net.ListenPacket("udp", addr)
		if err != nil {
			return false
		}
		_ = l.Close()
		return true
	}
	l, err := net.Listen("tcp", addr)
	if err != nil {
		return false
	}
	_ = l.Close()
	return true
}
