package activitytemplate

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"testing"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/protobuf/proto"
	walletv1 "hyapp.local/api/proto/wallet/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	domain "hyapp/services/activity-service/internal/domain/activitytemplate"
)

type fakeRepository struct {
	current        domain.Template
	versions       map[int64]domain.Version
	listedVersions []domain.Version
	updateCalls    int
	statusCalls    int
	createCalls    int
}

func (f *fakeRepository) ListActivityTemplates(context.Context, domain.ListQuery) ([]domain.Summary, int64, error) {
	return nil, 0, nil
}

func (f *fakeRepository) GetActivityTemplate(_ context.Context, templateID string) (domain.Template, error) {
	if f.current.TemplateID != templateID {
		return domain.Template{}, xerr.New(xerr.NotFound, "activity template not found")
	}
	return f.current, nil
}

func (f *fakeRepository) CreateActivityTemplate(_ context.Context, item domain.Template, operatorAdminID int64, nowMS int64) (domain.Template, error) {
	f.createCalls++
	item.TemplateID = "acttpl_created"
	item.Status = domain.StatusDraft
	item.Revision = 1
	item.CreatedByAdminID = operatorAdminID
	item.UpdatedByAdminID = operatorAdminID
	item.CreatedAtMS = nowMS
	item.UpdatedAtMS = nowMS
	f.current = item
	return item, nil
}

func (f *fakeRepository) UpdateActivityTemplate(_ context.Context, command domain.UpdateCommand, nowMS int64) (domain.Template, error) {
	f.updateCalls++
	item := command.Template
	item.Revision = command.ExpectedRevision + 1
	item.UpdatedByAdminID = command.OperatorAdminID
	item.UpdatedAtMS = nowMS
	f.current = item
	return item, nil
}

func (f *fakeRepository) SetActivityTemplateStatus(_ context.Context, command domain.StatusCommand, nowMS int64) (domain.Template, error) {
	f.statusCalls++
	item := f.current
	item.Status = command.Status
	item.Revision++
	item.UpdatedByAdminID = command.OperatorAdminID
	item.UpdatedAtMS = nowMS
	if command.Status == domain.StatusPublished {
		item.PublishedVersion++
		item.PublishedByAdminID = command.OperatorAdminID
		item.PublishedAtMS = nowMS
	}
	f.current = item
	return item, nil
}

func (f *fakeRepository) ListActivityTemplateVersions(context.Context, string, int32, int32) ([]domain.Version, int64, error) {
	return append([]domain.Version(nil), f.listedVersions...), int64(len(f.listedVersions)), nil
}

func (f *fakeRepository) GetActivityTemplateVersion(_ context.Context, templateID string, versionNo int64) (domain.Version, error) {
	item, ok := f.versions[versionNo]
	if !ok || item.TemplateID != templateID {
		return domain.Version{}, xerr.New(xerr.NotFound, "activity template version not found")
	}
	return item, nil
}

type fakeCatalog struct {
	gift     *walletv1.GiftConfig
	groups   map[int64]*walletv1.ResourceGroup
	lastGift *walletv1.ListGiftConfigsRequest
	pins     []*walletv1.PinResourceGroupSnapshotRequest
}

type fakeRegionSource struct {
	regionIDs []int64
	err       error
}

func (f fakeRegionSource) ListActiveRegionIDs(context.Context) ([]int64, error) {
	return append([]int64(nil), f.regionIDs...), f.err
}

func (f *fakeCatalog) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest, _ ...grpc.CallOption) (*walletv1.ListGiftConfigsResponse, error) {
	f.lastGift = req
	if f.gift == nil || f.gift.GetGiftId() != req.GetKeyword() {
		return &walletv1.ListGiftConfigsResponse{}, nil
	}
	return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{f.gift}, Total: 1}, nil
}

func (f *fakeCatalog) GetResourceGroup(_ context.Context, req *walletv1.GetResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.GetResourceGroupResponse, error) {
	group := f.groups[req.GetGroupId()]
	if group == nil {
		return nil, xerr.New(xerr.NotFound, "resource group not found")
	}
	return &walletv1.GetResourceGroupResponse{Group: group, SourceContentHash: fakeResourceGroupHash(group)}, nil
}

