using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Tiku.Domain.Catalog;
using Tiku.Domain.Commerce;
using Tiku.Domain.Content;
using Tiku.Domain.Growth;
using Tiku.Domain.Import;
using Tiku.Domain.Learning;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;

namespace Tiku.IntegrationTests;

public sealed class PersistenceModelTests
{
    private static readonly DbContextOptions<TikuDbContext> Options =
        new DbContextOptionsBuilder<TikuDbContext>()
            .UseNpgsql("Host=localhost;Database=tiku_model_tests;Username=postgres")
            .Options;

    [Fact]
    public void Core_model_contains_expected_tables()
    {
        using var context = new TikuDbContext(Options);

        var tableNames = context.Model.GetEntityTypes()
            .Select(entity => entity.GetTableName())
            .ToHashSet(StringComparer.Ordinal);

        Assert.Contains("tenants", tableNames);
        Assert.Contains("tenant_frontend_configs", tableNames);
        Assert.Contains("tenant_question_references", tableNames);
        Assert.Contains("taxonomy_nodes", tableNames);
        Assert.Contains("practice_session_questions", tableNames);
        Assert.Contains("tenant_settings", tableNames);
        Assert.Contains("content_entries", tableNames);
        Assert.Contains("content_nodes", tableNames);
        Assert.Contains("question_collections", tableNames);
        Assert.Contains("practice_blueprints", tableNames);
        Assert.Contains("vocabulary_units", tableNames);
        Assert.Contains("vocabulary_words", tableNames);
        Assert.Contains("user_word_progress", tableNames);
        Assert.Contains("user_word_favorites", tableNames);
        Assert.Contains("handbook_subjects", tableNames);
        Assert.Contains("handbook_chapters", tableNames);
        Assert.Contains("handbook_entries", tableNames);
        Assert.Contains("question_type_groups", tableNames);
        Assert.Contains("subject_shares", tableNames);
        Assert.Contains("content_assets", tableNames);
        Assert.Contains("content_import_jobs", tableNames);
        Assert.Contains("content_import_items", tableNames);
        Assert.Contains("content_import_issues", tableNames);
        Assert.Contains("images", tableNames);
        Assert.Contains("app_assets", tableNames);
        Assert.Contains("video_explanations", tableNames);
        Assert.Contains("question_videos", tableNames);
        Assert.Contains("exam_dates", tableNames);
        Assert.Contains("reports", tableNames);
        Assert.Contains("report_status_events", tableNames);
        Assert.Contains("user_score_events", tableNames);
        Assert.Contains("practice_daily_usage", tableNames);
        Assert.Contains("practice_access_events", tableNames);
        Assert.Contains("practice_session_reports", tableNames);
        Assert.Contains("practice_session_report_sections", tableNames);
        Assert.Contains("dashboard_daily_stats", tableNames);
        Assert.Contains("revenue_daily_stats", tableNames);
        Assert.Contains("tenant_external_providers", tableNames);
        Assert.Contains("tenant_secrets", tableNames);
        Assert.Contains("sms_verification_codes", tableNames);
        Assert.Contains("auth_login_events", tableNames);
        Assert.Contains("auth_sessions", tableNames);
        Assert.Contains("sms_send_rate_limits", tableNames);
        Assert.Contains("tenant_classes", tableNames);
        Assert.Contains("tenant_class_members", tableNames);
        Assert.Contains("tenant_student_notes", tableNames);
        Assert.Contains("tenant_student_followups", tableNames);
        Assert.Contains("products", tableNames);
        Assert.Contains("svip_plans", tableNames);
        Assert.Contains("orders", tableNames);
        Assert.Contains("order_items", tableNames);
        Assert.Contains("payments", tableNames);
        Assert.Contains("payment_events", tableNames);
        Assert.Contains("entitlements", tableNames);
        Assert.Contains("code_batches", tableNames);
        Assert.Contains("activation_codes", tableNames);
        Assert.Contains("coupons", tableNames);
        Assert.Contains("coupon_redemptions", tableNames);
        Assert.Contains("saas_features", tableNames);
        Assert.Contains("permission_modules", tableNames);
        Assert.Contains("saas_offerings", tableNames);
        Assert.Contains("saas_offering_versions", tableNames);
        Assert.Contains("tenant_saas_subscriptions", tableNames);
        Assert.Contains("tenant_saas_subscription_items", tableNames);
        Assert.Contains("tenant_feature_usage", tableNames);
        Assert.Contains("platform_billing_orders", tableNames);
        Assert.Contains("platform_billing_payments", tableNames);
        Assert.Contains("platform_billing_invoices", tableNames);
        Assert.Contains("commerce_refund_requests", tableNames);
        Assert.Contains("commerce_refund_events", tableNames);
        Assert.Contains("commerce_reconciliation_batches", tableNames);
        Assert.Contains("commerce_reconciliation_items", tableNames);
        Assert.Contains("commerce_reconciliation_issues", tableNames);
        Assert.Contains("commerce_reconciliation_issue_events", tableNames);
        Assert.Contains("point_activity_tasks", tableNames);
        Assert.Contains("point_activity_claims", tableNames);
        Assert.Contains("point_exchange_items", tableNames);
        Assert.Contains("point_exchange_orders", tableNames);
        Assert.Contains("content_asset_access_events", tableNames);
        Assert.Contains("content_asset_security_scan_events", tableNames);
        Assert.Contains("tenant_question_bank_preferences", tableNames);
        Assert.Contains("ai_recommendation_reports", tableNames);
        Assert.Contains("referral_tracks", tableNames);
        Assert.Contains("referral_codes", tableNames);
        Assert.Contains("referral_leads", tableNames);
        Assert.Contains("referral_team_edges", tableNames);
        Assert.Contains("referral_qrcodes", tableNames);
        Assert.Contains("crm_config", tableNames);
        Assert.Contains("crm_webhook_queue", tableNames);
        Assert.Contains("crm_webhook_log", tableNames);
        Assert.Contains("tenant_commission_settings", tableNames);
        Assert.Contains("commission_settlements", tableNames);
        Assert.Contains("commission_settlement_items", tableNames);
        Assert.Contains("commission_settlement_proofs", tableNames);
        Assert.Contains("commission_settlement_export_events", tableNames);
        Assert.Contains("banners", tableNames);
        Assert.Contains("faqs", tableNames);
        Assert.Contains("announcements", tableNames);
        Assert.Contains("audit_logs", tableNames);
        Assert.Contains("user_notifications", tableNames);
        Assert.Contains("badges", tableNames);
        Assert.Contains("user_badges", tableNames);
        Assert.Contains("tenant_content_notifications", tableNames);
        Assert.Contains("tenant_theme_templates", tableNames);
        Assert.Contains("tenant_theme_configs", tableNames);
        Assert.Contains("tenant_billing_profiles", tableNames);
        Assert.Contains("platform_billing_invoices", tableNames);
        Assert.Contains("platform_billing_invoice_reminders", tableNames);
        Assert.Contains("platform_audit_alert_rules", tableNames);
        Assert.Contains("platform_audit_alerts", tableNames);
        Assert.Contains("platform_billing_dunning_notification_channels", tableNames);
        Assert.Contains("platform_billing_dunning_notification_events", tableNames);
        Assert.Contains("pb_import_runs", tableNames);
        Assert.Contains("pb_raw_records", tableNames);
        Assert.Contains("pb_import_issues", tableNames);
        Assert.Contains("questions", tableNames);
        Assert.Contains("question_versions", tableNames);
        Assert.Contains("scoreline_fields", tableNames);
        Assert.Contains("scoreline_records", tableNames);
        Assert.Contains("answer_records", tableNames);
    }

