package service

import (
	"errors"
	"path/filepath"
	"strings"
	"testing"

	"gorm.io/gorm"

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

// TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound guards the
// PostgreSQL fix where the externalProxy detection query (executed via .Scan) errored on
// json_extract and rolled back the whole transaction — including the client_traffics
// backfill at inbound.go:3093-3106, leaving clients with no traffic rows. A MultiDomain
// inbound is present so that query returns rows and the function runs to completion; both
// the backfill and the MultiDomain→ExternalProxy migration must then commit.
func TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound(t *testing.T) {
	dbDir := t.TempDir()
	t.Setenv("XUI_DB_FOLDER", dbDir)
	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
		t.Fatalf("InitDB: %v", err)
	}
	t.Cleanup(func() { _ = database.CloseDB() })

	db := database.GetDB()

	const backfillEmail = "needsbackfill@example.com"
	const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c010"

	// Inbound A: a client present only in settings.clients, with no client_traffics row.
	clientInbound := &model.Inbound{
		UserId:         1,
		Tag:            "a-tag",
		Enable:         true,
		Port:           30001,
		Protocol:       model.VLESS,
		Settings:       `{"clients":[{"email":"` + backfillEmail + `","id":"` + uid + `","enable":true}]}`,
		StreamSettings: `{"network":"tcp","security":"none"}`,
	}
	if err := db.Create(clientInbound).Error; err != nil {
		t.Fatalf("create client inbound: %v", err)
	}

	// Inbound B: a legacy MultiDomain inbound whose tag carries the 0.0.0.0: prefix.
	// Its presence makes the externalProxy query return rows, so the function does not
	// early-return and reaches the tag-cleanup statement.
	multiDomainInbound := &model.Inbound{
		UserId:         1,
		Tag:            "inbound-0.0.0.0:30002",
		Enable:         true,
		Port:           30002,
		Protocol:       model.VLESS,
		Settings:       `{"clients":[]}`,
		StreamSettings: `{"security":"tls","tlsSettings":{"settings":{"domains":[{"domain":"example.com"}]}}}`,
	}
	if err := db.Create(multiDomainInbound).Error; err != nil {
		t.Fatalf("create multidomain inbound: %v", err)
	}

	var before int64
	if err := db.Model(xray.ClientTraffic{}).Count(&before).Error; err != nil {
		t.Fatalf("count client_traffics before: %v", err)
	}
	if before != 0 {
		t.Fatalf("expected no client_traffics before migration, got %d", before)
	}

	svc := InboundService{}
	svc.MigrationRequirements()

	// The backfill must have committed: the settings-only client now owns a row.
	// Before the fix this was rolled back whenever the externalProxy detection query
	// errored (it does on Postgres via json_extract), so the MultiDomain inbound below
	// is deliberately present to make that query return rows and run to completion.
	var ct xray.ClientTraffic
	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", backfillEmail).First(&ct).Error; err != nil {
		t.Fatalf("client_traffics row not backfilled for %s: %v", backfillEmail, err)
	}

	// The MultiDomain→ExternalProxy migration must have committed too: the detection
	// query ran (.Scan executes it) and the loop rewrote the inbound's streamSettings.
	var refreshed model.Inbound
	if err := db.First(&refreshed, multiDomainInbound.Id).Error; err != nil {
		t.Fatalf("reload multidomain inbound: %v", err)
	}
	if !strings.Contains(refreshed.StreamSettings, "externalProxy") {
		t.Errorf("MultiDomain migration did not commit; streamSettings = %q", refreshed.StreamSettings)
	}
}

func TestMigrationRequirementsReturnsAddClientStatFailure(t *testing.T) {
	dbDir := t.TempDir()
	t.Setenv("XUI_DB_FOLDER", dbDir)
	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
		t.Fatalf("InitDB: %v", err)
	}
	t.Cleanup(func() { _ = database.CloseDB() })
	db := database.GetDB()
	first := &model.Inbound{UserId: 1, Tag: "first", Port: 31001, Protocol: model.VLESS, Settings: `{"clients":[{"email":"first@example.test","id":"id-1"}]}`, StreamSettings: `{}`}
	if err := db.Create(first).Error; err != nil {
		t.Fatalf("create first: %v", err)
	}
	const injected = "injected AddClientStat failure"
	failSave := func(tx *gorm.DB) {
		tx.AddError(errors.New(injected))
	}
	if err := db.Callback().Update().Before("gorm:update").Register("test:fail-migration-inbound-save", failSave); err != nil {
		t.Fatalf("register update callback: %v", err)
	}
	if err := db.Callback().Create().Before("gorm:create").Register("test:fail-migration-inbound-save", failSave); err != nil {
		t.Fatalf("register create callback: %v", err)
	}
	err := (&InboundService{}).MigrationRequirements()
	if err == nil || err.Error() != injected {
		t.Fatalf("MigrationRequirements error = %v, want %q", err, injected)
	}
	var count int64
	if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", "first@example.test").Count(&count).Error; err != nil {
		t.Fatalf("count rolled-back traffic: %v", err)
	}
	if count != 0 {
		t.Fatalf("earlier traffic write committed after save failure: count=%d", count)
	}
}