func (f *fakeCatalog) PinResourceGroupSnapshot(_ context.Context, req *walletv1.PinResourceGroupSnapshotRequest, _ ...grpc.CallOption) (*walletv1.PinResourceGroupSnapshotResponse, error) {
	f.pins = append(f.pins, req)
	group := f.groups[req.GetGroupId()]
	if group == nil {
		return nil, xerr.New(xerr.NotFound, "resource group not found")
	}
	hash := fakeResourceGroupHash(group)
	if req.GetExpectedSourceContentHash() != hash || req.GetExpectedGroupUpdatedAtMs() != group.GetUpdatedAtMs() {
		return nil, xerr.New(xerr.Conflict, "resource group content changed")
	}
	return &walletv1.PinResourceGroupSnapshotResponse{Snapshot: &walletv1.ResourceGroupSnapshot{
		AppCode: req.GetAppCode(), SnapshotId: "rgs_" + hash[:24], PinKey: req.GetPinKey(), SourceGroupId: group.GetGroupId(),
		VersionNo: 1, SnapshotHash: hash, Group: group, CreatedByUserId: req.GetOperatorUserId(), CreatedAtMs: 1,
		SourceGroupUpdatedAtMs: group.GetUpdatedAtMs(), SourceContentHash: hash,
	}}, nil
}

func fakeResourceGroupHash(group *walletv1.ResourceGroup) string {
	body, _ := proto.MarshalOptions{Deterministic: true}.Marshal(group)
	sum := sha256.Sum256(body)
	return hex.EncodeToString(sum[:])
}

func TestLifecycleStatusUsesUTCLeftClosedRightOpenBoundary(t *testing.T) {
	const startMS = int64(10_000)
	const endMS = int64(20_000)
	tests := []struct {
		name string
		now  int64
		want string
	}{
		{name: "before start", now: startMS - 1, want: domain.LifecycleScheduled},
		{name: "at start", now: startMS, want: domain.LifecycleOngoing},
		{name: "before end", now: endMS - 1, want: domain.LifecycleOngoing},
		{name: "at end", now: endMS, want: domain.LifecycleEnded},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := lifecycleStatus(domain.StatusPublished, startMS, endMS, tt.now); got != tt.want {
				t.Fatalf("lifecycleStatus() = %q, want %q", got, tt.want)
			}
		})
	}
}

func TestListVersionsUsesActualRuntimeWindowAfterDisableAndRepublish(t *testing.T) {
	const (
		startMS = int64(100)
		endMS   = int64(1_000)
		nowMS   = int64(700)
	)
	snapshot := domain.Template{
		TemplateID: "acttpl_republished", TemplateCode: "summer", Status: domain.StatusPublished,
		StartMS: startMS, EndMS: endMS,
	}
	repository := &fakeRepository{listedVersions: []domain.Version{
		{TemplateID: snapshot.TemplateID, VersionNo: 2, Snapshot: snapshot, RuntimeFromMS: 600},
		{TemplateID: snapshot.TemplateID, VersionNo: 1, Snapshot: snapshot, RuntimeFromMS: startMS, RuntimeToMS: 500},
	}}
	svc := New(repository, nil, nil)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }

	versions, total, err := svc.ListVersions(context.Background(), snapshot.TemplateID, 1, 20)
	if err != nil {
		t.Fatalf("ListVersions() error = %v", err)
	}
	if total != 2 || len(versions) != 2 {
		t.Fatalf("ListVersions() total/items = %d/%d", total, len(versions))
	}
	if versions[0].VersionNo != 2 || versions[0].Snapshot.LifecycleStatus != domain.LifecycleOngoing || versions[0].RuntimeFromMS != 600 || versions[0].RuntimeToMS != 0 {
		t.Fatalf("republished v2 lifecycle/window = %+v", versions[0])
	}
	if versions[1].VersionNo != 1 || versions[1].Snapshot.LifecycleStatus != domain.LifecycleDisabled || versions[1].RuntimeFromMS != startMS || versions[1].RuntimeToMS != 500 {
		t.Fatalf("disabled v1 lifecycle/window = %+v", versions[1])
	}
}

