using Microsoft.Extensions.Logging;

namespace ClawdDotNet.Core.Deploymentcenter;

/// <summary>Meldet Laufzeitfehler an das Deploymentcenter.</summary>
public interface IErrorReporter
{
    /// <summary>
    /// Meldet eine Ausnahme. <paramref name="fatal"/> heißt: Der Prozess endet daran.
    /// Gibt zurück, ob die Meldung angekommen ist — der Aufrufer muss das nicht prüfen.
    /// </summary>
    Task<bool> ReportAsync(Exception exception, bool fatal = false,
        IReadOnlyDictionary<string, object?>? context = null, CancellationToken ct = default);
}

/// <summary>Tut nichts. Für abgeschaltete Meldung und für Tests.</summary>
public sealed class NullErrorReporter : IErrorReporter
{
    public static readonly NullErrorReporter Instance = new();

    public Task<bool> ReportAsync(Exception exception, bool fatal = false,
        IReadOnlyDictionary<string, object?>? context = null, CancellationToken ct = default)
        => Task.FromResult(false);
}

/// <summary>
/// Anbindung an <c>POST /api/errors/v1/report</c> — den Eingang für den globalen
/// Ausnahmebehandler.
///
/// <para>Gespeichert wird serverseitig in derselben Tabelle wie der Bugtracker. Die
/// Trennung von Rauschen und Signal leisten dort die Ignore-Regeln: Ein bekannter,
/// harmloser Fehler wird weiterhin gezählt, bleibt aber aus der Übersicht — und schlägt
/// Alarm, wenn er plötzlich hundertmal so oft auftritt.</para>
///
/// <para><b>Eigener Schutz gegen Fehlerschleifen.</b> Der Server drosselt auf 300
/// Meldungen pro Minute und IP. Diese Klasse drosselt schon vorher: Derselbe Fehler
/// (gleicher Typ, gleiche Stelle) geht höchstens einmal je Zeitfenster raus. Ohne das
/// erzeugt eine Schleife in einem Timer tausende identische Anfragen, die der Server
/// dann verwerfen muss — und der einzige, der davon etwas hat, ist die Leitung.</para>
/// </summary>
public sealed class ErrorReporter : IErrorReporter, IDisposable
{
    /// <summary>Wie lange derselbe Fehler nach einer Meldung stumm bleibt.</summary>
    private static readonly TimeSpan RepeatWindow = TimeSpan.FromMinutes(5);

    /// <summary>Obergrenze für den Stacktrace — der Server schneidet sonst mitten im Wort ab.</summary>
    private const int MaxStackTraceLength = 8000;

    private readonly DeploymentcenterApi _api;
    private readonly bool _ownsApi;
    private readonly string _projectSlug;
    private readonly string _environment;
    private readonly string _build;
    private readonly ILogger _logger;
    private readonly Func<DateTimeOffset> _now;

    private readonly Dictionary<string, DateTimeOffset> _lastSent = [];
    private readonly Lock _gate = new();

    public ErrorReporter(
        DeploymentcenterApi api,
        string projectSlug,
        string environment,
        string build,
        ILogger logger,
        bool ownsApi = false,
        Func<DateTimeOffset>? now = null)
    {
        _api = api;
        _projectSlug = projectSlug;
        _environment = environment;
        _build = build;
        _logger = logger;
        _ownsApi = ownsApi;
        _now = now ?? (() => DateTimeOffset.UtcNow);
    }

    public async Task<bool> ReportAsync(Exception exception, bool fatal = false,
        IReadOnlyDictionary<string, object?>? context = null, CancellationToken ct = default)
    {
        if (!ShouldSend(exception))
            return false;

        var payload = new Dictionary<string, object?>
        {
            ["project_slug"] = _projectSlug,
            ["exception"] = exception.GetType().FullName,
            ["message"] = exception.Message,
            ["stack_trace"] = Truncate(exception.ToString(), MaxStackTraceLength),
            ["level"] = fatal ? "fatal" : "error",
            ["build"] = _build,
            ["environment"] = _environment,

            // Idempotenz: Kommt derselbe Fehler nach einem Neustart erneut, erhöht der
            // Server den Zähler, statt einen zweiten Eintrag anzulegen.
            ["client_ref"] = Fingerprint(exception)
        };

        if (context is { Count: > 0 })
            payload["context"] = context;

        if (exception.TargetSite?.DeclaringType?.FullName is { } declaringType)
            payload["file"] = declaringType;

        try
        {
            await _api.PostAsync("/api/errors/v1/report", payload, ct).ConfigureAwait(false);
            return true;
        }
        catch (Exception ex)
        {
            // Ein Meldeweg, der selbst wirft, wäre die schlechteste aller Welten:
            // Der ursprüngliche Fehler ginge dabei verloren.
            _logger.LogDebug(ex, "Fehlermeldung an das Deploymentcenter fehlgeschlagen (ignoriert).");
            return false;
        }
    }

    /// <summary>Drosselung je Fehlerart, damit eine Schleife nicht die Leitung flutet.</summary>
    private bool ShouldSend(Exception exception)
    {
        var key = Fingerprint(exception);
        var now = _now();

        lock (_gate)
        {
            if (_lastSent.TryGetValue(key, out var last) && now - last < RepeatWindow)
                return false;

            // Alte Einträge räumen, damit das Wörterbuch bei wechselnden Fehlern nicht wächst.
            if (_lastSent.Count > 200)
            {
                foreach (var stale in _lastSent
                             .Where(e => now - e.Value > RepeatWindow)
                             .Select(e => e.Key)
                             .ToList())
                {
                    _lastSent.Remove(stale);
                }
            }

            _lastSent[key] = now;
            return true;
        }
    }

    /// <summary>
    /// Kennzeichen eines Fehlers: Typ plus oberste Stelle im Stacktrace. Die Meldung
    /// bleibt bewusst außen vor — sie enthält oft wechselnde Werte (IDs, Pfade), und
    /// dann wäre jeder Aufruf ein neuer Fehler.
    /// </summary>
    private static string Fingerprint(Exception exception)
    {
        var frame = exception.StackTrace?
            .Split('\n', StringSplitOptions.RemoveEmptyEntries)
            .FirstOrDefault()?
            .Trim() ?? "";

        return $"{exception.GetType().FullName}|{frame}";
    }

    private static string Truncate(string value, int max) =>
        value.Length <= max ? value : value[..max] + "\n… (gekürzt)";

    public void Dispose()
    {
        if (_ownsApi)
            _api.Dispose();
    }
}
