//go:build integration

package repository

import (
	"context"
	"errors"
	"fmt"
	"testing"
	"time"

	dbent "github.com/Wei-Shaw/sub2api/ent"
	"github.com/Wei-Shaw/sub2api/internal/service"
	"github.com/stretchr/testify/suite"
)

type UserProfileIdentityRepoSuite struct {
	suite.Suite
	ctx    context.Context
	client *dbent.Client
	repo   *userRepository
}

func TestUserProfileIdentityRepoSuite(t *testing.T) {
	suite.Run(t, new(UserProfileIdentityRepoSuite))
}

func (s *UserProfileIdentityRepoSuite) SetupTest() {
	s.ctx = context.Background()
	s.client = testEntClient(s.T())
	s.repo = newUserRepositoryWithSQL(s.client, integrationDB)

	_, err := integrationDB.ExecContext(s.ctx, `
TRUNCATE TABLE
	identity_adoption_decisions,
	auth_identity_channels,
	auth_identities,
	pending_auth_sessions,
	auth_identity_migration_reports,
	user_provider_default_grants,
	user_avatars
RESTART IDENTITY`)
	s.Require().NoError(err)
}

func (s *UserProfileIdentityRepoSuite) mustCreateUser(label string) *dbent.User {
	s.T().Helper()

	user, err := s.client.User.Create().
		SetEmail(fmt.Sprintf("%s-%d@example.com", label, time.Now().UnixNano())).
		SetPasswordHash("test-password-hash").
		SetRole("user").
		SetStatus("active").
		Save(s.ctx)
	s.Require().NoError(err)
	return user
}

func (s *UserProfileIdentityRepoSuite) mustCreatePendingAuthSession(key AuthIdentityKey) *dbent.PendingAuthSession {
	s.T().Helper()

	session, err := s.client.PendingAuthSession.Create().
		SetSessionToken(fmt.Sprintf("pending-%d", time.Now().UnixNano())).
		SetIntent("bind_current_user").
		SetProviderType(key.ProviderType).
		SetProviderKey(key.ProviderKey).
		SetProviderSubject(key.ProviderSubject).
		SetExpiresAt(time.Now().UTC().Add(15 * time.Minute)).
		SetUpstreamIdentityClaims(map[string]any{"provider_subject": key.ProviderSubject}).
		SetLocalFlowState(map[string]any{"step": "pending"}).
		Save(s.ctx)
	s.Require().NoError(err)
	return session
}

func (s *UserProfileIdentityRepoSuite) TestCreateAndLookupCanonicalAndChannelIdentity() {
	user := s.mustCreateUser("canonical-channel")

	verifiedAt := time.Now().UTC().Truncate(time.Second)
	created, err := s.repo.CreateAuthIdentity(s.ctx, CreateAuthIdentityInput{
		UserID: user.ID,
		Canonical: AuthIdentityKey{
			ProviderType:    "wechat",
			ProviderKey:     "wechat-open",
			ProviderSubject: "union-123",
		},
		Channel: &AuthIdentityChannelKey{
			ProviderType:   "wechat",
			ProviderKey:    "wechat-open",
			Channel:        "mp",
			ChannelAppID:   "wx-app",
			ChannelSubject: "openid-123",
		},
		Issuer:          stringPtr("https://issuer.example"),
		VerifiedAt:      &verifiedAt,
		Metadata:        map[string]any{"unionid": "union-123"},
		ChannelMetadata: map[string]any{"openid": "openid-123"},
	})
	s.Require().NoError(err)
	s.Require().NotNil(created.Identity)
	s.Require().NotNil(created.Channel)

	canonical, err := s.repo.GetUserByCanonicalIdentity(s.ctx, created.IdentityRef())
	s.Require().NoError(err)
	s.Require().Equal(user.ID, canonical.User.ID)
	s.Require().Equal(created.Identity.ID, canonical.Identity.ID)
	s.Require().Equal("union-123", canonical.Identity.ProviderSubject)

	channel, err := s.repo.GetUserByChannelIdentity(s.ctx, *created.ChannelRef())
	s.Require().NoError(err)
	s.Require().Equal(user.ID, channel.User.ID)
	s.Require().Equal(created.Identity.ID, channel.Identity.ID)
	s.Require().Equal(created.Channel.ID, channel.Channel.ID)
}