func TestRuntimeLifecycleStatusUsesActualWindowBoundaries(t *testing.T) {
	item := domain.Template{Status: domain.StatusPublished, StartMS: 100, EndMS: 1_000}
	tests := []struct {
		name          string
		runtimeFromMS int64
		runtimeToMS   int64
		nowMS         int64
		want          string
	}{
		{name: "scheduled", runtimeFromMS: 200, nowMS: 199, want: domain.LifecycleScheduled},
		{name: "ongoing at runtime start", runtimeFromMS: 200, nowMS: 200, want: domain.LifecycleOngoing},
		{name: "disabled at exclusive runtime end", runtimeFromMS: 200, runtimeToMS: 500, nowMS: 500, want: domain.LifecycleDisabled},
		{name: "disabled remains historical state", runtimeFromMS: 200, runtimeToMS: 500, nowMS: 1_500, want: domain.LifecycleDisabled},
		{name: "naturally ended", runtimeFromMS: 200, nowMS: 1_000, want: domain.LifecycleEnded},
		{name: "disable after planned end stays ended", runtimeFromMS: 200, runtimeToMS: 1_200, nowMS: 1_200, want: domain.LifecycleEnded},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := RuntimeLifecycleStatus(item, tt.runtimeFromMS, tt.runtimeToMS, tt.nowMS); got != tt.want {
				t.Fatalf("RuntimeLifecycleStatus() = %q, want %q", got, tt.want)
			}
		})
	}
}

func TestListRejectsNegativeTimeFilter(t *testing.T) {
	svc := New(&fakeRepository{}, nil, nil)
	_, _, err := svc.List(context.Background(), domain.ListQuery{StartMS: -1})
	if !xerr.IsCode(err, xerr.InvalidArgument) {
		t.Fatalf("List() error = %v, want invalid argument", err)
	}
}

func TestValidatePublishRejectsOverlappingRankRangesAndNonObjectDisplayConfig(t *testing.T) {
	nowMS := int64(1_800_000_000_000)
	item := publishableTemplate(nowMS)
	item.RankRewards = append(item.RankRewards, domain.RankReward{
		BoardType: domain.BoardTypeDaily, RankFrom: 3, RankTo: 5, ResourceGroupID: 102, SortOrder: 2,
	})
	item.DisplayConfigJSON = "null"
	svc := New(&fakeRepository{}, nil, nil)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }

	issues, err := svc.Validate(context.Background(), item, true)
	if err != nil {
		t.Fatalf("Validate() error = %v", err)
	}
	if !hasIssue(issues, "rank_rewards", "overlap") {
		t.Fatalf("expected rank overlap issue, got %+v", issues)
	}
	if !hasIssue(issues, "display_config_json", "invalid_json") {
		t.Fatalf("expected JSON object issue, got %+v", issues)
	}
}

func TestValidateRejectsMultipleDailyVisitTasks(t *testing.T) {
	nowMS := int64(1_800_000_000_000)
	item := publishableTemplate(nowMS)
	item.Tasks = append(item.Tasks, domain.Task{
		TaskKey: "daily_visit_bonus", TaskType: domain.TaskTypeDailyVisit,
		TargetValue: 1, RewardResourceGroupID: 101, SortOrder: 2,
	})
	svc := New(&fakeRepository{}, nil, nil)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }

	issues, err := svc.Validate(context.Background(), item, false)
	if err != nil {
		t.Fatalf("Validate() error = %v", err)
	}
	if !hasIssue(issues, "tasks", "duplicate_daily_visit") {
		t.Fatalf("expected duplicate daily_visit issue, got %+v", issues)
	}
}