    [Fact]
    public void Content_node_path_is_mapped_to_ltree()
    {
        using var context = new TikuDbContext(Options);

        var path = context.Model.FindEntityType(typeof(ContentNode))!
            .FindProperty(nameof(ContentNode.Path))!;

        Assert.Equal("ltree", path.GetColumnType());
    }

    [Fact]
    public void Collection_relations_include_tenant_in_foreign_keys()
    {
        using var context = new TikuDbContext(Options);

        var foreignKeys = context.Model.FindEntityType(typeof(QuestionCollection))!
            .GetForeignKeys()
            .Where(foreignKey => foreignKey.Properties.Count > 1)
            .Select(foreignKey => foreignKey.Properties
                .Select(property => property.Name)
                .ToArray());

        Assert.All(
            foreignKeys,
            properties => Assert.Contains("TenantId", properties));
    }

    [Theory]
    [InlineData(nameof(Question.Tags), "'[]'::jsonb")]
    public void Question_json_properties_are_mapped_to_jsonb(
        string propertyName,
        string expectedDefaultValueSql)
    {
        using var context = new TikuDbContext(Options);

        var property = context.Model.FindEntityType(typeof(Question))!
            .FindProperty(propertyName)!;

        Assert.Equal("jsonb", property.GetColumnType());
        Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
        Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
    }

    [Fact]
    public void Question_relations_include_tenant_in_foreign_keys()
    {
        using var context = new TikuDbContext(Options);

        var foreignKeys = context.Model.FindEntityType(typeof(Question))!
            .GetForeignKeys()
            .Select(foreignKey => foreignKey.Properties
                .Select(property => property.Name)
                .ToArray())
            .ToArray();

        Assert.All(
            foreignKeys.Where(properties => properties.Length > 1),
            properties => Assert.Contains("TenantId", properties));

        Assert.Contains(
            foreignKeys,
            properties => properties.SequenceEqual(
                ["TenantId", "Id", "CurrentVersionId"]));
    }

