using System.Collections.Concurrent;
using System.Globalization;
using System.Reflection;
using System.Text.RegularExpressions;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.LocalDb.Hlc;
using ZB.MOM.WW.LocalDb.Registration;

namespace ZB.MOM.WW.LocalDb.Internal;

/// <summary>
/// SQLite-backed <see cref="ILocalDb"/> host: opens/creates the file in WAL mode, ensures the
/// library schema, recovers the HLC from durable state, and hands out UDF-registered connections.
/// </summary>
internal sealed class SqliteLocalDb : ILocalDb, IDisposable
{
    // Gates every registered table name: it is interpolated into PRAGMA table_info and the trigger DDL.
    private static readonly Regex TableNameRegex = new("^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.Compiled);

    private static readonly ConcurrentDictionary<Type, PropertyInfo[]> PropertyCache = new();

    private readonly ConcurrentDictionary<string, ReplicatedTable> _replicatedTables = new(StringComparer.Ordinal);

    private readonly string _synchronous;
    private readonly int _busyTimeoutMs;
    private readonly string _connectionString;
    // Held open for the object lifetime: it anchors the WAL journal and is the connection
    // Dispose flushes the HLC high-water mark through, so the flush cannot fail on reopen.
    // Guarded by _masterLock: SqliteConnection is not thread-safe, and registration/dispose
    // may race from different threads.
    private readonly SqliteConnection _master;
    private readonly Lock _masterLock = new();
    private readonly HybridLogicalClock _clock;
    private readonly string _nodeId;
    private bool _disposed;

    internal HybridLogicalClock Clock => _clock;
    internal string NodeId => _nodeId;

    public SqliteLocalDb(LocalDbOptions options)
    {
        if (string.IsNullOrWhiteSpace(options.Path))
            throw new ArgumentException("LocalDbOptions.Path is required.", nameof(options));

        _synchronous = Array.Find(LocalDbOptionsValidator.AllowedSynchronous,
                s => s.Equals(options.Synchronous, StringComparison.OrdinalIgnoreCase))
            ?? throw new ArgumentException(
                $"LocalDbOptions.Synchronous must be one of {string.Join("/", LocalDbOptionsValidator.AllowedSynchronous)}; got '{options.Synchronous}'.",
                nameof(options));

        if (options.BusyTimeoutMs <= 0)
            throw new ArgumentException(
                $"LocalDbOptions.BusyTimeoutMs must be greater than 0; got {options.BusyTimeoutMs}.",
                nameof(options));
        _busyTimeoutMs = options.BusyTimeoutMs;
        _connectionString = new SqliteConnectionStringBuilder { DataSource = options.Path }.ToString();

        EnsureDirectoryExists(options.Path);

        _master = new SqliteConnection(_connectionString);
        _master.Open();
        ExecuteRaw(_master, "PRAGMA journal_mode=WAL; " + PerConnectionPragmas());

        LocalDbSchema.EnsureCreated(_master);

        // last_seen_hlc covers remote HLCs whose only local trace was a dead-lettered entry: after
        // an ungraceful crash neither oplog nor row_version carries them, and recovering below a
        // peer HLC already observed would let the clock issue regressed stamps.
        var initial = ScalarLong(_master,
            "SELECT MAX(" +
            "(SELECT hlc_high_water FROM __localdb_meta WHERE id=1)," +
            "COALESCE((SELECT MAX(hlc) FROM __localdb_oplog),0)," +
            "COALESCE((SELECT MAX(hlc) FROM __localdb_row_version),0)," +
            "COALESCE((SELECT last_seen_hlc FROM __localdb_peer_state WHERE id=1),0))");
        _clock = new HybridLogicalClock(initial);
        _master.CreateFunction("zb_hlc_next", () => _clock.Next());

        (_nodeId, _) = LocalDbSchema.ReadMeta(_master);
    }

    // journal_mode is a durable DB property already set on the file; synchronous is per-connection.
    private string PerConnectionPragmas() =>
        $"PRAGMA synchronous={_synchronous}; PRAGMA busy_timeout={_busyTimeoutMs}; PRAGMA foreign_keys=ON;";

    public SqliteConnection CreateConnection()
    {
        var conn = new SqliteConnection(_connectionString);
        try
        {
            conn.Open();
            ExecuteRaw(conn, PerConnectionPragmas());
            conn.CreateFunction("zb_hlc_next", () => _clock.Next());
            return conn;
        }
        catch
        {
            conn.Dispose();
            throw;
        }
    }

    internal async Task<SqliteConnection> CreateConnectionAsync(CancellationToken ct)
    {
        var conn = new SqliteConnection(_connectionString);
        try
        {
            await conn.OpenAsync(ct);
            await using (var cmd = conn.CreateCommand())
            {
                cmd.CommandText = PerConnectionPragmas();
                await cmd.ExecuteNonQueryAsync(ct);
            }
            conn.CreateFunction("zb_hlc_next", () => _clock.Next());
            return conn;
        }
        catch
        {
            await conn.DisposeAsync();
            throw;
        }
    }

    public async Task<int> ExecuteAsync(string sql, object? parameters = null, CancellationToken ct = default)
    {
        await using var conn = await CreateConnectionAsync(ct);
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = sql;
        Bind(cmd, parameters);
        return await cmd.ExecuteNonQueryAsync(ct);
    }

    public async Task<IReadOnlyList<T>> QueryAsync<T>(string sql, Func<SqliteDataReader, T> map, object? parameters = null, CancellationToken ct = default)
    {
        await using var conn = await CreateConnectionAsync(ct);
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = sql;
        Bind(cmd, parameters);
        return await ReadAllAsync(cmd, map, ct);
    }

    public async Task<ILocalDbTransaction> BeginTransactionAsync(CancellationToken ct = default)
    {
        var conn = await CreateConnectionAsync(ct);
        try
        {
            var tx = (SqliteTransaction)await conn.BeginTransactionAsync(ct);
            return new Transaction(conn, tx);
        }
        catch
        {
            await conn.DisposeAsync();
            throw;
        }
    }

    public IReadOnlyDictionary<string, ReplicatedTable> ReplicatedTables => _replicatedTables;

    // Thread-safe: all master-connection work runs under _masterLock.
    public ReplicatedTable RegisterReplicated(string tableName)
    {
        if (string.IsNullOrWhiteSpace(tableName) || !TableNameRegex.IsMatch(tableName))
            throw new LocalDbRegistrationException(
                $"Cannot register table '{tableName}': name must be a plain identifier matching {TableNameRegex}.");

        lock (_masterLock)
        {
            var columns = new List<(string Name, string Type, int Pk)>();
            using (var cmd = _master.CreateCommand())
            {
                cmd.CommandText = $"PRAGMA table_info('{tableName}')";
                using var reader = cmd.ExecuteReader();
                while (reader.Read())
                    // table_info cols: 0=cid, 1=name, 2=type, 3=notnull, 4=dflt_value, 5=pk (1-based PK ordinal, 0 if none).
                    columns.Add((reader.GetString(1), reader.IsDBNull(2) ? "" : reader.GetString(2), reader.GetInt32(5)));
            }

            if (columns.Count == 0)
                throw new LocalDbRegistrationException($"Cannot register table '{tableName}': it does not exist.");

            var blob = columns.FirstOrDefault(c => c.Type.Contains("BLOB", StringComparison.OrdinalIgnoreCase));
            if (blob.Name is not null)
                throw new LocalDbRegistrationException(
                    $"Cannot register table '{tableName}': column '{blob.Name}' is declared {blob.Type} — " +
                    "BLOB columns are not supported in replicated tables (json_object cannot capture them).");

            var pkColumns = columns.Where(c => c.Pk > 0).OrderBy(c => c.Pk).Select(c => c.Name).ToList();
            if (pkColumns.Count == 0)
                throw new LocalDbRegistrationException(
                    $"Cannot register table '{tableName}': replication requires an explicit primary key.");

            var allColumns = columns.Select(c => (c.Name, c.Type)).ToList();
            var digest = ReplicatedTable.ComputeDigest(tableName, pkColumns, allColumns);
            var table = new ReplicatedTable(tableName, pkColumns, allColumns, digest);

            var script = TriggerSqlGenerator.BuildInstallScript(table);
            using (var tx = _master.BeginTransaction())
            {
                using (var cmd = _master.CreateCommand())
                {
                    cmd.Transaction = tx;
                    cmd.CommandText = script;
                    cmd.ExecuteNonQuery();
                }
                tx.Commit();
            }

            _replicatedTables[tableName] = table;
            return table;
        }
    }

    public void Dispose()
    {
        if (_disposed)
            return;
        _disposed = true;
        lock (_masterLock)
        {
            LocalDbSchema.FlushHlcHighWater(_master, _clock.Current);
            _master.Dispose();
        }
    }

    /// <summary>
    /// Creates the directory holding the database file when it does not already exist.
    /// </summary>
    /// <remarks>
    /// <para>
    /// SQLite creates the database FILE on demand but never its parent DIRECTORY, and this
    /// constructor opens the file eagerly — so a configured path under a directory that does
    /// not exist yet was a hard startup failure ("SQLite Error 14: unable to open database
    /// file") rather than a first-run that simply worked. Relative defaults such as
    /// <c>./data/app.db</c> hit it routinely; a containerized consumer escapes only when a
    /// volume mount happens to create the directory first.
    /// </para>
    /// <para>
    /// Creating it here is deliberate rather than merely convenient. The objection to a
    /// library that creates directories is that it can mask a mis-typed path by silently
    /// starting an empty database — but SQLite ALREADY does exactly that for a mis-typed
    /// file NAME. Refusing to create the directory therefore protects nothing; it only makes
    /// the two halves of the same path behave inconsistently, and turns the directory half
    /// into an opaque error.
    /// </para>
    /// <para>
    /// A creation failure (permissions, read-only mount) is reported here rather than left
    /// to surface as SQLite error 14, because the error the caller actually needs names the
    /// directory and the reason.
    /// </para>
    /// </remarks>
    private static void EnsureDirectoryExists(string path)
    {
        string? directory;
        try
        {
            directory = Path.GetDirectoryName(Path.GetFullPath(path));
        }
        catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
        {
            // An unresolvable path is not this method's error to report: opening it produces a
            // message naming the data source, which is the better diagnostic.
            return;
        }

        if (string.IsNullOrEmpty(directory) || Directory.Exists(directory))
            return;

        try
        {
            Directory.CreateDirectory(directory);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            throw new InvalidOperationException(
                $"LocalDb could not create the directory '{directory}' for database file '{path}'. " +
                "SQLite creates the database file but not its parent directory; either create the " +
                "directory out of band or point LocalDb:Path somewhere writable.", ex);
        }
    }

    private static async Task<IReadOnlyList<T>> ReadAllAsync<T>(SqliteCommand cmd, Func<SqliteDataReader, T> map, CancellationToken ct)
    {
        var results = new List<T>();
        await using var reader = await cmd.ExecuteReaderAsync(ct);
        while (await reader.ReadAsync(ct))
            results.Add(map((SqliteDataReader)reader));
        return results;
    }

    private static void Bind(SqliteCommand cmd, object? parameters)
    {
        switch (parameters)
        {
            case null:
                return;
            case IDictionary<string, object?> dict:
                foreach (var (key, value) in dict)
                    cmd.Parameters.AddWithValue("@" + key, value ?? DBNull.Value);
                return;
            // Any other collection would silently mis-bind under the reflection fallback (its members —
            // Count, Keys, Length … — bind as bogus @parameters), so fail loudly instead. string is exempt:
            // it is IEnumerable but a legitimate reflection-fallback target is never a bare string anyway.
            case System.Collections.IEnumerable when parameters is not string:
                throw new ArgumentException(
                    $"Unsupported parameters type '{parameters.GetType()}': pass an anonymous object / POCO " +
                    "(public properties bind as @Name) or an IDictionary<string, object?> (entries bind as @Key). " +
                    "A Dictionary<string, string>, Hashtable, array, or other collection is not supported.",
                    nameof(parameters));
            default:
                foreach (var prop in PropertiesOf(parameters.GetType()))
                    cmd.Parameters.AddWithValue("@" + prop.Name, prop.GetValue(parameters) ?? DBNull.Value);
                return;
        }
    }

    private static PropertyInfo[] PropertiesOf(Type type) =>
        PropertyCache.GetOrAdd(type, static t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance));