func TestSetStatusPublishesOnlyAfterOwnerReferencesValidate(t *testing.T) {
	nowMS := int64(1_800_000_000_000)
	item := publishableTemplate(nowMS)
	repository := &fakeRepository{current: item}
	catalog := publishableCatalog(item)
	svc := New(repository, catalog, fakeRegionSource{regionIDs: []int64{1}})
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }
	ctx := appcode.WithContext(context.Background(), "hyapp")

	got, err := svc.SetStatus(ctx, domain.StatusCommand{
		TemplateID: item.TemplateID, Status: domain.StatusPublished,
		ExpectedRevision: item.Revision, OperatorAdminID: 90001,
	})
	if err != nil {
		t.Fatalf("SetStatus() error = %v", err)
	}
	if got.Status != domain.StatusPublished || got.PublishedVersion != 1 || got.Revision != item.Revision+1 {
		t.Fatalf("published template metadata = %+v", got)
	}
	if repository.statusCalls != 1 {
		t.Fatalf("status repository calls = %d, want 1", repository.statusCalls)
	}
	if catalog.lastGift == nil || catalog.lastGift.GetStatus() != "active" || catalog.lastGift.GetActiveOnly() {
		t.Fatalf("gift lookup must load active owner config without current-time-only filtering: %+v", catalog.lastGift)
	}
	if len(catalog.pins) != 3 {
		t.Fatalf("publish pin calls = %d, want 3", len(catalog.pins))
	}
	for _, pin := range catalog.pins {
		if pin.GetRequiredAllRegions() || len(pin.GetRequiredRegionIds()) != 1 || pin.GetRequiredRegionIds()[0] != 1 {
			t.Fatalf("targeted activity pin omitted required region coverage: %+v", pin)
		}
	}
}

func TestSetStatusAllRegionsRequiresGlobalRewardGiftCoverageAtWalletPin(t *testing.T) {
	nowMS := int64(1_800_000_000_000)
	item := publishableTemplate(nowMS)
	item.AllRegions = true
	item.RegionIDs = nil
	repository := &fakeRepository{current: item}
	catalog := publishableCatalog(item)
	svc := New(repository, catalog, fakeRegionSource{regionIDs: []int64{1, 2}})
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }

	if _, err := svc.SetStatus(appcode.WithContext(context.Background(), "hyapp"), domain.StatusCommand{
		TemplateID: item.TemplateID, Status: domain.StatusPublished,
		ExpectedRevision: item.Revision, OperatorAdminID: 90001,
	}); err != nil {
		t.Fatalf("publish all-regions template: %v", err)
	}
	if len(catalog.pins) != 3 {
		t.Fatalf("all-regions publish pin calls = %d, want 3", len(catalog.pins))
	}
	for _, pin := range catalog.pins {
		if !pin.GetRequiredAllRegions() || len(pin.GetRequiredRegionIds()) != 0 {
			t.Fatalf("all-regions activity must require global reward gift coverage: %+v", pin)
		}
	}
}

func TestValidatePublishRejectsGiftThatExpiresDuringActivity(t *testing.T) {
	nowMS := int64(1_800_000_000_000)
	item := publishableTemplate(nowMS)
	catalog := publishableCatalog(item)
	catalog.gift.EffectiveToMs = item.EndMS - 1
	svc := New(&fakeRepository{}, catalog, fakeRegionSource{regionIDs: []int64{1}})
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }

	issues, err := svc.Validate(context.Background(), item, true)
	if err != nil {
		t.Fatalf("Validate() error = %v", err)
	}
	if !hasIssue(issues, "gifts", "gift_period_mismatch") {
		t.Fatalf("expected gift period issue, got %+v", issues)
	}
}

func TestValidatePublishRejectsInactiveTargetRegion(t *testing.T) {
	nowMS := int64(1_800_000_000_000)
	item := publishableTemplate(nowMS)
	svc := New(&fakeRepository{}, publishableCatalog(item), fakeRegionSource{regionIDs: []int64{2}})
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }

	issues, err := svc.Validate(context.Background(), item, true)
	if err != nil {
		t.Fatalf("Validate() error = %v", err)
	}
	if !hasIssue(issues, "region_ids", "region_not_active") {
		t.Fatalf("expected inactive region issue, got %+v", issues)
	}
}

