package main

// HTTP handlers for the opnfwd integration's admin surface. All session-
// gated (live behind `mux.Handle("/api/", h.auth.requireSession(...))`).
//
// The settings endpoints read/write the panel_settings table. The token
// is masked on read — operators see "<set>" or empty, never the raw
// value once it's saved (matching how Steam credentials are surfaced).

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"net"
	"net/http"
	"strings"
	"time"

	"github.com/dbledeez/panel/controller/internal/opnfwd"
)

// ---- GET /api/admin/opnfwd ----

func (h *httpServer) opnfwdGetSettings(w http.ResponseWriter, r *http.Request) {
	settings, err := h.db.ListSettings(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"enabled":      strings.EqualFold(settings["opnfwd_enabled"], "true"),
		"url":          settings["opnfwd_url"],
		"token_set":    settings["opnfwd_token"] != "",
		"insecure_tls": strings.EqualFold(settings["opnfwd_insecure_tls"], "true"),
	})
}

// ---- PUT /api/admin/opnfwd ----

type opnfwdSettingsReq struct {
	Enabled     *bool   `json:"enabled,omitempty"`
	URL         *string `json:"url,omitempty"`
	Token       *string `json:"token,omitempty"`
	InsecureTLS *bool   `json:"insecure_tls,omitempty"`
}

func (h *httpServer) opnfwdSetSettings(w http.ResponseWriter, r *http.Request) {
	var req opnfwdSettingsReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	ctx := r.Context()
	if req.URL != nil {
		u := strings.TrimRight(strings.TrimSpace(*req.URL), "/")
		if u != "" && !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
			writeError(w, http.StatusBadRequest, "bad_url", "url must start with http:// or https://")
			return
		}
		if err := h.db.SetSetting(ctx, "opnfwd_url", u); err != nil {
			writeError(w, http.StatusInternalServerError, "db", err.Error())
			return
		}
	}
	if req.Token != nil {
		// Empty token is allowed — clears the credential. Otherwise just trim.
		if err := h.db.SetSetting(ctx, "opnfwd_token", strings.TrimSpace(*req.Token)); err != nil {
			writeError(w, http.StatusInternalServerError, "db", err.Error())
			return
		}
	}
	if req.Enabled != nil {
		val := "false"
		if *req.Enabled {
			val = "true"
		}
		if err := h.db.SetSetting(ctx, "opnfwd_enabled", val); err != nil {
			writeError(w, http.StatusInternalServerError, "db", err.Error())
			return
		}
	}
	if req.InsecureTLS != nil {
		val := "false"
		if *req.InsecureTLS {
			val = "true"
		}
		if err := h.db.SetSetting(ctx, "opnfwd_insecure_tls", val); err != nil {
			writeError(w, http.StatusInternalServerError, "db", err.Error())
			return
		}
	}
	h.opnfwdGetSettings(w, r)
}

// ---- POST /api/admin/opnfwd/test ----

// opnfwdTest hits the configured opnfwd /api/me to verify URL + token + auth.
// Returns the round-trip latency on success so the UI can show "12ms".
func (h *httpServer) opnfwdTest(w http.ResponseWriter, r *http.Request) {
	settings, err := h.db.ListSettings(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	url := strings.TrimSpace(settings["opnfwd_url"])
	token := strings.TrimSpace(settings["opnfwd_token"])
	if url == "" {
		writeError(w, http.StatusBadRequest, "no_url", "opnfwd URL not configured")
		return
	}
	if token == "" {
		writeError(w, http.StatusBadRequest, "no_token", "opnfwd token not configured")
		return
	}
	client := &opnfwd.Client{
		BaseURL:               url,
		Token:                 token,
		Timeout:               10 * time.Second,
		InsecureTLSSkipVerify: strings.EqualFold(settings["opnfwd_insecure_tls"], "true"),
	}
	ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
	defer cancel()
	start := time.Now()
	if err := client.Health(ctx); err != nil {
		writeJSON(w, http.StatusOK, map[string]any{
			"ok":         false,
			"err":        err.Error(),
			"elapsed_ms": int(time.Since(start).Milliseconds()),
		})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"ok":         true,
		"elapsed_ms": int(time.Since(start).Milliseconds()),
	})
}

// ---- GET /api/admin/opnfwd/forwards ----