func (s *UserProfileIdentityRepoSuite) TestBindAuthIdentityToUser_IsIdempotentAndRejectsOtherOwners() {
	owner := s.mustCreateUser("owner")
	other := s.mustCreateUser("other")

	first, err := s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
		UserID: owner.ID,
		Canonical: AuthIdentityKey{
			ProviderType:    "linuxdo",
			ProviderKey:     "linuxdo-main",
			ProviderSubject: "subject-1",
		},
		Channel: &AuthIdentityChannelKey{
			ProviderType:   "linuxdo",
			ProviderKey:    "linuxdo-main",
			Channel:        "oauth",
			ChannelAppID:   "linuxdo-web",
			ChannelSubject: "subject-1",
		},
		Metadata:        map[string]any{"username": "first"},
		ChannelMetadata: map[string]any{"scope": "read"},
	})
	s.Require().NoError(err)

	second, err := s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
		UserID: owner.ID,
		Canonical: AuthIdentityKey{
			ProviderType:    "linuxdo",
			ProviderKey:     "linuxdo-main",
			ProviderSubject: "subject-1",
		},
		Channel: &AuthIdentityChannelKey{
			ProviderType:   "linuxdo",
			ProviderKey:    "linuxdo-main",
			Channel:        "oauth",
			ChannelAppID:   "linuxdo-web",
			ChannelSubject: "subject-1",
		},
		Metadata:        map[string]any{"username": "second"},
		ChannelMetadata: map[string]any{"scope": "write"},
	})
	s.Require().NoError(err)
	s.Require().Equal(first.Identity.ID, second.Identity.ID)
	s.Require().Equal(first.Channel.ID, second.Channel.ID)
	s.Require().Equal("second", second.Identity.Metadata["username"])
	s.Require().Equal("write", second.Channel.Metadata["scope"])

	_, err = s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
		UserID: other.ID,
		Canonical: AuthIdentityKey{
			ProviderType:    "linuxdo",
			ProviderKey:     "linuxdo-main",
			ProviderSubject: "subject-1",
		},
	})
	s.Require().ErrorIs(err, ErrAuthIdentityOwnershipConflict)

	_, err = s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
		UserID: other.ID,
		Canonical: AuthIdentityKey{
			ProviderType:    "linuxdo",
			ProviderKey:     "linuxdo-main",
			ProviderSubject: "subject-2",
		},
		Channel: &AuthIdentityChannelKey{
			ProviderType:   "linuxdo",
			ProviderKey:    "linuxdo-main",
			Channel:        "oauth",
			ChannelAppID:   "linuxdo-web",
			ChannelSubject: "subject-1",
		},
	})
	s.Require().ErrorIs(err, ErrAuthIdentityChannelOwnershipConflict)
}

func (s *UserProfileIdentityRepoSuite) TestCreateAuthIdentity_RejectsChannelProviderMismatch() {
	user := s.mustCreateUser("provider-mismatch-create")

	_, err := s.repo.CreateAuthIdentity(s.ctx, CreateAuthIdentityInput{
		UserID: user.ID,
		Canonical: AuthIdentityKey{
			ProviderType:    "wechat",
			ProviderKey:     "wechat-main",
			ProviderSubject: "union-create-mismatch",
		},
		Channel: &AuthIdentityChannelKey{
			ProviderType:   "linuxdo",
			ProviderKey:    "linuxdo-main",
			Channel:        "oauth",
			ChannelAppID:   "app-mismatch",
			ChannelSubject: "openid-create-mismatch",
		},
	})
	s.Require().ErrorIs(err, ErrAuthIdentityChannelProviderMismatch)
}

func (s *UserProfileIdentityRepoSuite) TestBindAuthIdentityToUser_RejectsChannelProviderMismatch() {
	user := s.mustCreateUser("provider-mismatch-bind")

	_, err := s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
		UserID: user.ID,
		Canonical: AuthIdentityKey{
			ProviderType:    "wechat",
			ProviderKey:     "wechat-main",
			ProviderSubject: "union-bind-mismatch",
		},
		Channel: &AuthIdentityChannelKey{
			ProviderType:   "wechat",
			ProviderKey:    "wechat-legacy",
			Channel:        "oa",
			ChannelAppID:   "wx-app-bind-mismatch",
			ChannelSubject: "openid-bind-mismatch",
		},
	})
	s.Require().ErrorIs(err, ErrAuthIdentityChannelProviderMismatch)
}