func TestUpdateRejectsStaleRevisionBeforeWriting(t *testing.T) {
	nowMS := int64(1_800_000_000_000)
	current := publishableTemplate(nowMS)
	current.Status = domain.StatusDisabled
	repository := &fakeRepository{current: current}
	svc := New(repository, nil, nil)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }

	_, err := svc.Update(context.Background(), domain.UpdateCommand{
		Template: current, ExpectedRevision: current.Revision - 1, OperatorAdminID: 90001,
	})
	if !xerr.IsCode(err, xerr.Conflict) {
		t.Fatalf("Update() error = %v, want conflict", err)
	}
	if repository.updateCalls != 0 {
		t.Fatalf("stale update wrote repository %d times", repository.updateCalls)
	}
}

func TestUpdateRejectsTemplateCodeChangeAfterCreate(t *testing.T) {
	nowMS := int64(1_800_000_000_000)
	current := publishableTemplate(nowMS)
	current.Status = domain.StatusDisabled
	repository := &fakeRepository{current: current}
	svc := New(repository, nil, nil)
	changed := current
	changed.TemplateCode = "replacement_code"

	_, err := svc.Update(context.Background(), domain.UpdateCommand{
		Template: changed, ExpectedRevision: current.Revision, OperatorAdminID: 90001,
	})
	if !xerr.IsCode(err, xerr.Conflict) || repository.updateCalls != 0 {
		t.Fatalf("code mutation error=%v update_calls=%d", err, repository.updateCalls)
	}
}

func TestDeleteRequiresScheduledOrOngoingPublishedTemplateToBeDisabledFirst(t *testing.T) {
	nowMS := int64(1_800_000_000_000)
	current := publishableTemplate(nowMS)
	current.Status = domain.StatusPublished
	repository := &fakeRepository{current: current}
	svc := New(repository, nil, nil)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }

	_, _, err := svc.Delete(context.Background(), domain.StatusCommand{
		TemplateID: current.TemplateID, ExpectedRevision: current.Revision, OperatorAdminID: 90001,
	})
	if !xerr.IsCode(err, xerr.Conflict) {
		t.Fatalf("Delete() error = %v, want conflict", err)
	}
	if repository.statusCalls != 0 {
		t.Fatalf("scheduled template was archived %d times", repository.statusCalls)
	}
}

func TestDeleteAllowsEndedPublishedTemplateToArchive(t *testing.T) {
	nowMS := int64(1_800_000_000_000)
	current := publishableTemplate(nowMS)
	current.Status = domain.StatusPublished
	current.StartMS = nowMS - 2_000
	current.EndMS = nowMS - 1_000
	repository := &fakeRepository{current: current}
	svc := New(repository, nil, nil)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }

	got, archived, err := svc.Delete(context.Background(), domain.StatusCommand{
		TemplateID: current.TemplateID, ExpectedRevision: current.Revision, OperatorAdminID: 90001,
	})
	if err != nil {
		t.Fatalf("Delete() error = %v", err)
	}
	if !archived || got.Status != domain.StatusArchived || got.LifecycleStatus != domain.LifecycleArchived {
		t.Fatalf("Delete() = archived:%v template:%+v", archived, got)
	}
}