    private static void ExecuteRaw(SqliteConnection conn, string sql)
    {
        using var cmd = conn.CreateCommand();
        cmd.CommandText = sql;
        cmd.ExecuteNonQuery();
    }

    private static long ScalarLong(SqliteConnection conn, string sql)
    {
        using var cmd = conn.CreateCommand();
        cmd.CommandText = sql;
        return Convert.ToInt64(cmd.ExecuteScalar(), CultureInfo.InvariantCulture);
    }

    private sealed class Transaction(SqliteConnection connection, SqliteTransaction transaction) : ILocalDbTransaction
    {
        public async Task<int> ExecuteAsync(string sql, object? parameters = null, CancellationToken ct = default)
        {
            await using var cmd = connection.CreateCommand();
            cmd.Transaction = transaction;
            cmd.CommandText = sql;
            Bind(cmd, parameters);
            return await cmd.ExecuteNonQueryAsync(ct);
        }

        public async Task<IReadOnlyList<T>> QueryAsync<T>(string sql, Func<SqliteDataReader, T> map, object? parameters = null, CancellationToken ct = default)
        {
            await using var cmd = connection.CreateCommand();
            cmd.Transaction = transaction;
            cmd.CommandText = sql;
            Bind(cmd, parameters);
            return await ReadAllAsync(cmd, map, ct);
        }

        public Task CommitAsync(CancellationToken ct = default) => transaction.CommitAsync(ct);

        public Task RollbackAsync(CancellationToken ct = default) => transaction.RollbackAsync(ct);

        public async ValueTask DisposeAsync()
        {
            await transaction.DisposeAsync();
            await connection.DisposeAsync();
        }
    }
}