// opnfwdListForwards returns the panel-tracked side of the truth (every row
// in instance_forwards). Used by the admin settings card to show a count
// and by the reconcile path. Does NOT call opnfwd — pure local read.
func (h *httpServer) opnfwdListForwards(w http.ResponseWriter, r *http.Request) {
	rows, err := h.db.ListAllInstanceForwards(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	out := make([]map[string]any, 0, len(rows))
	for _, r := range rows {
		out = append(out, map[string]any{
			"instance_id": r.InstanceID,
			"port_name":   r.PortName,
			"nat_uuid":    r.NatUUID,
			"filter_uuid": r.FilterUUID,
			"target_ip":   r.TargetIP,
			"host_port":   r.HostPort,
			"proto":       r.Proto,
			"descr":       r.Descr,
			"created_at":  r.CreatedAt.UTC().Format(time.RFC3339),
		})
	}
	writeJSON(w, http.StatusOK, map[string]any{"forwards": out})
}

// ---- POST /api/admin/opnfwd/reconcile ----

// opnfwdReconcile diffs panel-tracked forwards against opnfwd's view.
// Three categories surface:
//   - matched: panel + opnfwd agree (uuid + port + target + descr line up)
//   - panel_only: panel has the row but opnfwd doesn't (rule was deleted
//     out-of-band; panel should drop the row or recreate)
//   - opnfwd_only: opnfwd has the rule but panel doesn't track it. Could
//     be (a) operator-added rule via opnfwd UI, (b) leftover from before
//     the integration was enabled, (c) a panel rule whose DB row was lost.
//
// Reports only — never auto-mutates. Operator decides what to do.
func (h *httpServer) opnfwdReconcile(w http.ResponseWriter, r *http.Request) {
	settings, err := h.db.ListSettings(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	url := strings.TrimSpace(settings["opnfwd_url"])
	token := strings.TrimSpace(settings["opnfwd_token"])
	if url == "" || token == "" {
		writeError(w, http.StatusBadRequest, "not_configured", "opnfwd URL or token not configured")
		return
	}
	client := &opnfwd.Client{
		BaseURL:               url,
		Token:                 token,
		Timeout:               20 * time.Second,
		InsecureTLSSkipVerify: strings.EqualFold(settings["opnfwd_insecure_tls"], "true"),
	}
	ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
	defer cancel()
	remote, err := client.List(ctx)
	if err != nil {
		writeError(w, http.StatusBadGateway, "opnfwd_list", err.Error())
		return
	}
	local, err := h.db.ListAllInstanceForwards(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	// Index by nat_uuid for matching.
	byNAT := map[string]bool{}
	for _, lf := range local {
		if lf.NatUUID != "" {
			byNAT[lf.NatUUID] = true
		}
	}
	matched := 0
	opnfwdOnly := []map[string]any{}
	for _, rf := range remote {
		if rf.NatUUID != "" && byNAT[rf.NatUUID] {
			matched++
			continue
		}
		// Only flag rules whose name starts with our `panel-` prefix as
		// candidate orphans — operator-managed forwards are out of scope
		// for the panel's reconcile.
		if strings.HasPrefix(rf.Name, "panel-") {
			opnfwdOnly = append(opnfwdOnly, map[string]any{
				"name":        rf.Name,
				"port":        rf.Port,
				"target":      rf.Target,
				"proto":       rf.Proto,
				"nat_uuid":    rf.NatUUID,
				"filter_uuid": rf.FilterUUID,
			})
		}
	}
	// Build remote-uuid set for finding panel-only orphans.
	remoteUUIDs := map[string]bool{}
	for _, rf := range remote {
		if rf.NatUUID != "" {
			remoteUUIDs[rf.NatUUID] = true
		}
	}
	panelOnly := []map[string]any{}
	for _, lf := range local {
		if lf.NatUUID == "" || !remoteUUIDs[lf.NatUUID] {
			panelOnly = append(panelOnly, map[string]any{
				"instance_id": lf.InstanceID,
				"port_name":   lf.PortName,
				"nat_uuid":    lf.NatUUID,
				"host_port":   lf.HostPort,
				"target_ip":   lf.TargetIP,
			})
		}
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"matched":      matched,
		"panel_only":   panelOnly,
		"opnfwd_only":  opnfwdOnly,
		"local_total":  len(local),
		"remote_total": len(remote),
	})
}

// ---- POST /api/admin/opnfwd/reforward/{id} ----

// opnfwdReforward re-syncs ONE instance's opnfwd forwards: clears any tracked
// rows + their OPNsense rules, then re-adds from the instance's current
// assigned ports. Repairs forwards lost out-of-band — e.g. an instance that
// was deleted+recreated through a path that didn't fire the create hook
// (buffered re-create after a delete, cluster recreate). Synchronous — waits
// for the OPNsense round-trips so the caller sees a finished result.
func (h *httpServer) opnfwdReforward(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		writeError(w, http.StatusBadRequest, "missing_id", "instance id required in path")
		return
	}
	if h.opnfwd == nil {
		writeError(w, http.StatusBadRequest, "not_configured", "opnfwd orchestrator unavailable")
		return
	}
	rows, err := h.db.ListInstances(r.Context(), "")
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	idx := -1
	for i := range rows {
		if rows[i].InstanceID == id {
			idx = i
			break
		}
	}
	if idx < 0 {
		writeError(w, http.StatusNotFound, "not_found", "instance not in db")
		return
	}
	row := rows[idx]
	octx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
	defer cancel()
	h.opnfwd.onInstanceDeleting(octx, id)
	h.opnfwd.onInstanceCreated(octx, id, row.AgentID, row.ModuleID, row.AssignedPorts)
	writeJSON(w, http.StatusOK, map[string]any{
		"ok":          true,
		"instance_id": id,
		"agent_id":    row.AgentID,
		"ports":       row.AssignedPorts,
	})
}

// ---- PATCH /api/agents/{id}/lan-ip ----

type setAgentLANIPReq struct {
	LANIP string `json:"lan_ip"`
}

func (h *httpServer) opnfwdSetAgentLANIP(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		writeError(w, http.StatusBadRequest, "missing_id", "agent id required in path")
		return
	}
	var req setAgentLANIPReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	ip := strings.TrimSpace(req.LANIP)
	if ip != "" {
		parsed := net.ParseIP(ip)
		if parsed == nil || parsed.To4() == nil {
			writeError(w, http.StatusBadRequest, "bad_ip", "lan_ip must be IPv4 dotted-quad")
			return
		}
		if !parsed.IsPrivate() {
			writeError(w, http.StatusBadRequest, "bad_ip", "lan_ip must be RFC1918 (10/8, 172.16/12, 192.168/16)")
			return
		}
	}
	if err := h.db.SetAgentLANIP(r.Context(), id, ip); err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"agent_id": id, "lan_ip": ip})
}
