using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Entities.Deployment;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Entities.Instances;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Entities.Scripts;
using ScadaLink.Commons.Entities.Security;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Entities.Templates;

namespace ScadaLink.ConfigurationDatabase;

public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
{
    private readonly IDataProtectionProvider? _dataProtectionProvider;

    public ScadaLinkDbContext(DbContextOptions<ScadaLinkDbContext> options) : base(options)
    {
    }

    /// <summary>
    /// Creates a context with an explicit Data Protection provider used to encrypt
    /// secret-bearing configuration columns at rest. The runtime resolves this overload
    /// via DI; design-time tooling uses the single-argument overload.
    /// </summary>
    public ScadaLinkDbContext(DbContextOptions<ScadaLinkDbContext> options, IDataProtectionProvider dataProtectionProvider)
        : base(options)
    {
        _dataProtectionProvider = dataProtectionProvider
            ?? throw new ArgumentNullException(nameof(dataProtectionProvider));
    }

    // Templates
    public DbSet<Template> Templates => Set<Template>();
    public DbSet<TemplateAttribute> TemplateAttributes => Set<TemplateAttribute>();
    public DbSet<TemplateAlarm> TemplateAlarms => Set<TemplateAlarm>();
    public DbSet<TemplateScript> TemplateScripts => Set<TemplateScript>();
    public DbSet<TemplateComposition> TemplateCompositions => Set<TemplateComposition>();
    public DbSet<TemplateFolder> TemplateFolders => Set<TemplateFolder>();

    // Instances
    public DbSet<Instance> Instances => Set<Instance>();
    public DbSet<InstanceAttributeOverride> InstanceAttributeOverrides => Set<InstanceAttributeOverride>();
    public DbSet<InstanceAlarmOverride> InstanceAlarmOverrides => Set<InstanceAlarmOverride>();
    public DbSet<InstanceConnectionBinding> InstanceConnectionBindings => Set<InstanceConnectionBinding>();
    public DbSet<Area> Areas => Set<Area>();

    // Sites
    public DbSet<Site> Sites => Set<Site>();
    public DbSet<DataConnection> DataConnections => Set<DataConnection>();

    // Deployment
    public DbSet<DeploymentRecord> DeploymentRecords => Set<DeploymentRecord>();
    public DbSet<SystemArtifactDeploymentRecord> SystemArtifactDeploymentRecords => Set<SystemArtifactDeploymentRecord>();
    public DbSet<DeployedConfigSnapshot> DeployedConfigSnapshots => Set<DeployedConfigSnapshot>();

    // External Systems
    public DbSet<ExternalSystemDefinition> ExternalSystemDefinitions => Set<ExternalSystemDefinition>();
    public DbSet<ExternalSystemMethod> ExternalSystemMethods => Set<ExternalSystemMethod>();
    public DbSet<DatabaseConnectionDefinition> DatabaseConnectionDefinitions => Set<DatabaseConnectionDefinition>();

    // Notifications
    public DbSet<NotificationList> NotificationLists => Set<NotificationList>();
    public DbSet<NotificationRecipient> NotificationRecipients => Set<NotificationRecipient>();
    public DbSet<SmtpConfiguration> SmtpConfigurations => Set<SmtpConfiguration>();
    public DbSet<Notification> Notifications => Set<Notification>();

    // Scripts
    public DbSet<SharedScript> SharedScripts => Set<SharedScript>();

    // Security
    public DbSet<LdapGroupMapping> LdapGroupMappings => Set<LdapGroupMapping>();
    public DbSet<SiteScopeRule> SiteScopeRules => Set<SiteScopeRule>();

    // Inbound API
    public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
    public DbSet<ApiMethod> ApiMethods => Set<ApiMethod>();

    // Audit
    public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();

    // Data Protection Keys (for shared ASP.NET Data Protection across nodes)
    public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        base.OnConfiguring(optionsBuilder);

        // The secret-column converter built in OnModelCreating differs depending on whether
        // a real Data Protection provider was supplied (encrypting converter) or not
        // (schema-only converter). EF Core's default model cache keys only on context type,
        // so a provider-bearing and a schema-only context sharing the same options type
        // would otherwise share one cached model — and whichever was built first would win.
        // Distinguish them so each gets its own model.
        optionsBuilder.ReplaceService<IModelCacheKeyFactory, SecretAwareModelCacheKeyFactory>();
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(ScadaLinkDbContext).Assembly);

        ApplySecretColumnEncryption(modelBuilder);
    }

    public override int SaveChanges(bool acceptAllChangesOnSuccess)
    {
        GuardSecretWritesHaveAKeyRing();
        return base.SaveChanges(acceptAllChangesOnSuccess);
    }

    public override Task<int> SaveChangesAsync(
        bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
    {
        GuardSecretWritesHaveAKeyRing();
        return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
    }

    /// <summary>True when this context was constructed with a real Data Protection provider.</summary>
    internal bool HasSecretEncryptionProvider => _dataProtectionProvider is not null;

    /// <summary>
    /// Fails fast — before any database round-trip — if a context built without a real
    /// Data Protection key ring (the schema-only single-argument constructor) is about to
    /// persist a non-null secret-bearing column. Without this guard the schema-only
    /// protector would still throw, but only deep inside EF's update pipeline wrapped in a
    /// <c>DbUpdateException</c>/<c>CryptographicException</c>; surfacing a clear
    /// <see cref="InvalidOperationException"/> here makes the misconfiguration obvious.
    /// </summary>
    private void GuardSecretWritesHaveAKeyRing()
    {
        if (_dataProtectionProvider is not null)
            return;

        foreach (var entry in ChangeTracker.Entries())
        {
            if (entry.State is not (EntityState.Added or EntityState.Modified))
                continue;

            string? secretProperty = entry.Entity switch
            {
                SmtpConfiguration => nameof(SmtpConfiguration.Credentials),
                ExternalSystemDefinition => nameof(ExternalSystemDefinition.AuthConfiguration),
                DatabaseConnectionDefinition => nameof(DatabaseConnectionDefinition.ConnectionString),
                _ => null
            };
            if (secretProperty is null)
                continue;

            if (entry.Property(secretProperty).CurrentValue is not null)
            {
                throw new InvalidOperationException(
                    "This ScadaLinkDbContext was constructed without a Data Protection key " +
                    "ring (the single-argument, schema-only constructor). It cannot persist " +
                    $"the secret-bearing column '{entry.Entity.GetType().Name}.{secretProperty}'. " +
                    "Construct the context with the DI-registered IDataProtectionProvider " +
                    "(AddConfigurationDatabase wires this up).");
            }
        }
    }

    /// <summary>
    /// Model cache key factory that folds <see cref="HasSecretEncryptionProvider"/> into the
    /// cache key, so a write-capable (provider-bearing) context and a schema-only context do
    /// not share a cached model.
    /// </summary>
    private sealed class SecretAwareModelCacheKeyFactory : IModelCacheKeyFactory
    {
        public object Create(DbContext context, bool designTime)
            => (context.GetType(),
                designTime,
                (context as ScadaLinkDbContext)?.HasSecretEncryptionProvider ?? false);

        public object Create(DbContext context) => Create(context, false);
    }

    /// <summary>
    /// Applies encryption-at-rest to columns that hold authentication secrets
    /// (SMTP credentials, external-system auth config, database connection strings)
    /// so they are never persisted as plaintext.
    /// </summary>
    /// <remarks>
    /// When no Data Protection provider is supplied — design-time <c>dotnet ef</c> tooling,
    /// which only emits schema and never reads or writes secret data — a schema-only
    /// protector is used. It produces an identical <c>nvarchar</c> schema, but throws a
    /// clear <see cref="InvalidOperationException"/> if a secret column is ever read or
    /// written through it. This deliberately does NOT silently substitute an ephemeral
    /// (in-memory, process-lifetime) key: encrypting a runtime write with a throwaway key
    /// would persist ciphertext that becomes permanently undecryptable on the next process
    /// restart, with no error. A write-capable context must be constructed with the
    /// DI-registered provider whose keys are persisted to this database.
    /// </remarks>
    private void ApplySecretColumnEncryption(ModelBuilder modelBuilder)
    {
        IDataProtector protector = _dataProtectionProvider is { } provider
            ? provider.CreateProtector(EncryptedStringConverter.ProtectorPurpose)
            : SchemaOnlyDataProtector.Instance;

        // Held as the non-generic ValueConverter base so all three HasConversion calls
        // read identically. The converter's CLR type is string?->string?; binding it to
        // the non-nullable DatabaseConnectionDefinition.ConnectionString property would
        // otherwise raise a CS8620 nullability mismatch — the non-generic reference is
        // the supported way to apply one converter uniformly across nullable and
        // non-nullable string columns.
        ValueConverter converter = new EncryptedStringConverter(protector);

        modelBuilder.Entity<SmtpConfiguration>()
            .Property(s => s.Credentials)
            .HasConversion(converter);

        modelBuilder.Entity<ExternalSystemDefinition>()
            .Property(e => e.AuthConfiguration)
            .HasConversion(converter);

        modelBuilder.Entity<DatabaseConnectionDefinition>()
            .Property(d => d.ConnectionString)
            .HasConversion(converter);
    }

    /// <summary>
    /// An <see cref="IDataProtector"/> for contexts built without a real Data Protection
    /// provider (design-time / schema-only). It satisfies model building but fails fast
    /// with a clear message if a secret column is actually read or written, rather than
    /// silently producing throwaway ciphertext that cannot be decrypted after a restart.
    /// </summary>
    private sealed class SchemaOnlyDataProtector : IDataProtector
    {
        internal static readonly SchemaOnlyDataProtector Instance = new();

        private const string Message =
            "This ScadaLinkDbContext was constructed without a Data Protection key ring " +
            "(the single-argument, schema-only constructor). Secret-bearing configuration " +
            "columns cannot be read or written through it. Construct the context with the " +
            "DI-registered IDataProtectionProvider (AddConfigurationDatabase wires this up).";

        public IDataProtector CreateProtector(string purpose) => this;

        public byte[] Protect(byte[] plaintext) => throw new InvalidOperationException(Message);

        public byte[] Unprotect(byte[] protectedData) => throw new InvalidOperationException(Message);
    }
}
