using System.Diagnostics;
using Serilog;

namespace Predictalytics.Hosting;

/// <summary>
/// Forwards runtime errors to the Deployment Center error stream
/// (POST /api/errors/v1/report). The server groups identical errors, counts them up and
/// silences known-harmless ones via ignore rules — so this reports rather than filters.
///
/// Static on purpose: the Serilog sink and the global exception handlers are wired up
/// before the settings are known, and both have to reach the same rate limiter.
/// </summary>
public static class DcErrorReporter
{
    /// <summary>Server limit is 60 reports per minute and IP — stay well below it.</summary>
    private const int MaxReportsPerMinute = 20;

    /// <summary>The same error is only reported again after this interval (the server counts it up anyway).</summary>
    private static readonly TimeSpan RepeatSuppression = TimeSpan.FromMinutes(5);

    private static readonly object Sync = new();
    private static readonly Dictionary<string, DateTime> RecentSignatures = new();

    private static DcApiClient? _api;
    private static bool _enabled;
    private static bool _tokenRejected;
    private static DateTime _windowStartUtc = DateTime.UtcNow;
    private static int _sentInWindow;

    public static bool IsEnabled => _enabled && _api is not null && !_tokenRejected;

    /// <summary>(Re-)configures the reporter. An empty token switches it off.</summary>
    public static void Configure(string token, bool enabled)
    {
        lock (Sync)
        {
            _api?.Dispose();
            _api = null;
            _tokenRejected = false;

            if (!enabled || string.IsNullOrWhiteSpace(token))
            {
                _enabled = false;
                return;
            }

            _api = new DcApiClient(token);
            _enabled = true;
        }
    }

    public static void Shutdown()
    {
        lock (Sync)
        {
            _enabled = false;
            _api?.Dispose();
            _api = null;
        }
    }

    /// <summary>
    /// Installs the global handlers. Without them an unhandled exception ends the process
    /// without a trace in the error stream — exactly the case the stream exists for.
    /// </summary>
    public static void InstallGlobalHandlers()
    {
        // Der UI-Thread-Handler bleibt beim Host: jedes Fenstersystem hat sein eigenes
        // Ereignis dafuer (WinForms Application.ThreadException, Avalonia
        // Dispatcher.UIThread.UnhandledException). Der Host ruft dafuer
        // ReportUiThreadException auf.

        AppDomain.CurrentDomain.UnhandledException += (_, e) =>
        {
            if (e.ExceptionObject is not Exception ex) return;
            // Report before logging: the Serilog sink would report the same exception first
            // and the duplicate suppression would then swallow the blocking call — the
            // process would die before anything reached the server.
            Report(ex, "fatal", blocking: true);
            Log.Fatal(ex, "Unbehandelte Ausnahme — Prozess wird beendet");
        };

        TaskScheduler.UnobservedTaskException += (_, e) =>
        {
            Log.Warning(e.Exception, "Unbeobachtete Task-Ausnahme");
            Report(e.Exception, "error");
            e.SetObserved();
        };
    }

    /// <summary>
    /// Vom Host aufzurufen, wenn im UI-Thread eine unbehandelte Ausnahme auftritt.
    /// Protokolliert und meldet sie; die Meldung an den Benutzer bleibt beim Host,
    /// weil dafuer ein Fenster gebraucht wird.
    /// </summary>
    public static void ReportUiThreadException(Exception exception)
    {
        Log.Error(exception, "Unbehandelte Ausnahme im UI-Thread");
        Report(exception, "error");
    }

    public static void Report(Exception exception, string level = "error", bool blocking = false)
    {
        if (!IsEnabled) return;

        var inner = Unwrap(exception);
        var (file, line) = ResolveOrigin(inner);

        Send(
            exceptionType: inner.GetType().FullName ?? inner.GetType().Name,
            message: inner.Message,
            stackTrace: exception.ToString(),
            level: level,
            file: file,
            line: line,
            blocking: blocking);
    }

    /// <summary>Reports a logged error that carries no exception (Log.Error("..." )).</summary>
    public static void ReportMessage(string exceptionType, string message, string? stackTrace, string level)
    {
        if (!IsEnabled) return;
        Send(exceptionType, message, stackTrace, level, null, null, blocking: false);
    }

