package service

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"sync"
	"testing"

	"github.com/mhsanaei/3x-ui/v3/internal/database"
	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
)

// fakeNodePanel serves just enough of the node API for ReconcileNode: the
// inbound list plus update/del endpoints, recording which remote ids get
// deleted.
func fakeNodePanel(t *testing.T, tagToID map[string]int) (*httptest.Server, func() []int) {
	t.Helper()
	var mu sync.Mutex
	var deleted []int
	writeOK := func(w http.ResponseWriter, obj any) {
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
	}
	mux := http.NewServeMux()
	mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
		type row struct {
			Id  int    `json:"id"`
			Tag string `json:"tag"`
		}
		rows := make([]row, 0, len(tagToID))
		for tag, id := range tagToID {
			rows = append(rows, row{Id: id, Tag: tag})
		}
		writeOK(w, rows)
	})
	mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, _ *http.Request) {
		writeOK(w, nil)
	})
	mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) {
		id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/"))
		if err != nil {
			http.Error(w, "bad id", http.StatusBadRequest)
			return
		}
		mu.Lock()
		deleted = append(deleted, id)
		mu.Unlock()
		writeOK(w, nil)
	})
	ts := httptest.NewServer(mux)
	t.Cleanup(ts.Close)
	return ts, func() []int {
		mu.Lock()
		defer mu.Unlock()
		out := append([]int(nil), deleted...)
		sort.Ints(out)
		return out
	}
}

func reconcileTestNode(t *testing.T, ts *httptest.Server, name, mode string, tags []string) *model.Node {
	t.Helper()
	u, err := url.Parse(ts.URL)
	if err != nil {
		t.Fatalf("parse test server URL: %v", err)
	}
	port, err := strconv.Atoi(u.Port())
	if err != nil {
		t.Fatalf("parse test server port: %v", err)
	}
	n := &model.Node{
		Name:                name,
		Scheme:              "http",
		Address:             u.Hostname(),
		Port:                port,
		BasePath:            "/",
		ApiToken:            "tok",
		Enable:              true,
		AllowPrivateAddress: true,
		Status:              "online",
		InboundSyncMode:     mode,
		InboundTags:         tags,
	}
	if err := database.GetDB().Create(n).Error; err != nil {
		t.Fatalf("create node: %v", err)
	}
	return n
}

// In "selected" sync mode the panel never imports the unselected inbounds, so
// reconcile must not treat their absence from the local DB as a deletion: only
// a *selected* tag missing locally may be swept from the node.
func TestReconcileNode_SelectedModeLeavesUnselectedRemoteInbounds(t *testing.T) {
	setupConflictDB(t)

	ts, deletedIDs := fakeNodePanel(t, map[string]int{
		"keep":          1,
		"selected-gone": 2,
		"unmanaged":     3,
	})
	node := reconcileTestNode(t, ts, "sel-node", "selected", []string{"keep", "selected-gone"})
	seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)

	svc := InboundService{}
	if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
		t.Fatalf("ReconcileNode: %v", err)
	}

	got := deletedIDs()
	if len(got) != 1 || got[0] != 2 {
		t.Fatalf("deleted remote ids = %v, want [2] (unmanaged inbound 3 must survive)", got)
	}
}

// "all" mode keeps the original anti-entropy contract: every remote inbound
// missing from the local DB is deleted on the node.
func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) {
	setupConflictDB(t)

	ts, deletedIDs := fakeNodePanel(t, map[string]int{
		"keep":   1,
		"gone-a": 2,
		"gone-b": 3,
	})
	node := reconcileTestNode(t, ts, "all-node", "all", nil)
	seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)

	svc := InboundService{}
	if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
		t.Fatalf("ReconcileNode: %v", err)
	}

	got := deletedIDs()
	if len(got) != 2 || got[0] != 2 || got[1] != 3 {
		t.Fatalf("deleted remote ids = %v, want [2 3]", got)
	}
}

func TestEnsureInboundTagAllowed(t *testing.T) {
	setupConflictDB(t)
	db := database.GetDB()
	svc := NodeService{}

	selected := &model.Node{
		Name: "ensure-sel", Address: "127.0.0.1", Port: 2096, ApiToken: "tok",
		InboundSyncMode: "selected", InboundTags: []string{"a"},
	}
	if err := db.Create(selected).Error; err != nil {
		t.Fatalf("create node: %v", err)
	}

	if err := svc.EnsureInboundTagAllowed(selected.Id, "b"); err != nil {
		t.Fatalf("EnsureInboundTagAllowed add: %v", err)
	}
	var got model.Node
	if err := db.First(&got, selected.Id).Error; err != nil {
		t.Fatalf("reload node: %v", err)
	}
	if len(got.InboundTags) != 2 || got.InboundTags[0] != "a" || got.InboundTags[1] != "b" {
		t.Fatalf("InboundTags = %#v, want [a b]", got.InboundTags)
	}

	if err := svc.EnsureInboundTagAllowed(selected.Id, "a"); err != nil {
		t.Fatalf("EnsureInboundTagAllowed existing: %v", err)
	}
	if err := db.First(&got, selected.Id).Error; err != nil {
		t.Fatalf("reload node: %v", err)
	}
	if len(got.InboundTags) != 2 {
		t.Fatalf("existing tag must not duplicate, got %#v", got.InboundTags)
	}

	all := &model.Node{
		Name: "ensure-all", Address: "127.0.0.1", Port: 2097, ApiToken: "tok",
		InboundSyncMode: "all",
	}
	if err := db.Create(all).Error; err != nil {
		t.Fatalf("create node: %v", err)
	}
	if err := svc.EnsureInboundTagAllowed(all.Id, "x"); err != nil {
		t.Fatalf("EnsureInboundTagAllowed all-mode: %v", err)
	}
	var gotAll model.Node
	if err := db.First(&gotAll, all.Id).Error; err != nil {
		t.Fatalf("reload node: %v", err)
	}
	if len(gotAll.InboundTags) != 0 {
		t.Fatalf("all-mode node must stay without tags, got %#v", gotAll.InboundTags)
	}
}
