using System;
using System.Collections.Generic;
using System.Diagnostics;
using Npgsql.Internal;
using Npgsql.Logging;

namespace Npgsql;

class PreparedStatementManager
{
    internal int MaxAutoPrepared { get; }
    internal int UsagesBeforePrepare { get; }

    internal Dictionary<string, PreparedStatement> BySql { get; } = new();
    internal PreparedStatement?[] AutoPrepared { get; }

    readonly PreparedStatement?[] _candidates;

    /// <summary>
    /// Total number of current prepared statements (whether explicit or automatic).
    /// </summary>
    internal int NumPrepared;

    readonly NpgsqlConnector _connector;

    internal string NextPreparedStatementName() => "_p" + (++_preparedStatementIndex);
    ulong _preparedStatementIndex;

    static readonly NpgsqlLogger Log = NpgsqlLogManager.CreateLogger(nameof(PreparedStatementManager));

    internal const int CandidateCount = 100;

    internal PreparedStatementManager(NpgsqlConnector connector)
    {
        _connector = connector;
        MaxAutoPrepared = connector.Settings.MaxAutoPrepare;
        UsagesBeforePrepare = connector.Settings.AutoPrepareMinUsages;
        if (MaxAutoPrepared > 0)
        {
            if (MaxAutoPrepared > 256)
                Log.Warn($"{nameof(MaxAutoPrepared)} is over 256, performance degradation may occur. Please report via an issue.", connector.Id);
            AutoPrepared = new PreparedStatement[MaxAutoPrepared];
            _candidates = new PreparedStatement[CandidateCount];
        }
        else
        {
            AutoPrepared = null!;
            _candidates = null!;
        }
    }

    internal PreparedStatement? GetOrAddExplicit(NpgsqlBatchCommand batchCommand)
    {
        var sql = batchCommand.FinalCommandText!;

        PreparedStatement? statementBeingReplaced = null;
        if (BySql.TryGetValue(sql, out var pStatement))
        {
            Debug.Assert(pStatement.State != PreparedState.Unprepared);
            if (pStatement.IsExplicit)
            {
                // Great, we've found an explicit prepared statement.
                // We just need to check that the parameter types correspond, since prepared statements are
                // only keyed by SQL (to prevent pointless allocations). If we have a mismatch, simply run unprepared.
                return pStatement.DoParametersMatch(batchCommand.PositionalParameters!)
                    ? pStatement
                    : null;
            }

            // We've found an autoprepare statement (candidate or otherwise)
            switch (pStatement.State)
            {
            case PreparedState.NotPrepared:
                // Found a candidate for autopreparation. Remove it and prepare explicitly.
                RemoveCandidate(pStatement);
                break;
            case PreparedState.Prepared:
                // The statement has already been autoprepared. We need to "promote" it to explicit.
                statementBeingReplaced = pStatement;
                break;
            case PreparedState.Unprepared:
                throw new InvalidOperationException($"Found unprepared statement in {nameof(PreparedStatementManager)}");
            default:
                throw new ArgumentOutOfRangeException();
            }
        }

        // Statement hasn't been prepared yet
        return BySql[sql] = PreparedStatement.CreateExplicit(this, sql, NextPreparedStatementName(), batchCommand.PositionalParameters, statementBeingReplaced);
    }

