package web

// Offsite connectivity: the /offsite registry page. Originally S2 read-only (doc 06 §8);
// since v0.47.0 the page also MANAGES wg_endpoints rows (add/edit/delete) — a deliberate
// posture change (operator decision, hub-UI reorganization task). Scope guard: management
// covers the ENDPOINT RECORDS only. Peer allocation, the wgsync reconciler push, and the
// desired-state merge all still use the LOWEST endpoint_id (GetWGEndpoint) — per-endpoint
// allocation is an explicitly deferred future arc (wg_peers.endpoint_id migration).

import (
	"database/sql"
	"net/http"
	"net/netip"
	"regexp"
	"strconv"
	"strings"

	"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)

// validEndpointID matches the endpoint_id shape ("ep0" style — lowercase, digits, hyphens).
var validEndpointID = regexp.MustCompile(`^[a-z0-9-]+$`)

// truncateMiddle shortens a long opaque value (pubkeys) for table display, keeping both ends —
// the full value always rides the title attribute.
func truncateMiddle(s string, keep int) string {
	if len(s) <= 2*keep+1 {
		return s
	}
	return s[:keep] + "…" + s[len(s)-keep:]
}

// offsitePeerRow is the per-peer view model.
type offsitePeerRow struct {
	Pubkey      string
	PubkeyShort string
	AssignedIP  string // with /32
	HostID      string // "" = unbound
	Note        string
	CreatedAt   string
	EndpointID  string // derived: first endpoint (id order) whose subnet contains the IP; "" = none
}

// offsiteEndpointCard is the per-endpoint view model.
type offsiteEndpointCard struct {
	EndpointID        string
	DNSName           string
	WGPort            int
	ServerPubkey      string
	ServerPubkeyShort string
	TunnelSubnet      string
	PBSTunnelIP       string
	PeerCount         int
}

// endpointForIP returns the id of the first endpoint (lowest endpoint_id — the slice is
// already id-ordered) whose tunnel subnet contains the peer IP; "" when none matches.
func endpointForIP(endpoints []store.WGEndpoint, ip string) string {
	addr, err := netip.ParseAddr(ip)
	if err != nil {
		return ""
	}
	for _, ep := range endpoints {
		prefix, err := netip.ParsePrefix(ep.TunnelSubnet)
		if err != nil {
			continue
		}
		if prefix.Contains(addr) {
			return ep.EndpointID
		}
	}
	return ""
}

// peersInSubnet counts registered peers whose IP lies inside the given CIDR — the guard
// input for endpoint delete + subnet edit. A parse error counts as 0 matches for that peer.
func (s *Server) peersInSubnet(subnet string) (int, error) {
	prefix, err := netip.ParsePrefix(subnet)
	if err != nil {
		return 0, err
	}
	peers, err := s.store.ListWGPeers()
	if err != nil {
		return 0, err
	}
	n := 0
	for _, p := range peers {
		if addr, err := netip.ParseAddr(p.AssignedIP); err == nil && prefix.Contains(addr) {
			n++
		}
	}
	return n, nil
}

// handleOffsite renders the offsite registry: one card per endpoint + the peer table.
func (s *Server) handleOffsite(w http.ResponseWriter, r *http.Request) {
	endpoints, err := s.store.ListWGEndpoints()
	if err != nil {
		s.logger.Printf("[ERROR] offsite: endpoint list: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	peers, err := s.store.ListWGPeers()
	if err != nil {
		s.logger.Printf("[ERROR] offsite: peer list: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	cards := make([]offsiteEndpointCard, 0, len(endpoints))
	for _, ep := range endpoints {
		card := offsiteEndpointCard{
			EndpointID:        ep.EndpointID,
			DNSName:           ep.DNSName,
			WGPort:            ep.WGPort,
			ServerPubkey:      ep.ServerPubkey,
			ServerPubkeyShort: truncateMiddle(ep.ServerPubkey, 10),
			TunnelSubnet:      ep.TunnelSubnet,
			PBSTunnelIP:       ep.PBSTunnelIP,
		}
		for _, p := range peers {
			if endpointForIP(endpoints, p.AssignedIP) == ep.EndpointID {
				card.PeerCount++
			}
		}
		cards = append(cards, card)
	}

	rows := make([]offsitePeerRow, 0, len(peers))
	for _, p := range peers {
		rows = append(rows, offsitePeerRow{
			Pubkey:      p.Pubkey,
			PubkeyShort: truncateMiddle(p.Pubkey, 10),
			AssignedIP:  p.AssignedIP + "/32",
			HostID:      p.HostID,
			Note:        p.Note,
			CreatedAt:   p.CreatedAt,
			EndpointID:  endpointForIP(endpoints, p.AssignedIP),
		})
	}

	boxView, custRows := s.offsiteBoxData() // R-5 restic pool-box aggregate panel (Restic tab)
	pbsView := s.pbsdrBoxData()             // R-5 v0.65.0 PBS-DR datastore panel (PBS DR tab)

	data := map[string]interface{}{
		"Endpoints":     cards,
		"HasEndpoints":  len(cards) > 0,
		"Peers":         rows,
		"OffsiteBox":    boxView,
		"OffsiteCusts":  custRows,
		"PBSBox":        pbsView,
		"CSRFToken":     s.getCSRFToken(r),
		"Flash":         r.URL.Query().Get("flash"),
	}
	if err := s.templates.ExecuteTemplate(w, "offsite.html", data); err != nil {
		s.logger.Printf("[ERROR] offsite.html template: %v", err)
	}
}

// handleOffsiteEndpointSave — POST /offsite/endpoints (v0.47.0): add or edit an endpoint
// record via SetWGEndpoint's existing upsert. Validation refuses with 400 and stores
// NOTHING; a tunnel_subnet change on an existing endpoint is refused with 409 while any
// peer is allocated inside the CURRENT subnet (their /32s would dangle).
func (s *Server) handleOffsiteEndpointSave(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	ep := store.WGEndpoint{
		EndpointID:   strings.TrimSpace(r.FormValue("endpoint_id")),
		DNSName:      strings.TrimSpace(r.FormValue("dns_name")),
		ServerPubkey: strings.TrimSpace(r.FormValue("server_pubkey")),
		TunnelSubnet: strings.TrimSpace(r.FormValue("tunnel_subnet")),
		PBSTunnelIP:  strings.TrimSpace(r.FormValue("pbs_tunnel_ip")),
	}

	refuse := func(msg string) {
		s.logger.Printf("[WARN] offsite endpoint save refused (%s): %s", ep.EndpointID, msg)
		http.Error(w, msg+" — nothing stored.", http.StatusBadRequest)
	}
	if !validEndpointID.MatchString(ep.EndpointID) {
		refuse("Invalid endpoint id (use lowercase letters, digits, hyphens)")
		return
	}
	if ep.DNSName == "" {
		refuse("DNS name is required")
		return
	}
	port, err := strconv.Atoi(strings.TrimSpace(r.FormValue("wg_port")))
	if err != nil || port < 1 || port > 65535 {
		refuse("Invalid WireGuard port (1–65535)")
		return
	}
	ep.WGPort = port
	if ep.ServerPubkey == "" {
		refuse("Server public key is required")
		return
	}
	prefix, err := netip.ParsePrefix(ep.TunnelSubnet)
	if err != nil {
		refuse("Invalid tunnel subnet (CIDR, e.g. 10.77.0.0/24)")
		return
	}
	pbsAddr, err := netip.ParseAddr(ep.PBSTunnelIP)
	if err != nil || !prefix.Contains(pbsAddr) {
		refuse("PBS tunnel address must be an IP inside the tunnel subnet")
		return
	}

	// Subnet-change guard on edit: peers allocated inside the CURRENT subnet pin it.
	existing, err := s.store.ListWGEndpoints()
	if err != nil {
		s.logger.Printf("[ERROR] offsite endpoint save: list: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	for _, cur := range existing {
		if cur.EndpointID != ep.EndpointID || cur.TunnelSubnet == ep.TunnelSubnet {
			continue
		}
		n, err := s.peersInSubnet(cur.TunnelSubnet)
		if err != nil {
			s.logger.Printf("[ERROR] offsite endpoint save: peer scan: %v", err)
			http.Error(w, "Internal error", http.StatusInternalServerError)
			return
		}
		if n > 0 {
			s.logger.Printf("[WARN] offsite endpoint %s subnet change refused: %d peer(s) allocated in %s", ep.EndpointID, n, cur.TunnelSubnet)
			http.Error(w, "Tunnel subnet unchanged: "+strconv.Itoa(n)+" peer(s) are allocated in the current subnet "+cur.TunnelSubnet+" — their addresses would dangle.", http.StatusConflict)
			return
		}
	}

	if err := s.store.SetWGEndpoint(&ep); err != nil {
		s.logger.Printf("[ERROR] offsite endpoint save %s: %v", ep.EndpointID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	s.logger.Printf("[INFO] offsite endpoint saved: %s (%s:%d, subnet %s)", ep.EndpointID, ep.DNSName, ep.WGPort, ep.TunnelSubnet)
	http.Redirect(w, r, "/offsite?flash=endpoint_saved", http.StatusSeeOther)
}

// handleOffsiteEndpointDelete — POST /offsite/endpoints/{id}/delete (v0.47.0). Refused
// with 409 while any peer's /32 lies inside the endpoint's subnet.
func (s *Server) handleOffsiteEndpointDelete(w http.ResponseWriter, r *http.Request, endpointID string) {
	endpoints, err := s.store.ListWGEndpoints()
	if err != nil {
		s.logger.Printf("[ERROR] offsite endpoint delete: list: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	var target *store.WGEndpoint
	for i := range endpoints {
		if endpoints[i].EndpointID == endpointID {
			target = &endpoints[i]
			break
		}
	}
	if target == nil {
		http.NotFound(w, r)
		return
	}
	n, err := s.peersInSubnet(target.TunnelSubnet)
	if err != nil {
		s.logger.Printf("[ERROR] offsite endpoint delete %s: peer scan: %v", endpointID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if n > 0 {
		s.logger.Printf("[WARN] offsite endpoint delete refused: %s has %d peer(s) in %s", endpointID, n, target.TunnelSubnet)
		http.Error(w, "Endpoint not deleted: "+strconv.Itoa(n)+" peer(s) are allocated in its subnet "+target.TunnelSubnet+".", http.StatusConflict)
		return
	}
	if err := s.store.DeleteWGEndpoint(endpointID); err != nil && err != sql.ErrNoRows {
		s.logger.Printf("[ERROR] offsite endpoint delete %s: %v", endpointID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	s.logger.Printf("[INFO] offsite endpoint deleted: %s", endpointID)
	http.Redirect(w, r, "/offsite?flash=endpoint_deleted", http.StatusSeeOther)
}