    [Theory]
    [InlineData(typeof(VocabularyWord), nameof(VocabularyWord.Tags), "'[]'::jsonb")]
    [InlineData(typeof(VocabularyWord), nameof(VocabularyWord.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(HandbookSubject), nameof(HandbookSubject.MajorLegacyIds), "'[]'::jsonb")]
    [InlineData(typeof(HandbookEntry), nameof(HandbookEntry.Tags), "'[]'::jsonb")]
    [InlineData(typeof(QuestionTypeGroup), nameof(QuestionTypeGroup.Types), "'[]'::jsonb")]
    [InlineData(typeof(UserWordProgress), nameof(UserWordProgress.Metadata), "'{}'::jsonb")]
    public void Study_content_json_properties_are_mapped_to_jsonb(
        Type entityType,
        string propertyName,
        string expectedDefaultValueSql)
    {
        using var context = new TikuDbContext(Options);

        var property = context.Model.FindEntityType(entityType)!
            .FindProperty(propertyName)!;

        Assert.Equal("jsonb", property.GetColumnType());
        Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
        Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
    }

    [Fact]
    public void User_word_progress_has_review_constraints_and_precision()
    {
        using var context = new TikuDbContext(Options);

        var entityType = context.GetService<IDesignTimeModel>()
            .Model
            .FindEntityType(typeof(UserWordProgress))!;
        var checkConstraints = entityType.GetCheckConstraints()
            .Select(constraint => constraint.Name)
            .ToHashSet(StringComparer.Ordinal);

        var easeFactor = entityType.FindProperty(nameof(UserWordProgress.EaseFactor))!;
        Assert.Equal(4, easeFactor.GetPrecision());
        Assert.Equal(2, easeFactor.GetScale());
        Assert.Contains("ck_user_word_progress_review_count", checkConstraints);
        Assert.Contains("ck_user_word_progress_correct_streak", checkConstraints);
        Assert.Contains("ck_user_word_progress_ease_factor", checkConstraints);
    }

    [Fact]
    public void Study_content_relations_include_tenant_in_foreign_keys()
    {
        using var context = new TikuDbContext(Options);

        var entityTypes = new[]
        {
            typeof(VocabularyUnit),
            typeof(VocabularyWord),
            typeof(UserWordProgress),
            typeof(UserWordFavorite),
            typeof(HandbookSubject),
            typeof(HandbookChapter),
            typeof(HandbookEntry),
            typeof(QuestionTypeGroup),
            typeof(SubjectShare)
        };

        var compositeForeignKeys = entityTypes
            .SelectMany(entityType => context.Model.FindEntityType(entityType)!
                .GetForeignKeys())
            .Where(foreignKey => foreignKey.Properties.Count > 1)
            .Select(foreignKey => foreignKey.Properties
                .Select(property => property.Name)
                .ToArray());

        Assert.All(
            compositeForeignKeys,
            properties => Assert.Contains("TenantId", properties));
    }

    [Theory]
    [InlineData(typeof(ScorelineField), nameof(ScorelineField.Options), "'[]'::jsonb")]
    [InlineData(typeof(ScorelineRecord), nameof(ScorelineRecord.FieldValues), "'{}'::jsonb")]
    public void Scoreline_json_properties_are_mapped_to_jsonb(
        Type entityType,
        string propertyName,
        string expectedDefaultValueSql)
    {
        using var context = new TikuDbContext(Options);

        var property = context.Model.FindEntityType(entityType)!
            .FindProperty(propertyName)!;

        Assert.Equal("jsonb", property.GetColumnType());
        Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
        Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
    }

    [Fact]
    public void Scoreline_relations_include_tenant_in_foreign_keys()
    {
        using var context = new TikuDbContext(Options);

        var foreignKeys = context.Model.FindEntityType(typeof(ScorelineRecord))!
            .GetForeignKeys()
            .Where(foreignKey => foreignKey.Properties.Count > 1)
            .Select(foreignKey => foreignKey.Properties
                .Select(property => property.Name)
                .ToArray());

        Assert.All(
            foreignKeys,
            properties => Assert.Contains("TenantId", properties));
    }

    [Fact]
    public void Word_user_join_tables_have_tenant_scoped_unique_indexes()
    {
        using var context = new TikuDbContext(Options);

        AssertHasUniqueIndex<UserWordProgress>(
            nameof(UserWordProgress.TenantId),
            nameof(UserWordProgress.UserId),
            nameof(UserWordProgress.WordId));
        AssertHasUniqueIndex<UserWordFavorite>(
            nameof(UserWordFavorite.TenantId),
            nameof(UserWordFavorite.UserId),
            nameof(UserWordFavorite.WordId));

        void AssertHasUniqueIndex<TEntity>(params string[] propertyNames)
        {
            var indexes = context.Model.FindEntityType(typeof(TEntity))!.GetIndexes();

            Assert.Contains(
                indexes,
                index => index.IsUnique
                    && index.Properties
                        .Select(property => property.Name)
                        .SequenceEqual(propertyNames));
        }
    }

    [Theory]
    [InlineData(typeof(ContentAsset), nameof(ContentAsset.AccessRules), "'{}'::jsonb")]
    [InlineData(typeof(ContentAsset), nameof(ContentAsset.VerificationDetails), "'{}'::jsonb")]
    [InlineData(typeof(ContentAsset), nameof(ContentAsset.SecurityScanSummary), "'{}'::jsonb")]
    [InlineData(typeof(ContentAsset), nameof(ContentAsset.SecurityFlags), "'{}'::jsonb")]
    [InlineData(typeof(ContentAssetAccessEvent), nameof(ContentAssetAccessEvent.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(ContentAssetSecurityScanEvent), nameof(ContentAssetSecurityScanEvent.Details), "'{}'::jsonb")]
    [InlineData(typeof(ContentImportJob), nameof(ContentImportJob.RawPayload), "'[]'::jsonb")]
    [InlineData(typeof(ContentImportItem), nameof(ContentImportItem.SourcePayload), "'{}'::jsonb")]
    [InlineData(typeof(ContentImportIssue), nameof(ContentImportIssue.Details), "'{}'::jsonb")]
    [InlineData(typeof(VideoExplanation), nameof(VideoExplanation.KnowledgeTags), "'[]'::jsonb")]
    [InlineData(typeof(QuestionVideo), nameof(QuestionVideo.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(TenantQuestionBankPreference), nameof(TenantQuestionBankPreference.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(AiRecommendationReport), nameof(AiRecommendationReport.InputPayload), "'{}'::jsonb")]
    [InlineData(typeof(AiRecommendationReport), nameof(AiRecommendationReport.ContextPayload), "'{}'::jsonb")]
    [InlineData(typeof(AiRecommendationReport), nameof(AiRecommendationReport.ResultPayload), "'{}'::jsonb")]
    public void Asset_import_and_video_json_properties_are_mapped_to_jsonb(
        Type entityType,
        string propertyName,
        string expectedDefaultValueSql)
    {
        using var context = new TikuDbContext(Options);

        var property = context.Model.FindEntityType(entityType)!
            .FindProperty(propertyName)!;

        Assert.Equal("jsonb", property.GetColumnType());
        Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
        Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
    }

    [Fact]
    public void Import_items_and_question_videos_have_expected_unique_indexes()
    {
        using var context = new TikuDbContext(Options);

        AssertHasUniqueIndex<ContentImportItem>(
            nameof(ContentImportItem.TenantId),
            nameof(ContentImportItem.JobId),
            nameof(ContentImportItem.RowNo));
        AssertHasUniqueIndex<QuestionVideo>(
            nameof(QuestionVideo.TenantId),
            nameof(QuestionVideo.QuestionId),
            nameof(QuestionVideo.VideoId),
            nameof(QuestionVideo.VideoType));
        AssertHasUniqueIndex<TenantQuestionBankPreference>(
            nameof(TenantQuestionBankPreference.TenantId),
            nameof(TenantQuestionBankPreference.QuestionBankOwnerTenantId),
            nameof(TenantQuestionBankPreference.QuestionBankId));

        void AssertHasUniqueIndex<TEntity>(params string[] propertyNames)
        {
            var indexes = context.Model.FindEntityType(typeof(TEntity))!.GetIndexes();

            Assert.Contains(
                indexes,
                index => index.IsUnique
                    && index.Properties
                        .Select(property => property.Name)
                        .SequenceEqual(propertyNames));
        }
    }

    [Fact]
    public void Asset_import_and_video_relations_include_tenant_in_foreign_keys()
    {
        using var context = new TikuDbContext(Options);

        var entityTypes = new[]
        {
            typeof(ContentAsset),
            typeof(ContentImportJob),
            typeof(ContentImportItem),
            typeof(ContentImportIssue),
            typeof(VideoExplanation),
            typeof(QuestionVideo)
        };

        var compositeForeignKeys = entityTypes
            .SelectMany(entityType => context.Model.FindEntityType(entityType)!
                .GetForeignKeys())
            .Where(foreignKey => foreignKey.Properties.Count > 1)
            .Select(foreignKey => foreignKey.Properties
                .Select(property => property.Name)
                .ToArray());

        Assert.All(
            compositeForeignKeys,
            properties => Assert.Contains("TenantId", properties));
    }

    [Theory]
    [InlineData(typeof(ContentNode), nameof(ContentNode.AccessRules), "'{}'::jsonb")]
    [InlineData(typeof(QuestionCollection), nameof(QuestionCollection.AccessRules), "'{}'::jsonb")]
    [InlineData(typeof(PracticeBlueprint), nameof(PracticeBlueprint.AccessRules), "'{}'::jsonb")]
    [InlineData(typeof(PracticeSession), nameof(PracticeSession.AccessSnapshot), "'{}'::jsonb")]
    [InlineData(typeof(Report), nameof(Report.Attachments), "'[]'::jsonb")]
    [InlineData(typeof(Report), nameof(Report.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(PracticeSessionReport), nameof(PracticeSessionReport.QuestionResults), "'[]'::jsonb")]
    [InlineData(typeof(PracticeSessionReport), nameof(PracticeSessionReport.WrongQuestionIds), "'[]'::jsonb")]
    [InlineData(typeof(PracticeSessionReportSection), nameof(PracticeSessionReportSection.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(PracticeDailyUsage), nameof(PracticeDailyUsage.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(PracticeAccessEvent), nameof(PracticeAccessEvent.Metadata), "'{}'::jsonb")]
    public void Learning_report_json_properties_are_mapped_to_jsonb(
        Type entityType,
        string propertyName,
        string expectedDefaultValueSql)
    {
        using var context = new TikuDbContext(Options);

        var property = context.Model.FindEntityType(entityType)!
            .FindProperty(propertyName)!;

        Assert.Equal("jsonb", property.GetColumnType());
        Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
        Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
    }

    [Fact]
    public void Practice_usage_unique_scope_treats_nulls_as_not_distinct()
    {
        using var context = new TikuDbContext(Options);

        var index = context.GetService<IDesignTimeModel>()
            .Model
            .FindEntityType(typeof(PracticeDailyUsage))!
            .GetIndexes()
            .Single(index => index.IsUnique
                && index.Properties.Select(property => property.Name).SequenceEqual([
                    nameof(PracticeDailyUsage.TenantId),
                    nameof(PracticeDailyUsage.UserId),
                    nameof(PracticeDailyUsage.UsageDate),
                    nameof(PracticeDailyUsage.ScopeType),
                    nameof(PracticeDailyUsage.ScopeId)
                ]));

        Assert.False(index.GetAreNullsDistinct());
    }

    [Fact]
    public void Practice_report_scores_have_expected_precision()
    {
        using var context = new TikuDbContext(Options);

        var reportType = context.Model.FindEntityType(typeof(PracticeSessionReport))!;
        var score = reportType.FindProperty(nameof(PracticeSessionReport.Score))!;
        var accuracy = reportType.FindProperty(nameof(PracticeSessionReport.Accuracy))!;

        Assert.Equal(10, score.GetPrecision());
        Assert.Equal(2, score.GetScale());
        Assert.Equal(6, accuracy.GetPrecision());
        Assert.Equal(4, accuracy.GetScale());
    }

    [Theory]
    [InlineData(typeof(TenantExternalProvider), nameof(TenantExternalProvider.ConfigPublic), "'{}'::jsonb")]
    [InlineData(typeof(TenantExternalProvider), nameof(TenantExternalProvider.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(SmsVerificationCode), nameof(SmsVerificationCode.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(AuthLoginEvent), nameof(AuthLoginEvent.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(AuthSession), nameof(AuthSession.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(TenantClass), nameof(TenantClass.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(TenantClassMember), nameof(TenantClassMember.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(TenantStudentNote), nameof(TenantStudentNote.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(TenantStudentFollowup), nameof(TenantStudentFollowup.Metadata), "'{}'::jsonb")]
    public void Auth_and_tenant_operations_json_properties_are_mapped_to_jsonb(
        Type entityType,
        string propertyName,
        string expectedDefaultValueSql)
    {
        using var context = new TikuDbContext(Options);

        var property = context.Model.FindEntityType(entityType)!
            .FindProperty(propertyName)!;

        Assert.Equal("jsonb", property.GetColumnType());
        Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
        Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
    }

    [Fact]
    public void Auth_and_class_tables_have_expected_unique_indexes()
    {
        using var context = new TikuDbContext(Options);

        AssertHasUniqueIndex<TenantExternalProvider>(
            nameof(TenantExternalProvider.TenantId),
            nameof(TenantExternalProvider.Capability),
            nameof(TenantExternalProvider.Provider));
        AssertHasUniqueIndex<TenantSecret>(
            nameof(TenantSecret.TenantId),
            nameof(TenantSecret.SecretRef));
        AssertHasUniqueIndex<TenantSecret>(
            nameof(TenantSecret.TenantId),
            nameof(TenantSecret.Purpose),
            nameof(TenantSecret.Provider),
            nameof(TenantSecret.SecretKey));
        AssertHasUniqueIndex<TenantClassMember>(
            nameof(TenantClassMember.TenantId),
            nameof(TenantClassMember.ClassId),
            nameof(TenantClassMember.UserId),
            nameof(TenantClassMember.MemberType));

        void AssertHasUniqueIndex<TEntity>(params string[] propertyNames)
        {
            var indexes = context.Model.FindEntityType(typeof(TEntity))!.GetIndexes();

            Assert.Contains(
                indexes,
                index => index.IsUnique
                    && index.Properties
                        .Select(property => property.Name)
                        .SequenceEqual(propertyNames));
        }
    }

    [Theory]
    [InlineData(typeof(Product), nameof(Product.Tags), "'[]'::jsonb")]
    [InlineData(typeof(Product), nameof(Product.DetailImages), "'[]'::jsonb")]
    [InlineData(typeof(Order), nameof(Order.RawPayload), "'{}'::jsonb")]
    [InlineData(typeof(OrderItem), nameof(OrderItem.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(Payment), nameof(Payment.RawPayload), "'{}'::jsonb")]
    [InlineData(typeof(PaymentEvent), nameof(PaymentEvent.Payload), "'{}'::jsonb")]
    [InlineData(typeof(Entitlement), nameof(Entitlement.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(TenantSaasSubscription), nameof(TenantSaasSubscription.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(CommerceRefundRequest), nameof(CommerceRefundRequest.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(CommerceRefundEvent), nameof(CommerceRefundEvent.Details), "'{}'::jsonb")]
    [InlineData(typeof(CommerceReconciliationBatch), nameof(CommerceReconciliationBatch.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(CommerceReconciliationItem), nameof(CommerceReconciliationItem.Details), "'{}'::jsonb")]
    [InlineData(typeof(CommerceReconciliationIssue), nameof(CommerceReconciliationIssue.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(CommerceReconciliationIssueEvent), nameof(CommerceReconciliationIssueEvent.Details), "'{}'::jsonb")]
    public void Commerce_json_properties_are_mapped_to_jsonb(
        Type entityType,
        string propertyName,
        string expectedDefaultValueSql)
    {
        using var context = new TikuDbContext(Options);

        var property = context.Model.FindEntityType(entityType)!
            .FindProperty(propertyName)!;

        Assert.Equal("jsonb", property.GetColumnType());
        Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
        Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
    }

    [Fact]
    public void Commerce_tables_have_expected_unique_indexes_and_money_types()
    {
        using var context = new TikuDbContext(Options);

        var productType = context.Model.FindEntityType(typeof(Product))!;
        var isActive = productType.FindProperty(nameof(Product.IsActive))!;
        var productsTable = StoreObjectIdentifier.Table("products", null);
        Assert.Equal("is_active", isActive.GetColumnName(productsTable));
        Assert.Contains(
            productType.GetIndexes(),
            index => !index.IsUnique
                && index.Properties
                    .Select(property => property.Name)
                    .SequenceEqual([
                        nameof(Product.TenantId),
                        nameof(Product.RegionId),
                        nameof(Product.Type),
                        nameof(Product.IsActive),
                        nameof(Product.SortOrder)
                    ]));

        AssertHasUniqueIndex<Order>(nameof(Order.TenantId), nameof(Order.OrderNo));
        AssertHasUniqueIndex<ActivationCode>(nameof(ActivationCode.TenantId), nameof(ActivationCode.Code));
        AssertHasUniqueIndex<CommerceRefundRequest>(
            nameof(CommerceRefundRequest.TenantId),
            nameof(CommerceRefundRequest.RefundNo));
        AssertHasUniqueIndex<CommerceReconciliationItem>(
            nameof(CommerceReconciliationItem.TenantId),
            nameof(CommerceReconciliationItem.BatchId),
            nameof(CommerceReconciliationItem.RowNo));
        AssertHasUniqueIndex<CommerceReconciliationIssue>(
            nameof(CommerceReconciliationIssue.TenantId),
            nameof(CommerceReconciliationIssue.IssueNo));
        AssertHasUniqueIndex<CommerceReconciliationIssue>(
            nameof(CommerceReconciliationIssue.TenantId),
            nameof(CommerceReconciliationIssue.ItemId));

        var amount = context.Model.FindEntityType(typeof(Order))!
            .FindProperty(nameof(Order.AmountCents))!;
        Assert.Equal(typeof(int), amount.ClrType);

        var refundedAmount = context.Model.FindEntityType(typeof(Order))!
            .FindProperty(nameof(Order.RefundedAmountCents))!;
        Assert.Equal(typeof(int), refundedAmount.ClrType);

        void AssertHasUniqueIndex<TEntity>(params string[] propertyNames)
        {
            var indexes = context.Model.FindEntityType(typeof(TEntity))!.GetIndexes();

            Assert.Contains(
                indexes,
                index => index.IsUnique
                    && index.Properties
                        .Select(property => property.Name)
                        .SequenceEqual(propertyNames));
        }
    }

    [Theory]
    [InlineData(typeof(PointActivityTask), nameof(PointActivityTask.Rules), "'{}'::jsonb")]
    [InlineData(typeof(PointActivityTask), nameof(PointActivityTask.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(PointActivityClaim), nameof(PointActivityClaim.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(PointExchangeItem), nameof(PointExchangeItem.FulfillmentPayload), "'{}'::jsonb")]
    [InlineData(typeof(PointExchangeItem), nameof(PointExchangeItem.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(PointExchangeOrder), nameof(PointExchangeOrder.FulfillmentSnapshot), "'{}'::jsonb")]
    [InlineData(typeof(PointExchangeOrder), nameof(PointExchangeOrder.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(ReferralTrack), nameof(ReferralTrack.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(ReferralCode), nameof(ReferralCode.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(ReferralLead), nameof(ReferralLead.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(ReferralTeamEdge), nameof(ReferralTeamEdge.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(ReferralQrcode), nameof(ReferralQrcode.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(CrmConfig), nameof(CrmConfig.AssignmentPool), "'[]'::jsonb")]
    [InlineData(typeof(CrmConfig), nameof(CrmConfig.AssignmentConfig), "'{}'::jsonb")]
    [InlineData(typeof(CrmWebhookQueueItem), nameof(CrmWebhookQueueItem.Payload), "'{}'::jsonb")]
    [InlineData(typeof(CrmWebhookLog), nameof(CrmWebhookLog.RequestPayload), "'{}'::jsonb")]
    [InlineData(typeof(TenantCommissionSetting), nameof(TenantCommissionSetting.Config), "'{}'::jsonb")]
    [InlineData(typeof(CommissionSettlement), nameof(CommissionSettlement.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(CommissionSettlementItem), nameof(CommissionSettlementItem.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(CommissionSettlementProof), nameof(CommissionSettlementProof.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(CommissionSettlementExportEvent), nameof(CommissionSettlementExportEvent.Metadata), "'{}'::jsonb")]
    public void Growth_json_properties_are_mapped_to_jsonb(
        Type entityType,
        string propertyName,
        string expectedDefaultValueSql)
    {
        using var context = new TikuDbContext(Options);

        var property = context.Model.FindEntityType(entityType)!
            .FindProperty(propertyName)!;

        Assert.Equal("jsonb", property.GetColumnType());
        Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
        Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
    }

    [Fact]
    public void Growth_tables_have_expected_unique_indexes_and_decimal_precision()
    {
        using var context = new TikuDbContext(Options);

        AssertHasUniqueIndex<PointActivityTask>(nameof(PointActivityTask.TenantId), nameof(PointActivityTask.TaskKey));
        AssertHasUniqueIndex<PointExchangeItem>(nameof(PointExchangeItem.TenantId), nameof(PointExchangeItem.ItemKey));
        AssertHasUniqueIndex<PointExchangeOrder>(nameof(PointExchangeOrder.TenantId), nameof(PointExchangeOrder.OrderNo));
        AssertHasUniqueIndex<ReferralCode>(nameof(ReferralCode.TenantId), nameof(ReferralCode.Code));
        AssertHasUniqueIndex<ReferralLead>(nameof(ReferralLead.TenantId), nameof(ReferralLead.StudentUserId));
        AssertHasUniqueIndex<CrmConfig>(nameof(CrmConfig.TenantId));
        AssertHasUniqueIndex<CrmWebhookQueueItem>(nameof(CrmWebhookQueueItem.TenantId), nameof(CrmWebhookQueueItem.IdempotencyKey));
        AssertHasUniqueIndex<CommissionSettlement>(
            nameof(CommissionSettlement.TenantId),
            nameof(CommissionSettlement.SettlementNo));
        AssertHasUniqueIndex<CommissionSettlementItem>(
            nameof(CommissionSettlementItem.TenantId),
            nameof(CommissionSettlementItem.SourceType),
            nameof(CommissionSettlementItem.SourceId));

        AssertPrecision<TenantCommissionSetting>(nameof(TenantCommissionSetting.DefaultRate), 6, 4);
        AssertPrecision<CommissionSettlement>(nameof(CommissionSettlement.DefaultRate), 6, 4);
        AssertPrecision<CommissionSettlementItem>(nameof(CommissionSettlementItem.CommissionRate), 6, 4);

        void AssertHasUniqueIndex<TEntity>(params string[] propertyNames)
        {
            var indexes = context.Model.FindEntityType(typeof(TEntity))!.GetIndexes();

            Assert.Contains(
                indexes,
                index => index.IsUnique
                    && index.Properties
                        .Select(property => property.Name)
                        .SequenceEqual(propertyNames));
        }

        void AssertPrecision<TEntity>(string propertyName, int precision, int scale)
        {
            var property = context.Model.FindEntityType(typeof(TEntity))!
                .FindProperty(propertyName)!;

            Assert.Equal(precision, property.GetPrecision());
            Assert.Equal(scale, property.GetScale());
        }
    }

    [Theory]
    [InlineData(typeof(AuditLog), nameof(AuditLog.Details), "'{}'::jsonb")]
    [InlineData(typeof(UserNotification), nameof(UserNotification.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(Badge), nameof(Badge.ConditionExtra), "'{}'::jsonb")]
    [InlineData(typeof(TenantContentNotification), nameof(TenantContentNotification.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(TenantThemeTemplate), nameof(TenantThemeTemplate.Theme), "'{}'::jsonb")]
    [InlineData(typeof(TenantThemeTemplate), nameof(TenantThemeTemplate.PublicAssets), "'{}'::jsonb")]
    [InlineData(typeof(TenantThemeConfig), nameof(TenantThemeConfig.ActiveTheme), "'{}'::jsonb")]
    [InlineData(typeof(TenantThemeConfig), nameof(TenantThemeConfig.ActivePublicAssets), "'{}'::jsonb")]
    [InlineData(typeof(TenantThemeConfig), nameof(TenantThemeConfig.DraftTheme), "'{}'::jsonb")]
    [InlineData(typeof(TenantThemeConfig), nameof(TenantThemeConfig.DraftPublicAssets), "'{}'::jsonb")]
    public void Operations_json_properties_are_mapped_to_jsonb(
        Type entityType,
        string propertyName,
        string expectedDefaultValueSql)
    {
        using var context = new TikuDbContext(Options);

        var property = context.Model.FindEntityType(entityType)!
            .FindProperty(propertyName)!;

        Assert.Equal("jsonb", property.GetColumnType());
        Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
        Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
    }

    [Fact]
    public void Operations_tables_have_expected_unique_indexes_and_condition_precision()
    {
        using var context = new TikuDbContext(Options);

        AssertHasUniqueIndex<Banner>(nameof(Banner.TenantId), nameof(Banner.LegacyId));
        AssertHasUniqueIndex<UserNotification>(
            nameof(UserNotification.TenantId),
            nameof(UserNotification.UserId),
            nameof(UserNotification.NotificationType),
            nameof(UserNotification.DedupeKey));
        AssertHasUniqueIndex<Badge>(nameof(Badge.TenantId), nameof(Badge.LegacyId));
        AssertHasUniqueIndex<UserBadge>(
            nameof(UserBadge.TenantId),
            nameof(UserBadge.UserId),
            nameof(UserBadge.BadgeId));
        AssertHasUniqueIndex<TenantThemeTemplate>(nameof(TenantThemeTemplate.Code));
        AssertHasUniqueIndex<TenantThemeConfig>(nameof(TenantThemeConfig.TenantId));

        var conditionValue = context.Model.FindEntityType(typeof(Badge))!
            .FindProperty(nameof(Badge.ConditionValue))!;

        Assert.Equal(18, conditionValue.GetPrecision());
        Assert.Equal(4, conditionValue.GetScale());

        void AssertHasUniqueIndex<TEntity>(params string[] propertyNames)
        {
            var indexes = context.Model.FindEntityType(typeof(TEntity))!.GetIndexes();

            Assert.Contains(
                indexes,
                index => index.IsUnique
                    && index.Properties
                        .Select(property => property.Name)
                        .SequenceEqual(propertyNames));
        }
    }

    [Theory]
    [InlineData(typeof(SaasOfferingVersion), nameof(SaasOfferingVersion.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(PlatformBillingQuote), nameof(PlatformBillingQuote.FeatureSnapshot), "'[]'::jsonb")]
    [InlineData(typeof(PlatformBillingQuote), nameof(PlatformBillingQuote.LimitSnapshot), "'{}'::jsonb")]
    [InlineData(typeof(TenantBillingProfile), nameof(TenantBillingProfile.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(PlatformBillingInvoice), nameof(PlatformBillingInvoice.BillingProfileSnapshot), "'{}'::jsonb")]
    [InlineData(typeof(PlatformBillingInvoiceReminder), nameof(PlatformBillingInvoiceReminder.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(PlatformAuditAlertRule), nameof(PlatformAuditAlertRule.Conditions), "'{}'::jsonb")]
    [InlineData(typeof(PlatformAuditAlertRule), nameof(PlatformAuditAlertRule.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(PlatformAuditAlert), nameof(PlatformAuditAlert.Details), "'{}'::jsonb")]
    [InlineData(typeof(PlatformBillingDunningNotificationChannel), nameof(PlatformBillingDunningNotificationChannel.Metadata), "'{}'::jsonb")]
    [InlineData(typeof(PlatformBillingDunningNotificationEvent), nameof(PlatformBillingDunningNotificationEvent.RequestPayload), "'{}'::jsonb")]
    [InlineData(typeof(PlatformBillingDunningNotificationEvent), nameof(PlatformBillingDunningNotificationEvent.Metadata), "'{}'::jsonb")]
    public void Platform_operations_json_properties_are_mapped_to_jsonb(
        Type entityType,
        string propertyName,
        string expectedDefaultValueSql)
    {
        using var context = new TikuDbContext(Options);

        var property = context.Model.FindEntityType(entityType)!
            .FindProperty(propertyName)!;

        Assert.Equal("jsonb", property.GetColumnType());
        Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
        Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
    }

    [Fact]
    public void Platform_operations_have_expected_unique_indexes_arrays_and_money_fields()
    {
        using var context = new TikuDbContext(Options);

        AssertHasUniqueIndex<SaasFeature>(nameof(SaasFeature.Code));
        AssertHasUniqueIndex<PlatformBillingInvoice>(
            nameof(PlatformBillingInvoice.TenantId),
            nameof(PlatformBillingInvoice.InvoiceNo));
        AssertHasUniqueIndex<PlatformBillingInvoiceReminder>(
            nameof(PlatformBillingInvoiceReminder.TenantId),
            nameof(PlatformBillingInvoiceReminder.InvoiceId),
            nameof(PlatformBillingInvoiceReminder.ReminderType),
            nameof(PlatformBillingInvoiceReminder.Channel),
            nameof(PlatformBillingInvoiceReminder.ReminderDate));
        AssertHasUniqueIndex<PlatformAuditAlertRule>(nameof(PlatformAuditAlertRule.Code));
        AssertHasUniqueIndex<PlatformAuditAlert>(nameof(PlatformAuditAlert.RuleId), nameof(PlatformAuditAlert.AuditLogId));
        AssertHasUniqueIndex<PlatformBillingDunningNotificationChannel>(nameof(PlatformBillingDunningNotificationChannel.ChannelCode));
        AssertHasUniqueIndex<PlatformBillingDunningNotificationEvent>(
            nameof(PlatformBillingDunningNotificationEvent.TenantId),
            nameof(PlatformBillingDunningNotificationEvent.ChannelId),
            nameof(PlatformBillingDunningNotificationEvent.ReminderId));

        Assert.Equal(
            "text[]",
            context.Model.FindEntityType(typeof(PlatformAuditAlertRule))!
                .FindProperty(nameof(PlatformAuditAlertRule.ActionPatterns))!
                .GetColumnType());
        Assert.Equal(
            "uuid[]",
            context.Model.FindEntityType(typeof(PlatformBillingDunningNotificationChannel))!
                .FindProperty(nameof(PlatformBillingDunningNotificationChannel.TenantIds))!
                .GetColumnType());

        var total = context.Model.FindEntityType(typeof(PlatformBillingInvoice))!
            .FindProperty(nameof(PlatformBillingInvoice.TotalAmountCents))!;
        Assert.Equal(typeof(int), total.ClrType);

        void AssertHasUniqueIndex<TEntity>(params string[] propertyNames)
        {
            var indexes = context.Model.FindEntityType(typeof(TEntity))!.GetIndexes();

            Assert.Contains(
                indexes,
                index => index.IsUnique
                    && index.Properties
                        .Select(property => property.Name)
                        .SequenceEqual(propertyNames));
        }
    }

    [Theory]
    [InlineData(typeof(PocketBaseImportRun), nameof(PocketBaseImportRun.Stats), "'{}'::jsonb")]
    [InlineData(typeof(PocketBaseRawRecord), nameof(PocketBaseRawRecord.Record), "'{}'::jsonb")]
    [InlineData(typeof(PocketBaseRawRecord), nameof(PocketBaseRawRecord.Errors), "'[]'::jsonb")]
    public void PocketBase_import_json_properties_are_mapped_to_jsonb(
        Type entityType,
        string propertyName,
        string expectedDefaultValueSql)
    {
        using var context = new TikuDbContext(Options);

        var property = context.Model.FindEntityType(entityType)!
            .FindProperty(propertyName)!;

        Assert.Equal("jsonb", property.GetColumnType());
        Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
        Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
    }

    [Fact]
    public void PocketBase_import_raw_records_keep_legacy_identity_in_primary_key()
    {
        using var context = new TikuDbContext(Options);

        var keyProperties = context.Model.FindEntityType(typeof(PocketBaseRawRecord))!
            .FindPrimaryKey()!
            .Properties
            .Select(property => property.Name)
            .ToArray();

        Assert.Equal(
            [
                nameof(PocketBaseRawRecord.RunId),
                nameof(PocketBaseRawRecord.CollectionName),
                nameof(PocketBaseRawRecord.LegacyId)
            ],
            keyProperties);
    }
}
