package sub

import (
	"encoding/json"
	"strings"
	"testing"

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

func seedSubBalancer(t *testing.T, b *model.SubBalancer) *model.SubBalancer {
	t.Helper()
	if err := database.GetDB().Create(b).Error; err != nil {
		t.Fatalf("seed balancer: %v", err)
	}
	return b
}

func parseSubJsonDocs(t *testing.T, out string) []map[string]any {
	t.Helper()
	var docs []map[string]any
	if err := json.Unmarshal([]byte(out), &docs); err != nil {
		t.Fatalf("subscription is not a JSON array: %v\n%s", err, out)
	}
	return docs
}

func docOutboundTags(doc map[string]any) []string {
	outbounds, _ := doc["outbounds"].([]any)
	tags := make([]string, 0, len(outbounds))
	for _, ob := range outbounds {
		if m, ok := ob.(map[string]any); ok {
			tags = append(tags, m["tag"].(string))
		}
	}
	return tags
}

func findDocByRemarks(docs []map[string]any, remarks string) map[string]any {
	for _, doc := range docs {
		if doc["remarks"] == remarks {
			return doc
		}
	}
	return nil
}

// The balancer document retags members under a per-balancer prefix, points
// proxy rules at the balancer, and probes it — manual docs keep plain "proxy".
func TestSubJson_BalancerDocument(t *testing.T) {
	seedSubDB(t)
	tcp := seedSubInbound(t, "s1", "tcpin", 4701, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
	ws := seedSubInbound(t, "s1", "wsin", 4702, 2, wsTLSStream)
	seedSubBalancer(t, &model.SubBalancer{
		Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id, ws.Id}, SortOrder: 1, Enabled: true,
	})

	rules := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
	js := NewSubJsonService("", rules, "", NewSubService(""))
	out, _, err := js.GetJson("s1", "req.example.com", true)
	if err != nil {
		t.Fatalf("GetJson: %v", err)
	}
	docs := parseSubJsonDocs(t, out)
	if len(docs) != 3 {
		t.Fatalf("docs = %d, want 3 (2 inbounds + 1 balancer):\n%s", len(docs), out)
	}

	balancerDoc := findDocByRemarks(docs, "auto")
	if balancerDoc == nil {
		t.Fatalf("balancer doc missing:\n%s", out)
	}
	if tags := docOutboundTags(balancerDoc); strings.Join(tags, ",") != "bal-1-vless,bal-1-vless-2,direct,block" {
		t.Fatalf("balancer outbound tags = %v", tags)
	}

	routing, _ := balancerDoc["routing"].(map[string]any)
	balancers, _ := routing["balancers"].([]any)
	if len(balancers) != 1 {
		t.Fatalf("balancers = %d, want 1", len(balancers))
	}
	balancer, _ := balancers[0].(map[string]any)
	if balancer["tag"] != "balancer" {
		t.Fatalf("balancer tag = %v", balancer["tag"])
	}
	if selector, _ := balancer["selector"].([]any); strings.Join(stringify(selector), ",") != "bal-1-" {
		t.Fatalf("selector = %v", selector)
	}
	strategy, _ := balancer["strategy"].(map[string]any)
	if strategy["type"] != "leastLoad" {
		t.Fatalf("strategy = %v", strategy)
	}
	if balancer["fallbackTag"] != "bal-1-vless" {
		t.Fatalf("fallbackTag = %v, want bal-1-vless (first member)", balancer["fallbackTag"])
	}

	ruleJSON, _ := json.Marshal(routing["rules"])
	if strings.Contains(string(ruleJSON), `"outboundTag":"proxy"`) {
		t.Fatalf("balancer rules must not point at the plain proxy tag: %s", ruleJSON)
	}
	if !strings.Contains(string(ruleJSON), `"balancerTag":"balancer"`) {
		t.Fatalf("balancer catch-all rule missing balancerTag: %s", ruleJSON)
	}
	proxyRules := strings.Count(string(ruleJSON), `"balancerTag"`)
	if proxyRules != 2 { // custom rule + default catch-all
		t.Fatalf("balancerTag rules = %d, want 2: %s", proxyRules, ruleJSON)
	}

	observatory, _ := balancerDoc["burstObservatory"].(map[string]any)
	if selector, _ := observatory["subjectSelector"].([]any); strings.Join(stringify(selector), ",") != "bal-1-" {
		t.Fatalf("subjectSelector = %v", selector)
	}
	ping, _ := observatory["pingConfig"].(map[string]any)
	if ping["destination"] != subBalancerProbeURL {
		t.Fatalf("pingConfig destination = %v", ping["destination"])
	}

	// The routing rewrite must not leak into the manual documents: s.configJson
	// is shared, so a missing clone would corrupt every other doc.
	for _, remarks := range []string{"tcpin-tcpin@e", "wsin-wsin@e"} {
		manual := findDocByRemarks(docs, remarks)
		if manual == nil {
			t.Fatalf("manual doc %q missing:\n%s", remarks, out)
		}
		if tags := docOutboundTags(manual); tags[0] != "proxy" {
			t.Fatalf("manual doc %q first tag = %q, want proxy", remarks, tags[0])
		}
		manualRouting, _ := manual["routing"].(map[string]any)
		manualRules, _ := json.Marshal(manualRouting["rules"])
		if !strings.Contains(string(manualRules), `"outboundTag":"proxy"`) {
			t.Fatalf("manual doc %q lost its proxy rule: %s", remarks, manualRules)
		}
		if _, has := manualRouting["balancers"]; has {
			t.Fatalf("manual doc %q must not carry balancers", remarks)
		}
	}
}

func stringify(values []any) []string {
	out := make([]string, 0, len(values))
	for _, v := range values {
		out = append(out, v.(string))
	}
	return out
}

// The balancer interleaves with inbounds by the same 1-based number and, on a
// tie, follows the inbound group with that number.
func TestSubJson_BalancerOrderInterleavesWithInbounds(t *testing.T) {
	seedSubDB(t)
	later := seedSubInbound(t, "s1", "later", 4711, 2, wsTLSStream)
	first := seedSubInbound(t, "s1", "first", 4712, 1, wsTLSStream)
	seedSubBalancer(t, &model.SubBalancer{
		Remark: "bal", Strategy: "roundRobin", InboundIds: []int{later.Id, first.Id}, SortOrder: 1, Enabled: true,
	})

	js := NewSubJsonService("", "", "", NewSubService(""))
	out, _, err := js.GetJson("s1", "req.example.com", true)
	if err != nil {
		t.Fatalf("GetJson: %v", err)
	}
	docs := parseSubJsonDocs(t, out)
	var remarks []string
	for _, doc := range docs {
		remarks = append(remarks, doc["remarks"].(string))
	}
	if strings.Join(remarks, ",") != "first-first@e,bal,later-later@e" {
		t.Fatalf("doc order = %v, want [first bal later]", remarks)
	}
	balancerDoc := findDocByRemarks(docs, "bal")
	routing, _ := balancerDoc["routing"].(map[string]any)
	balancers, _ := routing["balancers"].([]any)
	strategy, _ := balancers[0].(map[string]any)["strategy"].(map[string]any)
	if strategy["type"] != "roundRobin" {
		t.Fatalf("strategy = %v, want roundRobin", strategy["type"])
	}
}

// A disabled balancer is not emitted; an enabled one whose selected inbounds
// have no configs for this subscriber is skipped rather than emitted empty.
func TestSubJson_BalancerDisabledAndEmptySkipped(t *testing.T) {
	seedSubDB(t)
	inbound := seedSubInbound(t, "s1", "only", 4721, 1, wsTLSStream)
	seedSubBalancer(t, &model.SubBalancer{
		Remark: "off", Strategy: "random", InboundIds: []int{inbound.Id}, SortOrder: 1, Enabled: false,
	})
	seedSubBalancer(t, &model.SubBalancer{
		Remark: "nomembers", Strategy: "random", InboundIds: []int{inbound.Id + 100}, SortOrder: 1, Enabled: true,
	})

	js := NewSubJsonService("", "", "", NewSubService(""))
	out, _, err := js.GetJson("s1", "req.example.com", true)
	if err != nil {
		t.Fatalf("GetJson: %v", err)
	}
	docs := parseSubJsonDocs(t, out)
	if len(docs) != 1 {
		t.Fatalf("docs = %d, want 1:\n%s", len(docs), out)
	}
	if docs[0]["remarks"] != "only-only@e" {
		t.Fatalf("remaining doc = %v", docs[0]["remarks"])
	}
}

// Two members sharing a transport get deduplicated tags (…-2 suffix), matching
// the reference makeTag convention.
func TestSubJson_BalancerTagDedup(t *testing.T) {
	seedSubDB(t)
	a := seedSubInbound(t, "s1", "wsa", 4731, 1, wsTLSStream)
	b := seedSubInbound(t, "s1", "wsb", 4732, 2, wsTLSStream)
	seedSubBalancer(t, &model.SubBalancer{
		Remark: "dedup", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
	})

	js := NewSubJsonService("", "", "", NewSubService(""))
	out, _, err := js.GetJson("s1", "req.example.com", true)
	if err != nil {
		t.Fatalf("GetJson: %v", err)
	}
	docs := parseSubJsonDocs(t, out)
	balancerDoc := findDocByRemarks(docs, "dedup")
	if balancerDoc == nil {
		t.Fatalf("balancer doc missing:\n%s", out)
	}
	if tags := docOutboundTags(balancerDoc); strings.Join(tags, ",") != "bal-1-vless,bal-1-vless-2,direct,block" {
		t.Fatalf("balancer outbound tags = %v", tags)
	}
}

// random/roundRobin have no fallback so they emit no observatory; leastPing
// carries one, with the panel-wide ping config overriding the defaults.
func TestSubJson_BalancerObservatoryConditional(t *testing.T) {
	seedSubDB(t)
	rr := seedSubInbound(t, "s1", "rr", 4741, 1, wsTLSStream)
	lp := seedSubInbound(t, "s1", "lp", 4742, 2, wsTLSStream)
	seedSubBalancer(t, &model.SubBalancer{
		Remark: "rnd", Strategy: "random", InboundIds: []int{rr.Id}, SortOrder: 1, Enabled: true,
	})
	seedSubBalancer(t, &model.SubBalancer{
		Remark: "pinger", Strategy: "leastPing", InboundIds: []int{lp.Id}, SortOrder: 2, Enabled: true,
	})

	js := NewSubJsonService("", "", "", NewSubService(""))
	js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`)
	out, _, err := js.GetJson("s1", "req.example.com", true)
	if err != nil {
		t.Fatalf("GetJson: %v", err)
	}
	docs := parseSubJsonDocs(t, out)

	rnd := findDocByRemarks(docs, "rnd")
	if _, has := rnd["burstObservatory"]; has {
		t.Fatalf("random balancer must not emit burstObservatory: %v", rnd["burstObservatory"])
	}

	pinger := findDocByRemarks(docs, "pinger")
	obs, _ := pinger["burstObservatory"].(map[string]any)
	if obs == nil {
		t.Fatalf("leastPing balancer must emit burstObservatory:\n%s", out)
	}
	ping, _ := obs["pingConfig"].(map[string]any)
	if ping["destination"] != "https://probe.example/204" {
		t.Fatalf("destination = %v, want custom probe URL", ping["destination"])
	}
	if ping["httpMethod"] != "GET" {
		t.Fatalf("httpMethod = %v, want GET", ping["httpMethod"])
	}
	if ping["sampling"] != float64(5) {
		t.Fatalf("sampling = %v, want 5", ping["sampling"])
	}
	if ping["interval"] != "1m" {
		t.Fatalf("interval = %v, want default 1m", ping["interval"])
	}
}

// A balancer selecting [A, B] with B disabled must carry only A: getInboundsBySubId
// filters enable=true, so B never reaches entries. Guards the access scoping.
func TestSubJson_BalancerExcludesDisabledInbound(t *testing.T) {
	seedSubDB(t)
	a := seedSubInbound(t, "s1", "keep", 4751, 1, wsTLSStream)
	b := seedSubInbound(t, "s1", "drop", 4752, 2, wsTLSStream)
	if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", b.Id).Update("enable", false).Error; err != nil {
		t.Fatalf("disable inbound B: %v", err)
	}
	seedSubBalancer(t, &model.SubBalancer{
		Remark: "bal", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
	})

	js := NewSubJsonService("", "", "", NewSubService(""))
	out, _, err := js.GetJson("s1", "req.example.com", true)
	if err != nil {
		t.Fatalf("GetJson: %v", err)
	}
	docs := parseSubJsonDocs(t, out)
	balancerDoc := findDocByRemarks(docs, "bal")
	if balancerDoc == nil {
		t.Fatalf("balancer doc missing (A is still enabled, balancer must emit):\n%s", out)
	}
	tags := docOutboundTags(balancerDoc)
	joined := strings.Join(tags, ",")
	if !strings.Contains(joined, "bal-1-vless") {
		t.Fatalf("enabled inbound A must be a balancer member: %v", tags)
	}
	// B's address must not surface anywhere in the balancer doc — not as an
	// outbound tag, not as a connection target a client could dial.
	balJSON, _ := json.Marshal(balancerDoc)
	if strings.Contains(string(balJSON), "203.0.113.5:4752") {
		t.Fatalf("disabled inbound B leaked into balancer doc: %s", balJSON)
	}
}

// A balancer whose only selected inbound is disabled for this subscriber is
// skipped entirely — never emitted as an empty balancer with zero members.
func TestSubJson_BalancerSkippedWhenAllMembersDisabled(t *testing.T) {
	seedSubDB(t)
	only := seedSubInbound(t, "s1", "onlydisabled", 4761, 1, wsTLSStream)
	if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", only.Id).Update("enable", false).Error; err != nil {
		t.Fatalf("disable only inbound: %v", err)
	}
	seedSubBalancer(t, &model.SubBalancer{
		Remark: "empty", Strategy: "random", InboundIds: []int{only.Id}, SortOrder: 1, Enabled: true,
	})

	js := NewSubJsonService("", "", "", NewSubService(""))
	out, _, err := js.GetJson("s1", "req.example.com", true)
	if err != nil {
		t.Fatalf("GetJson: %v", err)
	}
	if strings.TrimSpace(out) == "" {
		return
	}
	docs := parseSubJsonDocs(t, out)
	if findDocByRemarks(docs, "empty") != nil {
		t.Fatalf("balancer with no accessible members must not be emitted:\n%s", out)
	}
}

// Connectivity defaults to empty (skip the direct pre-check); an explicit empty
// value stays empty instead of restoring the old generate_204 default.
func TestSubJson_BalancerObservatoryConnectivityDefaultEmpty(t *testing.T) {
	seedSubDB(t)
	inb := seedSubInbound(t, "s1", "lp", 4781, 1, wsTLSStream)
	seedSubBalancer(t, &model.SubBalancer{
		Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true,
	})

	js := NewSubJsonService("", "", "", NewSubService(""))
	out, _, err := js.GetJson("s1", "req.example.com", true)
	if err != nil {
		t.Fatalf("GetJson: %v", err)
	}
	ping := observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
	if ping["connectivity"] != "" {
		t.Fatalf("default connectivity = %v, want empty (skip)", ping["connectivity"])
	}

	js.SetObservatoryConfig(`{"connectivity":""}`)
	out, _, err = js.GetJson("s1", "req.example.com", true)
	if err != nil {
		t.Fatalf("GetJson: %v", err)
	}
	ping = observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
	if ping["connectivity"] != "" {
		t.Fatalf("explicit empty connectivity = %v, want empty", ping["connectivity"])
	}

	js.SetObservatoryConfig(`{"connectivity":"http://probe.example/204"}`)
	out, _, err = js.GetJson("s1", "req.example.com", true)
	if err != nil {
		t.Fatalf("GetJson: %v", err)
	}
	ping = observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
	if ping["connectivity"] != "http://probe.example/204" {
		t.Fatalf("custom connectivity = %v, want http://probe.example/204", ping["connectivity"])
	}
}

// leastPing/leastLoad always emit a burst observatory (Xray won't start them
// without one); a stored {"enabled":false} is ignored as it is mandatory.
func TestSubJson_BalancerObservatoryAlwaysEmittedForProbingStrategies(t *testing.T) {
	seedSubDB(t)
	a := seedSubInbound(t, "s1", "a", 4771, 1, wsTLSStream)
	b := seedSubInbound(t, "s1", "b", 4772, 2, wsTLSStream)
	seedSubBalancer(t, &model.SubBalancer{
		Remark: "pinger", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
	})

	js := NewSubJsonService("", "", "", NewSubService(""))
	js.SetObservatoryConfig(`{"enabled":false}`)
	out, _, err := js.GetJson("s1", "req.example.com", true)
	if err != nil {
		t.Fatalf("GetJson: %v", err)
	}
	pinger := findDocByRemarks(parseSubJsonDocs(t, out), "pinger")
	if pinger == nil {
		t.Fatalf("balancer doc missing:\n%s", out)
	}
	if _, has := pinger["burstObservatory"]; !has {
		t.Fatalf("leastPing must always emit burstObservatory (Xray requires it):\n%s", out)
	}
	routing, _ := pinger["routing"].(map[string]any)
	balancers, _ := routing["balancers"].([]any)
	balancer, _ := balancers[0].(map[string]any)
	if balancer["fallbackTag"] != "bal-1-vless" {
		t.Fatalf("fallbackTag = %v, want bal-1-vless (first member)", balancer["fallbackTag"])
	}
}

func observatoryPingConfig(t *testing.T, docs []map[string]any, remarks string) map[string]any {
	t.Helper()
	doc := findDocByRemarks(docs, remarks)
	if doc == nil {
		t.Fatalf("balancer doc %q missing", remarks)
	}
	obs, _ := doc["burstObservatory"].(map[string]any)
	if obs == nil {
		t.Fatalf("balancer %q has no burstObservatory", remarks)
	}
	ping, _ := obs["pingConfig"].(map[string]any)
	return ping
}

func balancerStrategy(t *testing.T, docs []map[string]any, remarks string) map[string]any {
	t.Helper()
	doc := findDocByRemarks(docs, remarks)
	if doc == nil {
		t.Fatalf("balancer doc %q missing", remarks)
	}
	routing, _ := doc["routing"].(map[string]any)
	balancers, _ := routing["balancers"].([]any)
	strategy, _ := balancers[0].(map[string]any)["strategy"].(map[string]any)
	return strategy
}

// leastLoad with configured weights must emit strategy.settings.costs keyed by
// the retagged member tags; members without a weight count as 1.0.
func TestSubJson_BalancerLeastLoadCosts(t *testing.T) {
	seedSubDB(t)
	fast := seedSubInbound(t, "s1", "fast", 4791, 1, wsTLSStream)
	slow := seedSubInbound(t, "s1", "slow", 4792, 2, wsTLSStream)
	seedSubBalancer(t, &model.SubBalancer{
		Remark: "weighted", Strategy: "leastLoad", InboundIds: []int{fast.Id, slow.Id},
		MemberWeights: map[int]float64{fast.Id: 0.2}, SortOrder: 1, Enabled: true,
	})

	js := NewSubJsonService("", "", "", NewSubService(""))
	out, _, err := js.GetJson("s1", "req.example.com", true)
	if err != nil {
		t.Fatalf("GetJson: %v", err)
	}
	strategy := balancerStrategy(t, parseSubJsonDocs(t, out), "weighted")
	settings, _ := strategy["settings"].(map[string]any)
	costs, _ := settings["costs"].([]any)
	if len(costs) != 2 {
		t.Fatalf("costs = %v, want 2 entries:\n%s", costs, out)
	}
	first, _ := costs[0].(map[string]any)
	second, _ := costs[1].(map[string]any)
	// Anchored regexp is required: xray's plain cost match is substring-based,
	// so a bare "bal-1-vless" would also hit the deduplicated "bal-1-vless-2".
	if first["regexp"] != true || first["match"] != "^bal-1-vless$" || first["value"] != 0.2 {
		t.Fatalf("costs[0] = %v, want regexp ^bal-1-vless$ value=0.2", first)
	}
	if second["regexp"] != true || second["match"] != "^bal-1-vless-2$" || second["value"] != 1.0 {
		t.Fatalf("costs[1] = %v, want regexp ^bal-1-vless-2$ value=1 (default)", second)
	}
}

// leastLoad without any configured weight emits no settings at all.
func TestSubJson_BalancerLeastLoadWithoutWeightsOmitsCosts(t *testing.T) {
	seedSubDB(t)
	a := seedSubInbound(t, "s1", "a", 4801, 1, wsTLSStream)
	b := seedSubInbound(t, "s1", "b", 4802, 2, wsTLSStream)
	seedSubBalancer(t, &model.SubBalancer{
		Remark: "plain", Strategy: "leastLoad", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
	})

	js := NewSubJsonService("", "", "", NewSubService(""))
	out, _, err := js.GetJson("s1", "req.example.com", true)
	if err != nil {
		t.Fatalf("GetJson: %v", err)
	}
	strategy := balancerStrategy(t, parseSubJsonDocs(t, out), "plain")
	if _, has := strategy["settings"]; has {
		t.Fatalf("leastLoad without weights must not emit strategy.settings: %v", strategy["settings"])
	}
}

// Emission-side guard independent of validate(): a non-leastLoad row written
// directly to the DB must still emit no costs — xray would ignore them.
func TestSubJson_BalancerCostsSkippedForNonLeastLoadStrategy(t *testing.T) {
	seedSubDB(t)
	a := seedSubInbound(t, "s1", "a", 4811, 1, wsTLSStream)
	b := seedSubInbound(t, "s1", "b", 4812, 2, wsTLSStream)
	seedSubBalancer(t, &model.SubBalancer{
		Remark: "misconfig", Strategy: "random", InboundIds: []int{a.Id, b.Id},
		MemberWeights: map[int]float64{a.Id: 0.5}, SortOrder: 1, Enabled: true,
	})

	js := NewSubJsonService("", "", "", NewSubService(""))
	out, _, err := js.GetJson("s1", "req.example.com", true)
	if err != nil {
		t.Fatalf("GetJson: %v", err)
	}
	strategy := balancerStrategy(t, parseSubJsonDocs(t, out), "misconfig")
	if _, has := strategy["settings"]; has {
		t.Fatalf("random balancer must never emit costs despite stored weights: %v", strategy)
	}
}