    private static void Send(
        string exceptionType, string message, string? stackTrace, string level,
        string? file, int? line, bool blocking)
    {
        DcApiClient api;
        lock (Sync)
        {
            if (_api is null || !_enabled || _tokenRejected) return;
            if (!PassesRateLimit(exceptionType, message)) return;
            api = _api;
        }

        var payload = new
        {
            project_slug = DcConfig.ProductSlug,
            exception = exceptionType,
            message = Truncate(message, 2000),
            stack_trace = Truncate(stackTrace, 8000),
            level,
            build = DcConfig.AppVersion,
            environment = DcConfig.Environment,
            file,
            line,
            // Tells apart reports coming from several installations of the same build,
            // and pins the report to an exact commit.
            context = new { host = System.Environment.MachineName, commit = DcConfig.GitCommitShort }
        };

        var task = PostAsync(api, payload);
        if (blocking)
        {
            task.Wait(TimeSpan.FromSeconds(5));
        }
    }

    private static async Task PostAsync(DcApiClient api, object payload)
    {
        try
        {
            await api.PostJsonAsync("/api/errors/v1/report", payload).ConfigureAwait(false);
        }
        catch (DcApiException ex) when (ex.IsPermanent)
        {
            lock (Sync) { _tokenRejected = true; }
            // Debug level on purpose: a warning here would be logged, land in the sink and
            // come straight back as the next report.
            Log.Debug("Deployment Center error stream rejected the token ({Code}); reporting disabled.", ex.Code);
        }
        catch (Exception ex)
        {
            Log.Debug(ex, "Deployment Center error report failed");
        }
    }

    /// <summary>Local budget: the server counts duplicates itself, we only avoid burning the rate limit.</summary>
    private static bool PassesRateLimit(string exceptionType, string message)
    {
        var now = DateTime.UtcNow;

        if (now - _windowStartUtc > TimeSpan.FromMinutes(1))
        {
            _windowStartUtc = now;
            _sentInWindow = 0;
        }
        if (_sentInWindow >= MaxReportsPerMinute) return false;

        var signature = exceptionType + "|" + Truncate(message, 200);
        if (RecentSignatures.TryGetValue(signature, out var last) && now - last < RepeatSuppression)
        {
            return false;
        }

        if (RecentSignatures.Count > 500) RecentSignatures.Clear();
        RecentSignatures[signature] = now;
        _sentInWindow++;
        return true;
    }

    /// <summary>
    /// AggregateException and TargetInvocationException say nothing about the actual defect;
    /// grouping on them would throw unrelated errors into one bucket.
    /// </summary>
    private static Exception Unwrap(Exception exception)
    {
        while (exception is AggregateException { InnerExceptions.Count: 1 } aggregate)
        {
            exception = aggregate.InnerExceptions[0];
        }
        return exception;
    }

    /// <summary>Reads file and line from the first stack frame that has debug info (PDB present).</summary>
    private static (string? File, int? Line) ResolveOrigin(Exception exception)
    {
        try
        {
            var trace = new StackTrace(exception, fNeedFileInfo: true);
            foreach (var frame in trace.GetFrames())
            {
                var file = frame.GetFileName();
                if (string.IsNullOrEmpty(file)) continue;
                var lineNo = frame.GetFileLineNumber();
                return (ToRepoRelative(file!), lineNo > 0 ? lineNo : (int?)null);
            }
        }
        catch
        {
            // Origin is a nicety, never a reason to drop the report.
        }
        return (null, null);
    }

    /// <summary>Turns C:\build\...\src\Foo\Bar.cs into src/Foo/Bar.cs so the path matches the repo.</summary>
    private static string ToRepoRelative(string path)
    {
        var normalized = path.Replace('\\', '/');
        var marker = normalized.LastIndexOf("/src/", StringComparison.OrdinalIgnoreCase);
        return marker >= 0 ? normalized[(marker + 1)..] : normalized;
    }

    private static string? Truncate(string? value, int max)
    {
        if (string.IsNullOrEmpty(value)) return value;
        return value!.Length <= max ? value : value[..max] + "…";
    }
}