// TestMigrationRequirements_CleansLegacyZeroAddrTag guards the legacy tag cleanup that
// strips the auto-generated "0.0.0.0:" prefix. The inbound is MultiDomain TLS so the
// externalProxy detection query returns rows and the cleanup is reached (it early-returns
// at len(externalProxy)==0 otherwise). The cleanup must use tx.Exec, not tx.Raw, which
// only builds a non-SELECT statement without running it.
func TestMigrationRequirements_CleansLegacyZeroAddrTag(t *testing.T) {
	dbDir := t.TempDir()
	t.Setenv("XUI_DB_FOLDER", dbDir)
	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
		t.Fatalf("InitDB: %v", err)
	}
	t.Cleanup(func() { _ = database.CloseDB() })

	db := database.GetDB()
	legacy := &model.Inbound{
		UserId:         1,
		Tag:            "inbound-0.0.0.0:30002",
		Enable:         true,
		Port:           30002,
		Protocol:       model.VLESS,
		Settings:       `{"clients":[]}`,
		StreamSettings: `{"security":"tls","tlsSettings":{"settings":{"domains":[{"domain":"example.com"}]}}}`,
	}
	if err := db.Create(legacy).Error; err != nil {
		t.Fatalf("create legacy inbound: %v", err)
	}

	svc := InboundService{}
	svc.MigrationRequirements()

	var got model.Inbound
	if err := db.First(&got, legacy.Id).Error; err != nil {
		t.Fatalf("reload inbound: %v", err)
	}
	if got.Tag != "inbound-30002" {
		t.Fatalf("legacy 0.0.0.0: tag not stripped: got %q, want %q", got.Tag, "inbound-30002")
	}
}

func TestMigrationRemoveOrphanedTraffics(t *testing.T) {
	setupConflictDB(t)
	db := database.GetDB()
	clientSvc := &ClientService{}
	inboundSvc := &InboundService{}

	const attachedEmail = "attached@example.com"
	attachedClient := model.Client{Email: attachedEmail, ID: "11111111-1111-1111-1111-111111111111", SubID: attachedEmail, Enable: true}
	attachedIb := mkInbound(t, 30003, model.VLESS, clientsSettings(t, []model.Client{attachedClient}))
	if err := clientSvc.SyncInbound(nil, attachedIb.Id, []model.Client{attachedClient}); err != nil {
		t.Fatalf("seed attached client: %v", err)
	}
	mkTraffic(t, attachedIb.Id, attachedEmail, 0, 0, 0, 0, true)

	const detachedEmail = "detached@example.com"
	detachedClient := model.Client{Email: detachedEmail, ID: "22222222-2222-2222-2222-222222222222", SubID: detachedEmail, Enable: true}
	detachedIb := mkInbound(t, 30004, model.VLESS, clientsSettings(t, []model.Client{detachedClient}))
	if err := clientSvc.SyncInbound(nil, detachedIb.Id, []model.Client{detachedClient}); err != nil {
		t.Fatalf("seed detached client: %v", err)
	}
	mkTraffic(t, detachedIb.Id, detachedEmail, 123, 456, 0, 0, true)
	detachedRec := lookupClientRecord(t, detachedEmail)
	if _, err := clientSvc.Detach(inboundSvc, detachedRec.Id, []int{detachedIb.Id}); err != nil {
		t.Fatalf("Detach: %v", err)
	}

	const jsonOnlyEmail = "jsononly@example.com"
	jsonOnlyClient := model.Client{Email: jsonOnlyEmail, ID: "33333333-3333-3333-3333-333333333333", SubID: jsonOnlyEmail, Enable: true}
	jsonOnlyIb := mkInbound(t, 30005, model.VLESS, clientsSettings(t, []model.Client{jsonOnlyClient}))
	mkTraffic(t, jsonOnlyIb.Id, jsonOnlyEmail, 0, 0, 0, 0, true)

	const trulyOrphanedEmail = "deleted@example.com"
	mkTraffic(t, attachedIb.Id, trulyOrphanedEmail, 0, 0, 0, 0, true)

	inboundSvc.MigrationRemoveOrphanedTraffics()

	cases := []struct {
		name  string
		email string
		want  int64
	}{
		{"attached, in clients table and JSON", attachedEmail, 1},
		{"detached-but-alive, in clients table only", detachedEmail, 1},
		{"seeder-skipped-but-live, in JSON only", jsonOnlyEmail, 1},
		{"truly orphaned, in neither", trulyOrphanedEmail, 0},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			var got int64
			if err := db.Model(xray.ClientTraffic{}).Where("email = ?", c.email).Count(&got).Error; err != nil {
				t.Fatalf("count client_traffics for %s: %v", c.email, err)
			}
			if got != c.want {
				t.Errorf("client_traffics count for %s: got %d, want %d", c.email, got, c.want)
			}
		})
	}
}