func (s *UserProfileIdentityRepoSuite) TestWithUserProfileIdentityTx_RollsBackIdentityAndGrantOnError() {
	user := s.mustCreateUser("tx-rollback")
	expectedErr := errors.New("rollback")

	err := s.repo.WithUserProfileIdentityTx(s.ctx, func(txCtx context.Context) error {
		_, err := s.repo.CreateAuthIdentity(txCtx, CreateAuthIdentityInput{
			UserID: user.ID,
			Canonical: AuthIdentityKey{
				ProviderType:    "oidc",
				ProviderKey:     "https://issuer.example",
				ProviderSubject: "subject-rollback",
			},
		})
		s.Require().NoError(err)

		inserted, err := s.repo.RecordProviderGrant(txCtx, ProviderGrantRecordInput{
			UserID:       user.ID,
			ProviderType: "oidc",
			GrantReason:  ProviderGrantReasonFirstBind,
		})
		s.Require().NoError(err)
		s.Require().True(inserted)
		return expectedErr
	})
	s.Require().ErrorIs(err, expectedErr)

	_, err = s.repo.GetUserByCanonicalIdentity(s.ctx, AuthIdentityKey{
		ProviderType:    "oidc",
		ProviderKey:     "https://issuer.example",
		ProviderSubject: "subject-rollback",
	})
	s.Require().True(dbent.IsNotFound(err))

	var count int
	s.Require().NoError(integrationDB.QueryRowContext(s.ctx, `
SELECT COUNT(*)
FROM user_provider_default_grants
WHERE user_id = $1 AND provider_type = $2 AND grant_reason = $3`,
		user.ID,
		"oidc",
		string(ProviderGrantReasonFirstBind),
	).Scan(&count))
	s.Require().Zero(count)
}

func (s *UserProfileIdentityRepoSuite) TestRecordProviderGrant_IsIdempotentPerReason() {
	user := s.mustCreateUser("grant")

	inserted, err := s.repo.RecordProviderGrant(s.ctx, ProviderGrantRecordInput{
		UserID:       user.ID,
		ProviderType: "wechat",
		GrantReason:  ProviderGrantReasonFirstBind,
	})
	s.Require().NoError(err)
	s.Require().True(inserted)

	inserted, err = s.repo.RecordProviderGrant(s.ctx, ProviderGrantRecordInput{
		UserID:       user.ID,
		ProviderType: "wechat",
		GrantReason:  ProviderGrantReasonFirstBind,
	})
	s.Require().NoError(err)
	s.Require().False(inserted)

	inserted, err = s.repo.RecordProviderGrant(s.ctx, ProviderGrantRecordInput{
		UserID:       user.ID,
		ProviderType: "wechat",
		GrantReason:  ProviderGrantReasonSignup,
	})
	s.Require().NoError(err)
	s.Require().True(inserted)

	var count int
	s.Require().NoError(integrationDB.QueryRowContext(s.ctx, `
SELECT COUNT(*)
FROM user_provider_default_grants
WHERE user_id = $1 AND provider_type = $2`,
		user.ID,
		"wechat",
	).Scan(&count))
	s.Require().Equal(2, count)
}

func (s *UserProfileIdentityRepoSuite) TestUpsertIdentityAdoptionDecision_PersistsAndLinksIdentity() {
	user := s.mustCreateUser("adoption")
	identity, err := s.repo.CreateAuthIdentity(s.ctx, CreateAuthIdentityInput{
		UserID: user.ID,
		Canonical: AuthIdentityKey{
			ProviderType:    "wechat",
			ProviderKey:     "wechat-open",
			ProviderSubject: "union-adoption",
		},
	})
	s.Require().NoError(err)

	session := s.mustCreatePendingAuthSession(identity.IdentityRef())

	first, err := s.repo.UpsertIdentityAdoptionDecision(s.ctx, IdentityAdoptionDecisionInput{
		PendingAuthSessionID: session.ID,
		AdoptDisplayName:     true,
		AdoptAvatar:          false,
	})
	s.Require().NoError(err)
	s.Require().True(first.AdoptDisplayName)
	s.Require().False(first.AdoptAvatar)
	s.Require().Nil(first.IdentityID)

	second, err := s.repo.UpsertIdentityAdoptionDecision(s.ctx, IdentityAdoptionDecisionInput{
		PendingAuthSessionID: session.ID,
		IdentityID:           &identity.Identity.ID,
		AdoptDisplayName:     true,
		AdoptAvatar:          true,
	})
	s.Require().NoError(err)
	s.Require().Equal(first.ID, second.ID)
	s.Require().NotNil(second.IdentityID)
	s.Require().Equal(identity.Identity.ID, *second.IdentityID)
	s.Require().True(second.AdoptAvatar)

	loaded, err := s.repo.GetIdentityAdoptionDecisionByPendingAuthSessionID(s.ctx, session.ID)
	s.Require().NoError(err)
	s.Require().Equal(second.ID, loaded.ID)
	s.Require().Equal(identity.Identity.ID, *loaded.IdentityID)
}

