package hostagencypolicy

import (
	"context"
	"testing"
	"time"

	"hyapp-admin-server/internal/model"
	"hyapp-admin-server/internal/repository"

	"github.com/DATA-DOG/go-sqlmock"
	gormMySQL "gorm.io/driver/mysql"
	"gorm.io/gorm"
)

func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) {
	item, err := policyModelFromRequest("lalu", 7, policyRequest{
		Name:                            "Lalu 默认政策",
		RegionIDs:                       []int64{102, 101, 102},
		CycleKey:                        "2026-06",
		Status:                          "active",
		SettlementMode:                  "half-month",
		SettlementTriggerMode:           "manual",
		GiftCoinToDiamondRatio:          "9",
		ResidualDiamondToUSDRate:        "0.000001",
		CoinSellerWithdrawalLimitPeriod: "week",
		CoinSellerWithdrawalLimitCount:  3,
		PlatformWithdrawalLimitPeriod:   "day",
		PlatformWithdrawalLimitCount:    1,
		PlatformWithdrawalAllowedDays:   []int32{30, 15, 30},
		Levels: []levelRequest{
			{Level: 2, RequiredDiamonds: 700000, HostSalaryUSD: "3", HostCoinReward: 198000, AgencySalaryUSD: "0.8"},
			{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
		},
	})
	if err != nil {
		t.Fatalf("policy should be valid: %v", err)
	}
	if item.AppCode != "lalu" || item.CycleKey != "2026-06" || item.CreatedByAdminID != 7 || item.SettlementMode != settlementModeHalfMonth || item.SettlementTriggerMode != settlementTriggerManual {
		t.Fatalf("policy fields mismatch: %+v", item)
	}
	if item.RegionID != 101 || len(item.RegionIDs) != 2 || item.RegionIDs[0] != 101 || item.RegionIDs[1] != 102 {
		t.Fatalf("multi-region scope should be sorted and deduplicated: %+v", item.RegionIDs)
	}
	// Host 政策只保存叠加系数，并随发布快照进入 wallet 运行表；礼物类型基础倍率仍由礼物钻石配置提供。
	if item.GiftCoinToDiamondRatio != "9.000000" || item.ResidualDiamondToUSDRate != "0.000001000000" {
		t.Fatalf("ratio fields mismatch: %+v", item)
	}
	if item.CoinSellerWithdrawalLimitPeriod != "week" || item.CoinSellerWithdrawalLimitCount != 3 || item.PlatformWithdrawalLimitPeriod != "day" || item.PlatformWithdrawalLimitCount != 1 || item.PlatformWithdrawalAllowedDays != "15,30" {
		t.Fatalf("withdrawal limit fields mismatch: %+v", item)
	}
	if len(item.Levels) != 2 || item.Levels[0].LevelNo != 1 || item.Levels[1].LevelNo != 2 {
		t.Fatalf("levels should be sorted: %+v", item.Levels)
	}
	if item.Levels[0].HostSalaryUSD != "1.50" || item.Levels[1].AgencySalaryUSD != "0.80" {
		t.Fatalf("salary fields should be fixed decimals: %+v", item.Levels)
	}
}

func TestPolicyModelFromRequestRejectsInvalidWithdrawalLimit(t *testing.T) {
	_, err := policyModelFromRequest("lalu", 1, policyRequest{
		Name: "Invalid withdrawal limit", RegionID: 101, CycleKey: "2026-06", SettlementMode: settlementModeDaily,
		CoinSellerWithdrawalLimitPeriod: "year", CoinSellerWithdrawalLimitCount: 1,
		Levels: []levelRequest{{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}},
	})
	if err == nil {
		t.Fatal("unsupported withdrawal limit period must fail")
	}
	_, err = policyModelFromRequest("lalu", 1, policyRequest{
		Name: "Invalid withdrawal count", RegionID: 101, CycleKey: "2026-06", SettlementMode: settlementModeDaily,
		PlatformWithdrawalLimitPeriod: "month", PlatformWithdrawalLimitCount: -1,
		Levels: []levelRequest{{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}},
	})
	if err == nil {
		t.Fatal("negative withdrawal limit count must fail")
	}
	_, err = policyModelFromRequest("lalu", 1, policyRequest{
		Name: "Invalid withdrawal day", RegionID: 101, CycleKey: "2026-06", SettlementMode: settlementModeDaily,
		PlatformWithdrawalLimitPeriod: "month", PlatformWithdrawalAllowedDays: []int32{15, 32},
		Levels: []levelRequest{{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}},
	})
	if err == nil {
		t.Fatal("platform withdrawal day outside 1..31 must fail")
	}
}

func TestPolicyModelFromRequestRejectsNonIncreasingLevels(t *testing.T) {
	_, err := policyModelFromRequest("lalu", 1, policyRequest{
		Name:           "Invalid",
		RegionID:       101,
		CycleKey:       "2026-06",
		SettlementMode: settlementModeDaily,
		Levels: []levelRequest{
			{Level: 1, RequiredDiamonds: 700000, HostSalaryUSD: "3", HostCoinReward: 198000, AgencySalaryUSD: "0.8"},
			{Level: 2, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
		},
	})
	if err == nil {
		t.Fatal("expected non-increasing levels to fail")
	}
}

func TestPolicyModelFromRequestDefaultsSettlementTriggerMode(t *testing.T) {
	item, err := policyModelFromRequest("lalu", 1, policyRequest{
		Name:           "Default trigger",
		RegionID:       101,
		CycleKey:       "2026-06",
		SettlementMode: settlementModeDaily,
		Levels: []levelRequest{
			{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
		},
	})
	if err != nil {
		t.Fatalf("policy should be valid: %v", err)
	}
	if item.SettlementTriggerMode != settlementTriggerAutomatic {
		t.Fatalf("default trigger mismatch: %+v", item)
	}
}

func TestPolicyModelFromRequestRejectsInvalidSettlementMode(t *testing.T) {
	_, err := policyModelFromRequest("lalu", 1, policyRequest{
		Name:           "Invalid",
		RegionID:       101,
		CycleKey:       "2026-06",
		SettlementMode: "monthly",
		Levels: []levelRequest{
			{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
		},
	})
	if err == nil {
		t.Fatal("expected invalid settlement mode to fail")
	}
}

func TestPolicyModelFromRequestRejectsInvalidSettlementTriggerMode(t *testing.T) {
	_, err := policyModelFromRequest("lalu", 1, policyRequest{
		Name:                  "Invalid",
		RegionID:              101,
		CycleKey:              "2026-06",
		SettlementMode:        settlementModeDaily,
		SettlementTriggerMode: "cron",
		Levels: []levelRequest{
			{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
		},
	})
	if err == nil {
		t.Fatal("expected invalid settlement trigger mode to fail")
	}
}

func TestPolicyModelFromRequestClearsFieldsOwnedByTheOtherPolicyType(t *testing.T) {
	pointItem, err := policyModelFromRequest("fami", 1, policyRequest{
		Name: "Permanent point policy", PolicyType: policyTypePointDiamond, RegionID: 25, CycleKey: "2026-07",
		SettlementMode: "malicious-mode", SettlementTriggerMode: "malicious-trigger", ResidualDiamondToUSDRate: "not-a-number",
		PointDiamondsPerUSD: 300000, CoinsPerUSD: 80000, MinimumWithdrawUSDMinor: 100, WithdrawFeeBPS: 333, AgencyPointShareBPS: 2000,
	})
	if err != nil {
		t.Fatalf("POINT_DIAMOND must ignore hidden salary fields: %v", err)
	}
	if pointItem.SettlementMode != settlementModeDaily || pointItem.SettlementTriggerMode != settlementTriggerAutomatic || pointItem.ResidualDiamondToUSDRate != "0.000000000000" {
		t.Fatalf("POINT_DIAMOND salary fields were not normalized to neutral values: %+v", pointItem)
	}

	salaryItem, err := policyModelFromRequest("lalu", 1, policyRequest{
		Name: "Salary policy", PolicyType: policyTypeSalaryDiamond, RegionID: 25, CycleKey: "2026-07",
		SettlementMode: settlementModeHalfMonth, SettlementTriggerMode: settlementTriggerManual, ResidualDiamondToUSDRate: "0.01",
		PointDiamondsPerUSD: -1, CoinsPerUSD: -2, MinimumWithdrawUSDMinor: -3, WithdrawFeeBPS: -4, AgencyPointShareBPS: -5,
		Levels: []levelRequest{{Level: 1, RequiredDiamonds: 100, HostSalaryUSD: "1", AgencySalaryUSD: "0.2"}},
	})
	if err != nil {
		t.Fatalf("SALARY_DIAMOND must ignore hidden point fields: %v", err)
	}
	if salaryItem.PointDiamondsPerUSD != 0 || salaryItem.CoinsPerUSD != 0 || salaryItem.MinimumWithdrawUSDMinor != 0 || salaryItem.WithdrawFeeBPS != 0 || salaryItem.AgencyPointShareBPS != 0 {
		t.Fatalf("SALARY_DIAMOND point fields were not cleared: %+v", salaryItem)
	}
}

func TestPolicyModelFromRequestRejectsMissingExplicitCycle(t *testing.T) {
	_, err := policyModelFromRequest("lalu", 1, policyRequest{
		Name:     "Legacy cycle",
		RegionID: 101,
		Levels: []levelRequest{
			{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
		},
	})
	if err == nil {
		t.Fatal("missing cycle_key must fail because it cannot be derived from a removed effective-time field")
	}
}

func TestPolicyModelFromRequestRejectsInvalidCycle(t *testing.T) {
	_, err := policyModelFromRequest("lalu", 1, policyRequest{
		Name:     "Invalid cycle",
		RegionID: 101,
		CycleKey: "2026-13",
		Levels: []levelRequest{
			{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
		},
	})
	if err == nil {
		t.Fatal("expected invalid cycle to fail")
	}
}

func TestCloneHostAgencySalaryPolicyForNextCycleKeepsIndependentDraft(t *testing.T) {
	source := model.HostAgencySalaryPolicy{
		ID: 71, AppCode: "fami", Name: "July policy", RegionID: 25, RegionIDs: []int64{25, 26},
		CycleKey: "2026-07", PolicyVersion: 71, Status: policyStatusActive, PublishStatus: publishStatusPublished,
		PublishedAtMS: 100, Levels: []model.HostAgencySalaryLevel{{ID: 9, PolicyID: 71, LevelNo: 1, RequiredDiamonds: 100}},
	}
	clone := cloneHostAgencySalaryPolicyForCycle(source, "2026-08")
	if clone.ID != 0 || clone.PolicyVersion != 0 || clone.CycleKey != "2026-08" || clone.PublishStatus != publishStatusDraft {
		t.Fatalf("next-cycle copy must be an independent draft: %+v", clone)
	}
	if clone.CopiedFromPolicyID != 71 || clone.CopiedFromCycleKey != "2026-07" || len(clone.RegionIDs) != 2 {
		t.Fatalf("copy provenance or regions mismatch: %+v", clone)
	}
	if len(clone.Levels) != 1 || clone.Levels[0].ID != 0 || clone.Levels[0].PolicyID != 0 {
		t.Fatalf("copied levels must receive new identities: %+v", clone.Levels)
	}
}

func TestEnsureRuntimeHostSalaryPolicySnapshotAcceptsIdenticalExistingSnapshot(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("create sqlmock: %v", err)
	}
	defer db.Close()
	mock.ExpectBegin()
	tx, err := db.BeginTx(context.Background(), nil)
	if err != nil {
		t.Fatalf("begin transaction: %v", err)
	}
	item := model.HostAgencySalaryPolicy{
		ID: 81, AppCode: "fami", Name: "August policy", RegionID: 25, CycleKey: "2026-08", PolicyVersion: 81,
		PolicyType: policyTypeSalaryDiamond,
		Status:     policyStatusActive, SettlementMode: settlementModeDaily, SettlementTriggerMode: settlementTriggerAutomatic,
		GiftCoinToDiamondRatio: "1.000000", ResidualDiamondToUSDRate: "0.000000000000",
		CoinSellerWithdrawalLimitPeriod: "week", CoinSellerWithdrawalLimitCount: 2,
		PlatformWithdrawalLimitPeriod: "day", PlatformWithdrawalLimitCount: 1,
		PlatformWithdrawalAllowedDays: "15,30",
		Levels:                        []model.HostAgencySalaryLevel{{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSD: "1.50", HostCoinReward: 20, AgencySalaryUSD: "0.50", Status: policyStatusActive, SortOrder: 1}},
	}
	mock.ExpectExec("INSERT INTO host_agency_salary_policies").WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectQuery("SELECT cycle_key, policy_version").WithArgs("fami", uint(81), uint64(81)).WillReturnRows(sqlmock.NewRows([]string{
		"cycle_key", "policy_version", "name", "policy_type", "region_id", "status", "settlement_mode", "settlement_trigger_mode",
		"gift_ratio", "points_per_usd", "coins_per_usd", "minimum_usd_minor",
		"coin_seller_minimum_usd_minor", "platform_minimum_usd_minor", "diamond_exchange_enabled",
		"fee_bps", "agency_bps", "residual_rate",
		"coin_period", "coin_count", "platform_period", "platform_count", "platform_allowed_days",
	}).AddRow("2026-08", 81, "August policy", "SALARY_DIAMOND", 25, "active", "daily", "automatic", "1.000000", 0, 0, 0, 0, 0, false, 0, 0, "0.000000000000", "week", 2, "day", 1, "15,30"))
	mock.ExpectQuery("SELECT level_no, required_diamonds").WithArgs("fami", uint(81), uint64(81)).WillReturnRows(sqlmock.NewRows([]string{
		"level_no", "required_diamonds", "host_salary_usd_minor", "host_coin_reward", "agency_salary_usd_minor", "status", "sort_order",
	}).AddRow(1, 100, 150, 20, 50, "active", 1))
	if err := ensureRuntimeHostSalaryPolicySnapshot(context.Background(), tx, item, 81, 200); err != nil {
		t.Fatalf("identical existing runtime snapshot must be idempotent: %v", err)
	}
	mock.ExpectRollback()
	_ = tx.Rollback()
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestRuntimeHostSalaryPolicySnapshotRejectsDifferentContent(t *testing.T) {
	item := model.HostAgencySalaryPolicy{
		Name: "August policy", RegionID: 25, CycleKey: "2026-08", Status: policyStatusActive,
		SettlementMode: settlementModeDaily, SettlementTriggerMode: settlementTriggerAutomatic,
		GiftCoinToDiamondRatio: "1", ResidualDiamondToUSDRate: "0",
		CoinSellerWithdrawalLimitPeriod: "month", PlatformWithdrawalLimitPeriod: "month",
		PlatformWithdrawalAllowedDays: "15,30",
	}
	existing := runtimeHostSalaryPolicySnapshot{
		Name: "August policy", RegionID: 25, CycleKey: "2026-08", PolicyVersion: 81, Status: policyStatusActive,
		SettlementMode: settlementModeDaily, SettlementTriggerMode: settlementTriggerAutomatic,
		GiftCoinToDiamondRatio: "1.000000", ResidualDiamondToUSDRate: "0.000000000000",
		CoinSellerWithdrawalLimitPeriod: "month", PlatformWithdrawalLimitPeriod: "month",
		PlatformWithdrawalLimitCount:  2,
		PlatformWithdrawalAllowedDays: "15,30",
	}
	if runtimeHostSalaryPolicyMatches(existing, item, 81) {
		t.Fatal("different withdrawal limit content must never be accepted as an idempotent snapshot")
	}
}

func TestBindExplicitHostSalaryPolicyMovesSamePolicyToHigherVersion(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("create sqlmock: %v", err)
	}
	defer db.Close()
	mock.ExpectBegin()
	tx, err := db.BeginTx(context.Background(), nil)
	if err != nil {
		t.Fatalf("begin transaction: %v", err)
	}
	item := model.HostAgencySalaryPolicy{ID: 81, AppCode: "fami", CycleKey: "2026-07"}
	mock.ExpectQuery("SELECT policy_id, policy_version, source_cycle_key").
		WithArgs("fami", int64(25), "2026-07").
		WillReturnRows(sqlmock.NewRows([]string{"policy_id", "policy_version", "source_cycle_key"}).AddRow(81, 81, "2026-07"))
	mock.ExpectExec("UPDATE host_salary_policy_cycle_bindings").
		WithArgs(uint64(1784650000000), int64(1784650001000), "fami", int64(25), "2026-07", uint(81), uint64(81)).
		WillReturnResult(sqlmock.NewResult(0, 1))
	if err := bindExplicitHostSalaryPolicy(context.Background(), tx, item, 25, 1784650000000, 1784650001000); err != nil {
		t.Fatalf("same current policy should move to its higher immutable version: %v", err)
	}
	mock.ExpectRollback()
	_ = tx.Rollback()
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestPublishFailureUpdateUsesNotPublishedCAS(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("create sqlmock: %v", err)
	}
	defer db.Close()
	gormDB, err := gorm.Open(gormMySQL.New(gormMySQL.Config{Conn: db, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
	if err != nil {
		t.Fatalf("open gorm: %v", err)
	}
	store := repository.New(gormDB)
	mock.ExpectExec("UPDATE .*admin_host_agency_salary_policies.*publish_status <> \\?").
		WillReturnResult(sqlmock.NewResult(0, 0))
	if err := store.UpdateHostAgencySalaryPolicyPublishFailureIfNotPublished("fami", 91, "duplicate publish", 7); err != nil {
		t.Fatalf("conditional failure update: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("failure update must retain the published guard: %v", err)
	}
}

func TestPolicyTypeForUpdatePreservesPointDraftWhenFieldIsOmitted(t *testing.T) {
	if got := policyTypeForUpdate("", policyTypePointDiamond); got != policyTypePointDiamond {
		t.Fatalf("omitted policy_type must preserve POINT_DIAMOND draft, got %q", got)
	}
	if got := policyTypeForUpdate(policyTypeSalaryDiamond, policyTypePointDiamond); got != policyTypeSalaryDiamond {
		t.Fatalf("explicit draft type change must remain visible to validation, got %q", got)
	}
}

func TestPublishedPolicyRevisionAllowsCurrentBusinessParametersButLocksIdentity(t *testing.T) {
	existing := model.HostAgencySalaryPolicy{
		PolicyType: policyTypePointDiamond, CycleKey: "2026-07", RegionID: 25, RegionIDs: []int64{25, 26}, Status: policyStatusActive,
		AgencyPointShareBPS: 20,
	}
	updated := existing
	updated.AgencyPointShareBPS = 2000
	if err := validatePublishedPolicyRevision(existing, updated, time.Date(2026, time.July, 22, 0, 0, 0, 0, time.UTC)); err != nil {
		t.Fatalf("current policy business parameters should be revisable: %v", err)
	}
	updated.RegionIDs = []int64{25}
	if err := validatePublishedPolicyRevision(existing, updated, time.Date(2026, time.July, 22, 0, 0, 0, 0, time.UTC)); err == nil {
		t.Fatal("published policy region identity must stay locked")
	}
	updated = existing
	if err := validatePublishedPolicyRevision(existing, updated, time.Date(2026, time.August, 1, 0, 0, 0, 0, time.UTC)); err == nil {
		t.Fatal("closed-cycle policy must remain immutable")
	}
}

func TestNextHostSalaryPolicyVersionIsMonotonicAndJSONSafe(t *testing.T) {
	now := time.Date(2026, time.July, 22, 0, 0, 0, 0, time.UTC)
	if got := nextHostSalaryPolicyVersion(81, now); got != uint64(now.UnixMilli()) {
		t.Fatalf("revision should use readable epoch-ms version, got %d", got)
	}
	if got := nextHostSalaryPolicyVersion(uint64(now.UnixMilli()), now); got != uint64(now.UnixMilli())+1 {
		t.Fatalf("same-millisecond revision must still increase, got %d", got)
	}
}