    internal PreparedStatement? TryGetAutoPrepared(NpgsqlBatchCommand batchCommand)
    {
        var sql = batchCommand.FinalCommandText!;
        if (!BySql.TryGetValue(sql, out var pStatement))
        {
            // New candidate. Find an empty candidate slot or eject a least-used one.
            int slotIndex = -1, leastUsages = int.MaxValue;
            var lastUsed = DateTime.MaxValue;
            for (var i = 0; i < _candidates.Length; i++)
            {
                var candidate = _candidates[i];
                // ReSharper disable once ConditionIsAlwaysTrueOrFalse
                // ReSharper disable HeuristicUnreachableCode
                if (candidate == null)  // Found an unused candidate slot, return immediately
                {
                    slotIndex = i;
                    break;
                }
                // ReSharper restore HeuristicUnreachableCode
                if (candidate.Usages < leastUsages)
                {
                    leastUsages = candidate.Usages;
                    slotIndex = i;
                    lastUsed = candidate.LastUsed;
                }
                else if (candidate.Usages == leastUsages && candidate.LastUsed < lastUsed)
                {
                    slotIndex = i;
                    lastUsed = candidate.LastUsed;
                }
            }

            var leastUsed = _candidates[slotIndex];
            // ReSharper disable once ConditionIsAlwaysTrueOrFalse
            if (leastUsed != null)
                BySql.Remove(leastUsed.Sql);
            pStatement = BySql[sql] = _candidates[slotIndex] = PreparedStatement.CreateAutoPrepareCandidate(this, sql);
        }

        switch (pStatement.State)
        {
        case PreparedState.NotPrepared:
            break;

        case PreparedState.Prepared:
        case PreparedState.BeingPrepared:
            // The statement has already been prepared (explicitly or automatically), or has been selected
            // for preparation (earlier identical statement in the same command).
            // We just need to check that the parameter types correspond, since prepared statements are
            // only keyed by SQL (to prevent pointless allocations). If we have a mismatch, simply run unprepared.
            if (!pStatement.DoParametersMatch(batchCommand.PositionalParameters))
                return null;
            // Prevent this statement from being replaced within this batch
            pStatement.LastUsed = DateTime.MaxValue;
            return pStatement;

        case PreparedState.BeingUnprepared:
            // The statement is being replaced by an earlier statement in this same batch.
            return null;

        default:
            Debug.Fail($"Unexpected {nameof(PreparedState)} in auto-preparation: {pStatement.State}");
            break;
        }

        if (++pStatement.Usages < UsagesBeforePrepare)
        {
            // Statement still hasn't passed the usage threshold, no automatic preparation.
            // Return null for unprepared execution.
            pStatement.LastUsed = DateTime.UtcNow;
            return null;
        }

        // Bingo, we've just passed the usage threshold, statement should get prepared
        Log.Trace($"Automatically preparing statement: {sql}", _connector.Id);

        // Look for either an empty autoprepare slot, or the least recently used prepared statement which we'll replace it.
        var oldestTimestamp = DateTime.MaxValue;
        var selectedIndex = -1;
        for (var i = 0; i < AutoPrepared.Length; i++)
        {
            var slot = AutoPrepared[i];

            if (slot is null)
            {
                // We found a free slot, exit the loop immediately
                selectedIndex = i;
                break;
            }

            switch (slot.State)
            {
            case PreparedState.Prepared:
                if (slot.LastUsed < oldestTimestamp)
                {
                    selectedIndex = i;
                    oldestTimestamp = slot.LastUsed;
                }
                break;

            case PreparedState.BeingPrepared:
                // Slot has already been selected for preparation by an earlier statement in this batch. Skip it.
                continue;

            default:
                throw new Exception(
                    $"Invalid {nameof(PreparedState)} state {slot.State} encountered when scanning prepared statement slots");
            }
        }

        if (selectedIndex == -1)
        {
            // We're here if we couldn't find a free slot or a prepared statement to replace - this means all slots are taken by
            // statements being prepared in this batch.
            return null;
        }

        var oldPreparedStatement = AutoPrepared[selectedIndex];

        if (oldPreparedStatement is null)
        {
            pStatement.Name = "_auto" + selectedIndex;
        }
        else
        {
            pStatement.Name = oldPreparedStatement.Name;
            pStatement.StatementBeingReplaced = oldPreparedStatement;
            oldPreparedStatement.State = PreparedState.BeingUnprepared;
        }

        pStatement.AutoPreparedSlotIndex = selectedIndex;
        AutoPrepared[selectedIndex] = pStatement;

        RemoveCandidate(pStatement);

        // Make sure this statement isn't replaced by a later statement in the same batch.
        pStatement.LastUsed = DateTime.MaxValue;

        // Note that the parameter types are only set at the moment of preparation - in the candidate phase
        // there's no differentiation between overloaded statements, which are a pretty rare case, saving
        // allocations.
        pStatement.SetParamTypes(batchCommand.PositionalParameters);

        return pStatement;
    }

    void RemoveCandidate(PreparedStatement candidate)
    {
        var i = 0;
        for (; i < _candidates.Length; i++)
        {
            if (_candidates[i] == candidate)
            {
                _candidates[i] = null;
                return;
            }
        }
        Debug.Assert(i < _candidates.Length);
    }

    internal void ClearAll()
    {
        BySql.Clear();
        NumPrepared = 0;
        _preparedStatementIndex = 0;
        if (AutoPrepared is not null)
            for (var i = 0; i < AutoPrepared.Length; i++)
                AutoPrepared[i] = null;
        if (_candidates != null)
            for (var i = 0; i < _candidates.Length; i++)
                _candidates[i] = null;
    }
}