package devices

import (
	"context"
	"database/sql"
	"strings"
	"testing"
	"time"
)

// freePlan is the standard free fallback used in resolver tests
// (numbers per design/CLAUDE.md §7).
func freePlan() Plan {
	m := 10
	return Plan{PlanCode: "free", MaxDevices: 1, DailyMinutes: &m, AdGate: true, Source: "free"}
}

func mkSub(code string, maxDevices int, expiresAt time.Time, source string) effSub {
	return effSub{PlanCode: code, MaxDevices: maxDevices, ExpiresAt: expiresAt, Source: source}
}

func TestResolveEffectivePlan_TrialActiveGivesPro(t *testing.T) {
	now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
	subs := []effSub{mkSub("pro", 5, now.Add(24*time.Hour), "trial")}

	got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
	if apiErr != nil {
		t.Fatalf("unexpected error: %v", apiErr)
	}
	if got.PlanCode != "pro" || got.MaxDevices != 5 {
		t.Fatalf("want pro/5, got %s/%d", got.PlanCode, got.MaxDevices)
	}
	if got.Source != "trial" {
		t.Errorf("want source trial, got %s", got.Source)
	}
	if got.ExpiresAt == nil {
		t.Fatal("expected non-nil expires_at")
	}
}

func TestResolveEffectivePlan_ExpiredFallsBackToFree(t *testing.T) {
	now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
	// pro trial expired one second ago.
	subs := []effSub{mkSub("pro", 5, now.Add(-time.Second), "trial")}

	got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
	if apiErr != nil {
		t.Fatalf("unexpected error: %v", apiErr)
	}
	if got.PlanCode != "free" {
		t.Fatalf("want free, got %s", got.PlanCode)
	}
	if got.ExpiresAt != nil {
		t.Errorf("free fallback should have nil expires_at, got %v", *got.ExpiresAt)
	}
	if got.MaxDevices != 1 {
		t.Errorf("want free max_devices 1, got %d", got.MaxDevices)
	}
}

func TestResolveEffectivePlan_BannedRejected(t *testing.T) {
	now := time.Now().UTC()
	subs := []effSub{mkSub("pro", 5, now.Add(time.Hour), "trial")}

	_, apiErr := resolveEffectivePlan(now, "banned", subs, freePlan())
	if apiErr == nil {
		t.Fatal("expected banned to be rejected")
	}
	if apiErr.Code != "ACCOUNT_BANNED" {
		t.Errorf("want ACCOUNT_BANNED, got %s", apiErr.Code)
	}
}

func TestResolveEffectivePlan_HighestTierWins(t *testing.T) {
	now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
	subs := []effSub{
		mkSub("pro", 5, now.Add(100*24*time.Hour), "code"), // longer pro
		mkSub("team", 10, now.Add(24*time.Hour), "code"),   // shorter team
		mkSub("free", 1, now.Add(50*24*time.Hour), "trial"),
	}

	got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
	if apiErr != nil {
		t.Fatalf("unexpected error: %v", apiErr)
	}
	// Highest tier (team) wins even though pro expires later.
	if got.PlanCode != "team" || got.MaxDevices != 10 {
		t.Fatalf("want team/10, got %s/%d", got.PlanCode, got.MaxDevices)
	}
}

func TestResolveEffectivePlan_SameTierLatestExpiryWins(t *testing.T) {
	now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
	early := now.Add(24 * time.Hour)
	late := now.Add(48 * time.Hour)
	subs := []effSub{
		mkSub("pro", 5, early, "code"),
		mkSub("pro", 5, late, "code"),
	}

	got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
	if apiErr != nil {
		t.Fatalf("unexpected error: %v", apiErr)
	}
	if got.ExpiresAt == nil || !got.ExpiresAt.Equal(late) {
		t.Fatalf("want latest expiry %v, got %v", late, got.ExpiresAt)
	}
}

func TestResolveEffectivePlan_UTCBoundaryStrict(t *testing.T) {
	now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
	// expires_at exactly == now must count as expired (strict >).
	subs := []effSub{mkSub("pro", 5, now, "trial")}

	got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
	if apiErr != nil {
		t.Fatalf("unexpected error: %v", apiErr)
	}
	if got.PlanCode != "free" {
		t.Fatalf("expires_at == now should be expired; want free, got %s", got.PlanCode)
	}

	// One microsecond after now is still active.
	subs2 := []effSub{mkSub("pro", 5, now.Add(time.Microsecond), "trial")}
	got2, _ := resolveEffectivePlan(now, "active", subs2, freePlan())
	if got2.PlanCode != "pro" {
		t.Fatalf("expires_at just after now should be active; want pro, got %s", got2.PlanCode)
	}
}

func TestResolveEffectivePlan_NoSubsGivesFree(t *testing.T) {
	got, apiErr := resolveEffectivePlan(time.Now().UTC(), "active", nil, freePlan())
	if apiErr != nil {
		t.Fatalf("unexpected error: %v", apiErr)
	}
	if got.PlanCode != "free" || got.Source != "free" {
		t.Fatalf("want free/free, got %s/%s", got.PlanCode, got.Source)
	}
}

