using System.Text.Json;

namespace Dada.Supervisor;

internal sealed record StructuredLogEvent(
    string StatusCategory,
    string? CorrelationId = null,
    string? ObjectId = null,
    long? DurationMs = null,
    string? ErrorCategory = null);

internal static class RedactionPolicy
{
    private static readonly HashSet<string> StatusCategories =
        ["starting", "ready", "completed", "failed", "unavailable", "degraded", "blocked", "stopping", "stopped"];
    private static readonly HashSet<string> ErrorCategories =
        ["none", "invalid_input", "storage_unavailable", "log_write_failed", "service_unavailable", "timeout", "internal_error"];

    internal static string StatusCategory(string value) => StatusCategories.Contains(value) ? value : "failed";
    internal static string ErrorCategory(string value) => ErrorCategories.Contains(value) ? value : "internal_error";
    internal static string Code(string value, string fallback) =>
        value.Length is > 0 and <= 64 && value[0] is >= 'a' and <= 'z' && value.All(character => character is >= 'a' and <= 'z' or >= '0' and <= '9' or '_')
            ? value
            : fallback;
    internal static string? Identifier(string? value) =>
        value is { Length: > 0 and <= 64 } && value.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-')
            ? value
            : null;
}

internal sealed class StructuredJsonlLogger
{
    internal const long SizeLimitBytes = 10 * 1024 * 1024;
    internal const int FileLimit = 10;
    internal const int RetentionDays = 30;

    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
    };

    private readonly string directory;
    private readonly string component;
    private readonly long sizeLimitBytes;
    private readonly int fileLimit;
    private readonly Func<DateTimeOffset> now;
    private readonly Action onWriteFailure;
    private static readonly System.Text.Encoding Utf8WithoutBom = new System.Text.UTF8Encoding(false);
    private bool initialized;
    private long currentSize;

    internal StructuredJsonlLogger(
        string directory,
        string component,
        Action? onWriteFailure = null,
        Func<DateTimeOffset>? now = null,
        long sizeLimitBytes = SizeLimitBytes,
        int fileLimit = FileLimit)
    {
        this.directory = directory;
        this.component = component is "api" or "worker" or "supervisor" ? component : "supervisor";
        this.onWriteFailure = onWriteFailure ?? (() => { });
        this.now = now ?? (() => DateTimeOffset.UtcNow);
        this.sizeLimitBytes = sizeLimitBytes;
        this.fileLimit = fileLimit;
    }

    internal void Write(StructuredLogEvent input)
    {
        try
        {
            Initialize();
            var entry = new Dictionary<string, object?>
            {
                ["schema_version"] = "1.0",
                ["timestamp"] = now().ToString("O"),
                ["component"] = component,
                ["status_category"] = RedactionPolicy.StatusCategory(input.StatusCategory),
            };
            var correlationId = RedactionPolicy.Identifier(input.CorrelationId);
            var objectId = RedactionPolicy.Identifier(input.ObjectId);
            if (correlationId is not null) entry["correlation_id"] = correlationId;
            if (objectId is not null) entry["object_id"] = objectId;
            if (input.DurationMs is >= 0) entry["duration_ms"] = input.DurationMs;
            if (input.ErrorCategory is not null) entry["error_category"] = RedactionPolicy.ErrorCategory(input.ErrorCategory);
            var line = JsonSerializer.Serialize(entry, JsonOptions) + Environment.NewLine;
            var bytes = System.Text.Encoding.UTF8.GetByteCount(line);
            if (bytes > sizeLimitBytes) throw new InvalidOperationException("log_entry_too_large");
            if (currentSize > 0 && currentSize + bytes > sizeLimitBytes) Rotate();
            File.AppendAllText(ActivePath(), line, Utf8WithoutBom);
            currentSize += bytes;
        }
        catch (Exception exception) when (exception is not LogWriteException)
        {
            try { onWriteFailure(); } catch { }
            throw new LogWriteException(exception);
        }
    }

    internal void Maintain()
    {
        try
        {
            Directory.CreateDirectory(directory);
            var cutoff = now().AddDays(-RetentionDays);
            foreach (var file in Files())
            {
                if (file.Index > fileLimit - 1 || file.Info.LastWriteTimeUtc < cutoff.UtcDateTime) file.Info.Delete();
            }
            foreach (var file in Files().Skip(fileLimit)) file.Info.Delete();
            currentSize = File.Exists(ActivePath()) ? new FileInfo(ActivePath()).Length : 0;
            initialized = true;
        }
        catch (Exception exception) when (exception is not LogWriteException)
        {
            try { onWriteFailure(); } catch { }
            throw new LogWriteException(exception);
        }
    }

    private void Initialize()
    {
        if (!initialized) Maintain();
    }

    private void Rotate()
    {
        File.Delete(Path.Combine(directory, $"{component}.{fileLimit - 1}.jsonl"));
        for (var index = fileLimit - 2; index >= 1; index--)
        {
            var source = Path.Combine(directory, $"{component}.{index}.jsonl");
            if (File.Exists(source)) File.Move(source, Path.Combine(directory, $"{component}.{index + 1}.jsonl"));
        }
        if (File.Exists(ActivePath())) File.Move(ActivePath(), Path.Combine(directory, $"{component}.1.jsonl"));
        currentSize = 0;
    }

    private IReadOnlyList<(int Index, FileInfo Info)> Files()
    {
        if (!Directory.Exists(directory)) return [];
        var prefix = component + ".";
        return Directory.EnumerateFiles(directory, $"{component}*.jsonl")
            .Select(path =>
            {
                var name = Path.GetFileName(path);
                var index = name == $"{component}.jsonl"
                    ? 0
                    : int.TryParse(name[prefix.Length..^".jsonl".Length], out var parsed) ? parsed : int.MaxValue;
                return (Index: index, Info: new FileInfo(path));
            })
            .OrderBy(file => file.Index)
            .ToArray();
    }

    private string ActivePath() => Path.Combine(directory, $"{component}.jsonl");
}

internal sealed class LogWriteException(Exception innerException) : IOException("log_write_failed", innerException);
