using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using StellaOps.Feedser.Models;

namespace StellaOps.Feedser.Exporter.Json;

/// <summary>
/// Writes canonical advisory snapshots into a vuln-list style directory tree with deterministic ordering.
/// </summary>
public sealed class JsonExportSnapshotBuilder
{
    private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
    private readonly JsonExportOptions _options;
    private readonly IJsonExportPathResolver _pathResolver;

    public JsonExportSnapshotBuilder(JsonExportOptions options, IJsonExportPathResolver pathResolver)
    {
        _options = options ?? throw new ArgumentNullException(nameof(options));
        _pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
    }

    public Task<JsonExportResult> WriteAsync(
        IReadOnlyCollection<Advisory> advisories,
        DateTimeOffset exportedAt,
        string? exportName = null,
        CancellationToken cancellationToken = default)
    {
        if (advisories is null)
        {
            throw new ArgumentNullException(nameof(advisories));
        }

        return WriteAsync(EnumerateAsync(advisories, cancellationToken), exportedAt, exportName, cancellationToken);
    }

    public async Task<JsonExportResult> WriteAsync(
        IAsyncEnumerable<Advisory> advisories,
        DateTimeOffset exportedAt,
        string? exportName = null,
        CancellationToken cancellationToken = default)
    {
        if (advisories is null)
        {
            throw new ArgumentNullException(nameof(advisories));
        }

        var exportDirectoryName = exportName ?? exportedAt.UtcDateTime.ToString(_options.DirectoryNameFormat, CultureInfo.InvariantCulture);
        if (string.IsNullOrWhiteSpace(exportDirectoryName))
        {
            throw new InvalidOperationException("Export directory name resolved to an empty string.");
        }

        var exportRoot = EnsureDirectoryExists(Path.GetFullPath(_options.OutputRoot));
        TrySetDirectoryTimestamp(exportRoot, exportedAt);
        var exportDirectory = Path.Combine(exportRoot, exportDirectoryName);

        if (Directory.Exists(exportDirectory))
        {
            Directory.Delete(exportDirectory, recursive: true);
        }

        Directory.CreateDirectory(exportDirectory);
        TrySetDirectoryTimestamp(exportDirectory, exportedAt);

        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var files = new List<JsonExportFile>();
        long totalBytes = 0L;
        var advisoryCount = 0;

        await foreach (var advisory in advisories.WithCancellation(cancellationToken))
        {
            cancellationToken.ThrowIfCancellationRequested();

            advisoryCount++;
            var entry = Resolve(advisory);
            if (!seen.Add(entry.RelativePath))
            {
                throw new InvalidOperationException($"Multiple advisories resolved to the same path '{entry.RelativePath}'.");
            }

            var destination = Combine(exportDirectory, entry.Segments);
            var destinationDirectory = Path.GetDirectoryName(destination);
            if (!string.IsNullOrEmpty(destinationDirectory))
            {
                EnsureDirectoryExists(destinationDirectory);
                TrySetDirectoryTimestamp(destinationDirectory, exportedAt);
            }
            var payload = SnapshotSerializer.ToSnapshot(entry.Advisory);
            var bytes = Utf8NoBom.GetBytes(payload);

            await File.WriteAllBytesAsync(destination, bytes, cancellationToken).ConfigureAwait(false);
            File.SetLastWriteTimeUtc(destination, exportedAt.UtcDateTime);

            var digest = ComputeDigest(bytes);
            files.Add(new JsonExportFile(entry.RelativePath, bytes.LongLength, digest));
            totalBytes += bytes.LongLength;
        }

        files.Sort(static (left, right) => string.CompareOrdinal(left.RelativePath, right.RelativePath));

        return new JsonExportResult(exportDirectory, exportedAt, files, advisoryCount, totalBytes);
    }

    private static async IAsyncEnumerable<Advisory> EnumerateAsync(
        IEnumerable<Advisory> advisories,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        foreach (var advisory in advisories)
        {
            cancellationToken.ThrowIfCancellationRequested();
            yield return advisory;
            await Task.Yield();
        }
    }

    private static string EnsureDirectoryExists(string directory)
    {
        if (string.IsNullOrWhiteSpace(directory))
        {
            throw new ArgumentException("Directory path must be provided.", nameof(directory));
        }

        Directory.CreateDirectory(directory);
        return directory;
    }

    private static string Combine(string root, IReadOnlyList<string> segments)
    {
        var parts = new string[segments.Count + 1];
        parts[0] = root;
        for (var i = 0; i < segments.Count; i++)
        {
            parts[i + 1] = segments[i];
        }

        return Path.Combine(parts);
    }

    private static void TrySetDirectoryTimestamp(string directory, DateTimeOffset timestamp)
    {
        try
        {
            Directory.SetLastWriteTimeUtc(directory, timestamp.UtcDateTime);
        }
        catch (IOException)
        {
            // Ignore failure to set timestamps; not critical for content determinism.
        }
        catch (UnauthorizedAccessException)
        {
            // Ignore permission issues when setting timestamps.
        }
        catch (PlatformNotSupportedException)
        {
            // Some platforms may not support this operation.
        }
    }

    private PathResolution Resolve(Advisory advisory)
    {
        if (advisory is null)
        {
            throw new ArgumentNullException(nameof(advisory));
        }

        var relativePath = _pathResolver.GetRelativePath(advisory);
        var segments = NormalizeRelativePath(relativePath);
        var normalized = string.Join('/', segments);
        return new PathResolution(advisory, normalized, segments);
    }

    private static string[] NormalizeRelativePath(string relativePath)
    {
        if (string.IsNullOrWhiteSpace(relativePath))
        {
            throw new InvalidOperationException("Path resolver returned an empty path.");
        }

        if (Path.IsPathRooted(relativePath))
        {
            throw new InvalidOperationException("Path resolver returned an absolute path; only relative paths are supported.");
        }

        var pieces = relativePath.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
        if (pieces.Length == 0)
        {
            throw new InvalidOperationException("Path resolver produced no path segments.");
        }

        var sanitized = new string[pieces.Length];
        for (var i = 0; i < pieces.Length; i++)
        {
            var segment = pieces[i];
            if (segment == "." || segment == "..")
            {
                throw new InvalidOperationException("Relative paths cannot include '.' or '..' segments.");
            }

            sanitized[i] = SanitizeSegment(segment);
        }

        return sanitized;
    }

    private static string SanitizeSegment(string segment)
    {
        var invalid = Path.GetInvalidFileNameChars();
        Span<char> buffer = stackalloc char[segment.Length];
        var count = 0;
        foreach (var ch in segment)
        {
            if (ch == '/' || ch == '\\' || Array.IndexOf(invalid, ch) >= 0)
            {
                buffer[count++] = '_';
            }
            else
            {
                buffer[count++] = ch;
            }
        }

        var sanitized = new string(buffer[..count]).Trim();
        return string.IsNullOrEmpty(sanitized) ? "_" : sanitized;
    }

    private sealed record PathResolution(Advisory Advisory, string RelativePath, IReadOnlyList<string> Segments);

    private static string ComputeDigest(ReadOnlySpan<byte> payload)
    {
        var hash = SHA256.HashData(payload);
        var hex = Convert.ToHexString(hash).ToLowerInvariant();
        return $"sha256:{hex}";
    }
}