func TestMigrationRequirements_NormalizesShareAddressFields(t *testing.T) {
	setupConflictDB(t)
	db := database.GetDB()

	invalidStrategy := &model.Inbound{
		UserId:         1,
		Tag:            "invalid-share-strategy",
		Enable:         true,
		Port:           31001,
		Protocol:       model.VLESS,
		Settings:       `{"clients":[]}`,
		StreamSettings: `{"network":"tcp","security":"none"}`,
	}
	paddedStrategy := &model.Inbound{
		UserId:         1,
		Tag:            "padded-share-strategy",
		Enable:         true,
		Port:           31002,
		Protocol:       model.VLESS,
		Settings:       `{"clients":[]}`,
		StreamSettings: `{"network":"tcp","security":"none"}`,
	}
	invalidAddress := &model.Inbound{
		UserId:         1,
		Tag:            "invalid-share-address",
		Enable:         true,
		Port:           31003,
		Protocol:       model.VLESS,
		Settings:       `{"clients":[]}`,
		StreamSettings: `{"network":"tcp","security":"none"}`,
	}
	if err := db.Create(invalidStrategy).Error; err != nil {
		t.Fatalf("create invalid strategy inbound: %v", err)
	}
	if err := db.Create(paddedStrategy).Error; err != nil {
		t.Fatalf("create padded strategy inbound: %v", err)
	}
	if err := db.Create(invalidAddress).Error; err != nil {
		t.Fatalf("create invalid address inbound: %v", err)
	}
	if err := db.Model(&model.Inbound{}).Where("id = ?", invalidStrategy.Id).Updates(map[string]any{
		"share_addr_strategy": " auto ",
		"share_addr":          "  edge.example.com  ",
	}).Error; err != nil {
		t.Fatalf("seed invalid share fields: %v", err)
	}
	if err := db.Model(&model.Inbound{}).Where("id = ?", paddedStrategy.Id).Updates(map[string]any{
		"share_addr_strategy": " listen ",
		"share_addr":          "  10.0.0.1  ",
	}).Error; err != nil {
		t.Fatalf("seed padded share fields: %v", err)
	}
	if err := db.Model(&model.Inbound{}).Where("id = ?", invalidAddress.Id).Updates(map[string]any{
		"share_addr_strategy": "custom",
		"share_addr":          "edge.example.com:8443",
	}).Error; err != nil {
		t.Fatalf("seed invalid address share fields: %v", err)
	}

	svc := InboundService{}
	svc.MigrationRequirements()

	var gotInvalid model.Inbound
	if err := db.First(&gotInvalid, invalidStrategy.Id).Error; err != nil {
		t.Fatalf("reload invalid strategy inbound: %v", err)
	}
	if gotInvalid.ShareAddrStrategy != "node" || gotInvalid.ShareAddr != "edge.example.com" {
		t.Fatalf("invalid share fields = (%q, %q), want (node, edge.example.com)", gotInvalid.ShareAddrStrategy, gotInvalid.ShareAddr)
	}

	var gotPadded model.Inbound
	if err := db.First(&gotPadded, paddedStrategy.Id).Error; err != nil {
		t.Fatalf("reload padded strategy inbound: %v", err)
	}
	if gotPadded.ShareAddrStrategy != "listen" || gotPadded.ShareAddr != "10.0.0.1" {
		t.Fatalf("padded share fields = (%q, %q), want (listen, 10.0.0.1)", gotPadded.ShareAddrStrategy, gotPadded.ShareAddr)
	}

	var gotInvalidAddress model.Inbound
	if err := db.First(&gotInvalidAddress, invalidAddress.Id).Error; err != nil {
		t.Fatalf("reload invalid address inbound: %v", err)
	}
	if gotInvalidAddress.ShareAddrStrategy != "node" || gotInvalidAddress.ShareAddr != "" {
		t.Fatalf("invalid address share fields = (%q, %q), want (node, empty)", gotInvalidAddress.ShareAddrStrategy, gotInvalidAddress.ShareAddr)
	}
}
