// Package opnfwd is a thin HTTP client wrapping an opnfwd service (an
// OPNsense port-forward automation helper) running on a LAN host. Used by
// the panel to auto-create / auto-delete OPNsense port forwards when a
// game-server instance is created or deleted, so operators don't have to
// click through OPNsense's two-page rule-editor by hand for every server.
//
// This integration is OPTIONAL — it only activates when an opnfwd URL +
// token are configured in panel settings. Auth is via a Bearer token the
// panel persists in panel_settings.opnfwd_token.
//
// The client never retries on failure — opnfwd writes a backup of
// `/conf/config.xml` BEFORE every mutation, so a partial failure is
// recoverable from opnfwd's History modal. Silent retries would risk
// duplicate rule creation.
package opnfwd

import (
	"bytes"
	"context"
	"crypto/tls"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"

	"github.com/dbledeez/panel/pkg/version"
	"strings"
	"time"
)

// Client points at an opnfwd HTTP endpoint and signs every request with the
// Bearer token. Zero-value Client is unusable — callers must populate at
// least BaseURL and Token.
type Client struct {
	BaseURL string        // e.g. "http://<opnfwd-host>:14995" or "https://<your-reverse-proxy>"
	Token   string        // matches opnfwd's [api] token in secrets/config.toml
	Timeout time.Duration // default 30s if zero
	// Optional: when calling the public reverse-proxy URL with a self-signed
	// cert chain, set true to skip TLS verify. Leave false for the LAN URL.
	InsecureTLSSkipVerify bool
}

// AddRequest mirrors opnfwd's POST /api/forwards body.
type AddRequest struct {
	Name   string `json:"name"`   // 1-64 chars, used as the rule's <descr>
	Port   string `json:"port"`   // single port like "7021" or range "10000-10005"
	Target string `json:"target"` // RFC1918 IPv4 destination (the LAN IP of the panel agent)
	Proto  string `json:"proto"`  // "tcp" | "udp" | "tcp/udp"
}

// AddResult mirrors opnfwd's success response — see opnfwd/app/opnsense.py
// `add_forward` return dict. The `pairs` array has one entry per port (a
// range expansion creates N pairs); single-port adds always return len 1.
type AddResult struct {
	Output string         `json:"output"`
	Pairs  []ForwardPair  `json:"pairs"`
	Count  int            `json:"count"`
}

// ForwardPair is what we persist in panel's instance_forwards table.
type ForwardPair struct {
	Port       string `json:"port"`
	NatUUID    string `json:"nat_uuid"`
	FilterUUID string `json:"filter_uuid"`
}

// DeleteRequest is the body of POST /api/forwards/bulk-delete.
type DeleteRequest struct {
	Items []DeleteItem `json:"items"`
}

// DeleteItem is one forward to remove. Either uuid alone is fine — opnfwd
// drops whichever side it can find. Passing both is the recommended form
// (the panel always has both because it persisted them at create time).
type DeleteItem struct {
	NatUUID    string `json:"nat_uuid,omitempty"`
	FilterUUID string `json:"filter_uuid,omitempty"`
}

// ForwardOut is one row from GET /api/forwards. Used by the reconcile path
// to compare what opnfwd has against what panel believes it has.
type ForwardOut struct {
	Name       string `json:"name"`
	Port       string `json:"port"`
	Target     string `json:"target"`
	Proto      string `json:"proto"`
	NatUUID    string `json:"nat_uuid,omitempty"`
	FilterUUID string `json:"filter_uuid,omitempty"`
}

// ---- public API ----

// Health calls /api/me — round-trips the token and confirms the service is
// reachable. Use this from the panel's settings page to surface a green/red
// dot before the operator flips the toggle.
func (c *Client) Health(ctx context.Context) error {
	if c.BaseURL == "" {
		return errors.New("opnfwd client: BaseURL is empty")
	}
	body, status, err := c.do(ctx, http.MethodGet, "/api/me", nil)
	if err != nil {
		return err
	}
	if status != http.StatusOK {
		return fmt.Errorf("health: HTTP %d: %s", status, truncate(body, 200))
	}
	return nil
}

// Add creates ONE forward (single port). The panel always calls Add with a
// single port — it tracks each manifest port individually so a deleted
// instance can have its forwards removed precisely. opnfwd's range support
// is a UX nicety for humans; we don't use it.
func (c *Client) Add(ctx context.Context, req AddRequest) (AddResult, error) {
	if req.Name == "" || req.Port == "" || req.Target == "" {
		return AddResult{}, errors.New("opnfwd Add: name, port, target are required")
	}
	if req.Proto == "" {
		req.Proto = "tcp/udp"
	}
	body, status, err := c.do(ctx, http.MethodPost, "/api/forwards", req)
	if err != nil {
		return AddResult{}, err
	}
	if status != http.StatusOK {
		return AddResult{}, fmt.Errorf("add forward: HTTP %d: %s", status, truncate(body, 400))
	}
	var out AddResult
	if err := json.Unmarshal(body, &out); err != nil {
		return AddResult{}, fmt.Errorf("decode add response: %w (body: %s)", err, truncate(body, 200))
	}
	return out, nil
}

// BulkDelete removes N forwards in one atomic mutation. opnfwd writes a
// /conf/config.xml backup before this fires.
func (c *Client) BulkDelete(ctx context.Context, items []DeleteItem) error {
	if len(items) == 0 {
		return nil
	}
	body, status, err := c.do(ctx, http.MethodPost, "/api/forwards/bulk-delete", DeleteRequest{Items: items})
	if err != nil {
		return err
	}
	if status != http.StatusOK {
		return fmt.Errorf("bulk delete: HTTP %d: %s", status, truncate(body, 400))
	}
	return nil
}

// List returns every forward opnfwd knows about. The reconcile button
// compares this to what the panel persisted in instance_forwards.
func (c *Client) List(ctx context.Context) ([]ForwardOut, error) {
	body, status, err := c.do(ctx, http.MethodGet, "/api/forwards", nil)
	if err != nil {
		return nil, err
	}
	if status != http.StatusOK {
		return nil, fmt.Errorf("list forwards: HTTP %d: %s", status, truncate(body, 200))
	}
	var out []ForwardOut
	if err := json.Unmarshal(body, &out); err != nil {
		return nil, fmt.Errorf("decode list response: %w", err)
	}
	return out, nil
}

// ---- internals ----

func (c *Client) do(ctx context.Context, method, path string, body any) ([]byte, int, error) {
	var bodyReader io.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			return nil, 0, fmt.Errorf("marshal body: %w", err)
		}
		bodyReader = bytes.NewReader(b)
	}
	url := strings.TrimRight(c.BaseURL, "/") + path
	req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
	if err != nil {
		return nil, 0, fmt.Errorf("new request: %w", err)
	}
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	req.Header.Set("Authorization", "Bearer "+c.Token)
	req.Header.Set("User-Agent", "panel-controller/"+version.Version+" (+opnfwd integration)")

	timeout := c.Timeout
	if timeout == 0 {
		timeout = 30 * time.Second
	}
	transport := &http.Transport{}
	if c.InsecureTLSSkipVerify {
		transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
	}
	httpClient := &http.Client{Timeout: timeout, Transport: transport}
	resp, err := httpClient.Do(req)
	if err != nil {
		return nil, 0, fmt.Errorf("http: %w", err)
	}
	defer resp.Body.Close()
	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, resp.StatusCode, fmt.Errorf("read body: %w", err)
	}
	return respBody, resp.StatusCode, nil
}

func truncate(b []byte, n int) string {
	if len(b) <= n {
		return string(b)
	}
	return string(b[:n]) + "…"
}