func TestClonePreservesConfigurationButClearsRuntimeIdentityAndSchedule(t *testing.T) {
	nowMS := int64(1_800_000_000_000)
	source := publishableTemplate(nowMS)
	source.Status = domain.StatusDisabled
	source.PublishedVersion = 3
	source.PublishedAtMS = nowMS - 10_000
	repository := &fakeRepository{current: source}
	svc := New(repository, nil, nil)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }

	got, err := svc.Clone(context.Background(), domain.CloneCommand{
		SourceTemplateID: source.TemplateID, TemplateCode: "gift_challenge_copy", Name: "Gift Challenge Copy", OperatorAdminID: 90002,
	})
	if err != nil {
		t.Fatalf("Clone() error = %v", err)
	}
	if got.TemplateID == source.TemplateID || got.TemplateCode != "gift_challenge_copy" || got.Status != domain.StatusDraft {
		t.Fatalf("clone identity/status = %+v", got)
	}
	if got.StartMS != 0 || got.EndMS != 0 || got.PublishedVersion != 0 || got.PublishedAtMS != 0 {
		t.Fatalf("clone leaked runtime metadata = %+v", got)
	}
	if len(got.RegionIDs) != 1 || got.RegionIDs[0] != source.RegionIDs[0] ||
		len(got.Tasks) != 1 || got.Tasks[0].RewardResourceGroupID != source.Tasks[0].RewardResourceGroupID ||
		len(got.RankRewards) != len(source.RankRewards) {
		t.Fatalf("clone did not preserve configuration: %+v", got)
	}
}

func publishableTemplate(nowMS int64) domain.Template {
	return domain.Template{
		TemplateID: "acttpl_source", TemplateCode: "gift_challenge_2026", Name: "Gift Challenge 2026",
		ActivityType: domain.ActivityTypeGiftChallenge, Status: domain.StatusDraft, Revision: 7,
		StartMS: nowMS + 60_000, EndMS: nowMS + int64((7*24*time.Hour)/time.Millisecond),
		AllRegions: false, RegionIDs: []int64{1},
		Locales: []domain.Locale{{Locale: "en", Title: "Gift Challenge", Rules: "Send gifts and complete tasks."}},
		Gifts: []domain.Gift{{
			GiftID: "gift_rose", SortOrder: 1, Name: "Rose", IconURL: "https://cdn.example.com/gifts/rose.png", CoinPrice: 100, PriceVersion: "v3",
		}},
		Tasks: []domain.Task{{
			TaskKey: "daily_visit", TaskType: domain.TaskTypeDailyVisit, TargetValue: 1, RewardResourceGroupID: 101, SortOrder: 1,
		}},
		DailyLeaderboardSize: 100,
		TotalLeaderboardSize: 100,
		RankRewards: []domain.RankReward{
			{BoardType: domain.BoardTypeDaily, RankFrom: 1, RankTo: 3, ResourceGroupID: 102, SortOrder: 1},
			{BoardType: domain.BoardTypeTotal, RankFrom: 1, RankTo: 3, ResourceGroupID: 103, SortOrder: 1},
		},
		DisplayConfigJSON: `{}`,
	}
}

func publishableCatalog(item domain.Template) *fakeCatalog {
	groups := make(map[int64]*walletv1.ResourceGroup)
	for _, groupID := range []int64{101, 102, 103} {
		groups[groupID] = &walletv1.ResourceGroup{
			GroupId: groupID, Status: "active", Name: "Reward", UpdatedAtMs: 1_800_000_000_001,
			Items: []*walletv1.ResourceGroupItem{{
				GroupId: groupID, ResourceId: groupID + 1000, Quantity: 1, ItemType: "resource",
				Resource: &walletv1.Resource{ResourceId: groupID + 1000, ResourceType: "badge", Name: "Badge", Status: "active", Grantable: true, GrantStrategy: "new_entitlement"},
			}},
		}
	}
	return &fakeCatalog{
		gift: &walletv1.GiftConfig{
			GiftId: item.Gifts[0].GiftID, Status: "active", Name: item.Gifts[0].Name,
			CoinPrice: item.Gifts[0].CoinPrice, PriceVersion: item.Gifts[0].PriceVersion,
			RegionIds: []int64{0},
			Resource: &walletv1.Resource{
				Status: "active", PreviewUrl: item.Gifts[0].IconURL, AssetUrl: "https://cdn.example.com/gifts/rose-animation.svga",
			},
		},
		groups: groups,
	}
}

func hasIssue(issues []domain.ValidationIssue, field string, code string) bool {
	for _, issue := range issues {
		if issue.Field == field && issue.Code == code {
			return true
		}
	}
	return false
}