func (s *UserProfileIdentityRepoSuite) TestUserAvatarCRUDAndUserLookup() {
	user := s.mustCreateUser("avatar")

	inlineAvatar, err := s.repo.UpsertUserAvatar(s.ctx, user.ID, service.UpsertUserAvatarInput{
		StorageProvider: "inline",
		URL:             "data:image/png;base64,QUJD",
		ContentType:     "image/png",
		ByteSize:        3,
		SHA256:          "902fbdd2b1df0c4f70b4a5d23525e932",
	})
	s.Require().NoError(err)
	s.Require().Equal("inline", inlineAvatar.StorageProvider)
	s.Require().Equal("data:image/png;base64,QUJD", inlineAvatar.URL)

	loadedAvatar, err := s.repo.GetUserAvatar(s.ctx, user.ID)
	s.Require().NoError(err)
	s.Require().NotNil(loadedAvatar)
	s.Require().Equal("image/png", loadedAvatar.ContentType)
	s.Require().Equal(3, loadedAvatar.ByteSize)

	_, err = s.repo.UpsertUserAvatar(s.ctx, user.ID, service.UpsertUserAvatarInput{
		StorageProvider: "remote_url",
		URL:             "https://cdn.example.com/avatar.png",
	})
	s.Require().NoError(err)

	loadedAvatar, err = s.repo.GetUserAvatar(s.ctx, user.ID)
	s.Require().NoError(err)
	s.Require().NotNil(loadedAvatar)
	s.Require().Equal("remote_url", loadedAvatar.StorageProvider)
	s.Require().Equal("https://cdn.example.com/avatar.png", loadedAvatar.URL)
	s.Require().Zero(loadedAvatar.ByteSize)

	s.Require().NoError(s.repo.DeleteUserAvatar(s.ctx, user.ID))
	loadedAvatar, err = s.repo.GetUserAvatar(s.ctx, user.ID)
	s.Require().NoError(err)
	s.Require().Nil(loadedAvatar)
}

func (s *UserProfileIdentityRepoSuite) TestAuthIdentityMigrationReportHelpers_ListAndSummarize() {
	_, err := integrationDB.ExecContext(s.ctx, `
INSERT INTO auth_identity_migration_reports (report_type, report_key, details, created_at)
VALUES
	('wechat_openid_only_requires_remediation', 'u-1', '{"user_id":1}'::jsonb, '2026-04-20T10:00:00Z'),
	('wechat_openid_only_requires_remediation', 'u-2', '{"user_id":2}'::jsonb, '2026-04-20T11:00:00Z'),
	('oidc_synthetic_email_requires_manual_recovery', 'u-3', '{"user_id":3}'::jsonb, '2026-04-20T12:00:00Z')`)
	s.Require().NoError(err)

	summary, err := s.repo.SummarizeAuthIdentityMigrationReports(s.ctx)
	s.Require().NoError(err)
	s.Require().Equal(int64(3), summary.Total)
	s.Require().Equal(int64(2), summary.ByType["wechat_openid_only_requires_remediation"])
	s.Require().Equal(int64(1), summary.ByType["oidc_synthetic_email_requires_manual_recovery"])

	reports, err := s.repo.ListAuthIdentityMigrationReports(s.ctx, AuthIdentityMigrationReportQuery{
		ReportType: "wechat_openid_only_requires_remediation",
		Limit:      10,
	})
	s.Require().NoError(err)
	s.Require().Len(reports, 2)
	s.Require().Equal("u-2", reports[0].ReportKey)
	s.Require().Equal(float64(2), reports[0].Details["user_id"])

	report, err := s.repo.GetAuthIdentityMigrationReport(s.ctx, "oidc_synthetic_email_requires_manual_recovery", "u-3")
	s.Require().NoError(err)
	s.Require().Equal("u-3", report.ReportKey)
	s.Require().Equal(float64(3), report.Details["user_id"])
}

func (s *UserProfileIdentityRepoSuite) TestUpdateUserLastLoginAndActiveAt_UsesDedicatedColumns() {
	user := s.mustCreateUser("activity")
	loginAt := time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC)
	activeAt := loginAt.Add(5 * time.Minute)

	s.Require().NoError(s.repo.UpdateUserLastLoginAt(s.ctx, user.ID, loginAt))
	s.Require().NoError(s.repo.UpdateUserLastActiveAt(s.ctx, user.ID, activeAt))

	var storedLoginAt sqlNullTime
	var storedActiveAt sqlNullTime
	s.Require().NoError(integrationDB.QueryRowContext(s.ctx, `
SELECT last_login_at, last_active_at
FROM users
WHERE id = $1`,
		user.ID,
	).Scan(&storedLoginAt, &storedActiveAt))
	s.Require().True(storedLoginAt.Valid)
	s.Require().True(storedActiveAt.Valid)
	s.Require().True(storedLoginAt.Time.Equal(loginAt))
	s.Require().True(storedActiveAt.Time.Equal(activeAt))
}

type sqlNullTime struct {
	Time  time.Time
	Valid bool
}

func (t *sqlNullTime) Scan(value any) error {
	switch v := value.(type) {
	case time.Time:
		t.Time = v
		t.Valid = true
		return nil
	case nil:
		t.Time = time.Time{}
		t.Valid = false
		return nil
	default:
		return fmt.Errorf("unsupported scan type %T", value)
	}
}

func stringPtr(v string) *string {
	return &v
}