func TestResolveEffectivePlan_DailyMinutesPropagated(t *testing.T) {
	now := time.Now().UTC()
	s := mkSub("pro", 5, now.Add(time.Hour), "code")
	s.DailyMinutes = sql.NullInt64{} // pro: unlimited
	got, _ := resolveEffectivePlan(now, "active", []effSub{s}, freePlan())
	if got.DailyMinutes != nil {
		t.Errorf("pro daily_minutes should be nil (unlimited), got %d", *got.DailyMinutes)
	}

	free := resolveFree(t, now)
	if free.DailyMinutes == nil || *free.DailyMinutes != 10 {
		t.Errorf("free daily_minutes should be 10, got %v", free.DailyMinutes)
	}
}

func resolveFree(t *testing.T, now time.Time) Plan {
	t.Helper()
	got, apiErr := resolveEffectivePlan(now, "active", nil, freePlan())
	if apiErr != nil {
		t.Fatalf("unexpected error: %v", apiErr)
	}
	return got
}

func TestCheckDeviceQuota(t *testing.T) {
	free := Plan{PlanCode: "free", MaxDevices: 1}
	pro := Plan{PlanCode: "pro", MaxDevices: 5}

	if err := CheckDeviceQuota(free, 0); err != nil {
		t.Errorf("0 < 1 should pass, got %v", err)
	}
	if err := CheckDeviceQuota(free, 1); err == nil {
		t.Error("1 >= 1 should be rejected")
	} else if err.Code != "DEVICE_LIMIT_EXCEEDED" {
		t.Errorf("want DEVICE_LIMIT_EXCEEDED, got %s", err.Code)
	}
	if err := CheckDeviceQuota(pro, 4); err != nil {
		t.Errorf("4 < 5 should pass, got %v", err)
	}
	if err := CheckDeviceQuota(pro, 5); err == nil {
		t.Error("5 >= 5 should be rejected")
	}
}

func TestErrDeviceLimitContainsNumber(t *testing.T) {
	e := errDeviceLimit(5)
	if !strings.Contains(e.MessageZH, "5") {
		t.Errorf("zh message should contain the limit 5: %q", e.MessageZH)
	}
	if !strings.Contains(e.MessageEn, "5") {
		t.Errorf("en message should contain the limit 5: %q", e.MessageEn)
	}
}

func TestRequirePaidTier(t *testing.T) {
	if err := RequirePaidTier(Plan{PlanCode: "free"}); err == nil {
		t.Error("free should require paid tier")
	} else if err.Code != "PAID_TIER_REQUIRED" {
		t.Errorf("want PAID_TIER_REQUIRED, got %s", err.Code)
	}
	if err := RequirePaidTier(Plan{PlanCode: "pro"}); err != nil {
		t.Errorf("pro should pass, got %v", err)
	}
	if err := RequirePaidTier(Plan{PlanCode: "team"}); err != nil {
		t.Errorf("team should pass, got %v", err)
	}
}

func TestNormalizePlatform(t *testing.T) {
	cases := map[string]bool{
		"ios": true, "iOS": true, "ANDROID": true, "windows": true, "macos": true,
		"linux": false, "": false, "blackberry": false,
	}
	for in, wantOK := range cases {
		_, ok := normalizePlatform(in)
		if ok != wantOK {
			t.Errorf("normalizePlatform(%q) ok=%v, want %v", in, ok, wantOK)
		}
	}
}

func TestNormalizeName(t *testing.T) {
	if got := normalizeName("", "ios"); got != "ios" {
		t.Errorf("empty name should fall back to platform, got %q", got)
	}
	if got := normalizeName("  iPhone 15  ", "ios"); got != "iPhone 15" {
		t.Errorf("name should be trimmed, got %q", got)
	}
	long := strings.Repeat("名", 100)
	if got := normalizeName(long, "ios"); len([]rune(got)) != 64 {
		t.Errorf("name should be truncated to 64 runes, got %d", len([]rune(got)))
	}
}

func TestUserIDContextRoundTrip(t *testing.T) {
	ctx := WithUserID(context.Background(), 42)
	id, ok := UserIDFromContext(ctx)
	if !ok || id != 42 {
		t.Fatalf("want 42/true, got %d/%v", id, ok)
	}
	if _, ok := UserIDFromContext(context.Background()); ok {
		t.Error("empty context should yield ok=false")
	}
	// userID 0 is treated as absent.
	if _, ok := UserIDFromContext(WithUserID(context.Background(), 0)); ok {
		t.Error("userID 0 should be treated as absent")
	}
}

func TestNoopRevokerRecordsCalls(t *testing.T) {
	n := &NoopRevoker{}
	_ = n.RevokeForUser(context.Background(), 7, "device_deleted")
	if len(n.Calls) != 1 || n.Calls[0].UserID != 7 || n.Calls[0].Reason != "device_deleted" {
		t.Fatalf("unexpected calls: %+v", n.Calls)
	}
}
