using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;

namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;

public sealed class OracleCatalogLoader
{
    public const string CachePrefix = "StellaOps.Excititor.Connectors.Oracle.CSAF.Catalog";

    private readonly IHttpClientFactory _httpClientFactory;
    private readonly IMemoryCache _memoryCache;
    private readonly IFileSystem _fileSystem;
    private readonly ILogger<OracleCatalogLoader> _logger;
    private readonly TimeProvider _timeProvider;
    private readonly SemaphoreSlim _semaphore = new(1, 1);
    private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);

    public OracleCatalogLoader(
        IHttpClientFactory httpClientFactory,
        IMemoryCache memoryCache,
        IFileSystem fileSystem,
        ILogger<OracleCatalogLoader> logger,
        TimeProvider? timeProvider = null)
    {
        _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
        _memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
        _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _timeProvider = timeProvider ?? TimeProvider.System;
    }

    public async Task<OracleCatalogResult> LoadAsync(OracleConnectorOptions options, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(options);
        options.Validate(_fileSystem);

        var cacheKey = CreateCacheKey(options);
        if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out var cached) && cached is not null && !cached.IsExpired(_timeProvider.GetUtcNow()))
        {
            return cached.ToResult(fromCache: true);
        }

        await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out cached) && cached is not null && !cached.IsExpired(_timeProvider.GetUtcNow()))
            {
                return cached.ToResult(fromCache: true);
            }

            CacheEntry? entry = null;
            if (options.PreferOfflineSnapshot)
            {
                entry = LoadFromOffline(options);
                if (entry is null)
                {
                    throw new InvalidOperationException("PreferOfflineSnapshot is enabled but no offline snapshot was found or could be loaded.");
                }
            }
            else
            {
                entry = await TryFetchFromNetworkAsync(options, cancellationToken).ConfigureAwait(false)
                    ?? LoadFromOffline(options);
            }

            if (entry is null)
            {
                throw new InvalidOperationException("Unable to load Oracle CSAF catalog from network or offline snapshot.");
            }

            var expiration = entry.MetadataCacheDuration == TimeSpan.Zero
                ? (DateTimeOffset?)null
                : _timeProvider.GetUtcNow().Add(entry.MetadataCacheDuration);

            var cacheEntryOptions = new MemoryCacheEntryOptions();
            if (expiration.HasValue)
            {
                cacheEntryOptions.AbsoluteExpiration = expiration.Value;
            }

            _memoryCache.Set(cacheKey, entry with { CachedAt = _timeProvider.GetUtcNow() }, cacheEntryOptions);
            return entry.ToResult(fromCache: false);
        }
        finally
        {
            _semaphore.Release();
        }
    }

    private async Task<CacheEntry?> TryFetchFromNetworkAsync(OracleConnectorOptions options, CancellationToken cancellationToken)
    {
        try
        {
            var client = _httpClientFactory.CreateClient(OracleConnectorOptions.HttpClientName);
            using var response = await client.GetAsync(options.CatalogUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
            response.EnsureSuccessStatusCode();
            var catalogPayload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);

            string? calendarPayload = null;
            if (options.CpuCalendarUri is not null)
            {
                try
                {
                    using var calendarResponse = await client.GetAsync(options.CpuCalendarUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
                    calendarResponse.EnsureSuccessStatusCode();
                    calendarPayload = await calendarResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
                }
                catch (Exception ex) when (ex is not OperationCanceledException)
                {
                    _logger.LogWarning(ex, "Failed to fetch Oracle CPU calendar from {Uri}; continuing without schedule.", options.CpuCalendarUri);
                }
            }

            var metadata = ParseMetadata(catalogPayload, calendarPayload);
            var fetchedAt = _timeProvider.GetUtcNow();
            var entry = new CacheEntry(metadata, fetchedAt, fetchedAt, options.MetadataCacheDuration, false);

            PersistSnapshotIfNeeded(options, metadata, fetchedAt);
            return entry;
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            _logger.LogWarning(ex, "Failed to fetch Oracle CSAF catalog from {Uri}; attempting offline fallback if available.", options.CatalogUri);
            return null;
        }
    }

    private CacheEntry? LoadFromOffline(OracleConnectorOptions options)
    {
        if (string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
        {
            return null;
        }

        if (!_fileSystem.File.Exists(options.OfflineSnapshotPath))
        {
            _logger.LogWarning("Oracle offline snapshot path {Path} does not exist.", options.OfflineSnapshotPath);
            return null;
        }

        try
        {
            var payload = _fileSystem.File.ReadAllText(options.OfflineSnapshotPath);
            var snapshot = JsonSerializer.Deserialize<OracleCatalogSnapshot>(payload, _serializerOptions);
            if (snapshot is null)
            {
                throw new InvalidOperationException("Offline snapshot payload was empty.");
            }

            return new CacheEntry(snapshot.Metadata, snapshot.FetchedAt, _timeProvider.GetUtcNow(), options.MetadataCacheDuration, true);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to load Oracle CSAF catalog from offline snapshot {Path}.", options.OfflineSnapshotPath);
            return null;
        }
    }

    private OracleCatalogMetadata ParseMetadata(string catalogPayload, string? calendarPayload)
    {
        if (string.IsNullOrWhiteSpace(catalogPayload))
        {
            throw new InvalidOperationException("Oracle catalog payload was empty.");
        }

        using var document = JsonDocument.Parse(catalogPayload);
        var root = document.RootElement;

        var generatedAt = root.TryGetProperty("generated", out var generatedElement) && generatedElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(generatedElement.GetString(), out var generated)
            ? generated
            : _timeProvider.GetUtcNow();

        var entries = ParseEntries(root);
        var schedule = ParseSchedule(root);

        if (!string.IsNullOrWhiteSpace(calendarPayload))
        {
            schedule = MergeSchedule(schedule, calendarPayload);
        }

        return new OracleCatalogMetadata(generatedAt, entries, schedule);
    }

    private ImmutableArray<OracleCatalogEntry> ParseEntries(JsonElement root)
    {
        if (!root.TryGetProperty("catalog", out var catalogElement) || catalogElement.ValueKind is not JsonValueKind.Array)
        {
            return ImmutableArray<OracleCatalogEntry>.Empty;
        }

        var builder = ImmutableArray.CreateBuilder<OracleCatalogEntry>();
        foreach (var entry in catalogElement.EnumerateArray())
        {
            var id = entry.TryGetProperty("id", out var idElement) && idElement.ValueKind == JsonValueKind.String ? idElement.GetString() : null;
            var title = entry.TryGetProperty("title", out var titleElement) && titleElement.ValueKind == JsonValueKind.String ? titleElement.GetString() : null;
            if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(title))
            {
                continue;
            }

            DateTimeOffset publishedAt = default;
            if (entry.TryGetProperty("published", out var publishedElement) && publishedElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(publishedElement.GetString(), out var publishedParsed))
            {
                publishedAt = publishedParsed;
            }

            string? revision = null;
            if (entry.TryGetProperty("revision", out var revisionElement) && revisionElement.ValueKind == JsonValueKind.String)
            {
                revision = revisionElement.GetString();
            }

            ImmutableArray<string> products = ImmutableArray<string>.Empty;
            if (entry.TryGetProperty("products", out var productsElement))
            {
                products = ParseStringArray(productsElement);
            }

            Uri? documentUri = null;
            string? sha256 = null;
            long? size = null;

            if (entry.TryGetProperty("document", out var documentElement) && documentElement.ValueKind == JsonValueKind.Object)
            {
                if (documentElement.TryGetProperty("url", out var urlElement) && urlElement.ValueKind == JsonValueKind.String && Uri.TryCreate(urlElement.GetString(), UriKind.Absolute, out var parsedUri))
                {
                    documentUri = parsedUri;
                }

                if (documentElement.TryGetProperty("sha256", out var hashElement) && hashElement.ValueKind == JsonValueKind.String)
                {
                    sha256 = hashElement.GetString();
                }

                if (documentElement.TryGetProperty("size", out var sizeElement) && sizeElement.ValueKind == JsonValueKind.Number && sizeElement.TryGetInt64(out var parsedSize))
                {
                    size = parsedSize;
                }
            }

            if (documentUri is null)
            {
                continue;
            }

            builder.Add(new OracleCatalogEntry(id!, title!, documentUri, publishedAt, revision, sha256, size, products));
        }

        return builder.ToImmutable();
    }

    private ImmutableArray<OracleCpuRelease> ParseSchedule(JsonElement root)
    {
        if (!root.TryGetProperty("schedule", out var scheduleElement) || scheduleElement.ValueKind is not JsonValueKind.Array)
        {
            return ImmutableArray<OracleCpuRelease>.Empty;
        }

        var builder = ImmutableArray.CreateBuilder<OracleCpuRelease>();
        foreach (var item in scheduleElement.EnumerateArray())
        {
            var window = item.TryGetProperty("window", out var windowElement) && windowElement.ValueKind == JsonValueKind.String ? windowElement.GetString() : null;
            if (string.IsNullOrWhiteSpace(window))
            {
                continue;
            }

            DateTimeOffset releaseDate = default;
            if (item.TryGetProperty("releaseDate", out var releaseElement) && releaseElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(releaseElement.GetString(), out var parsed))
            {
                releaseDate = parsed;
            }

            builder.Add(new OracleCpuRelease(window!, releaseDate));
        }

        return builder.ToImmutable();
    }

    private ImmutableArray<OracleCpuRelease> MergeSchedule(ImmutableArray<OracleCpuRelease> existing, string calendarPayload)
    {
        try
        {
            using var document = JsonDocument.Parse(calendarPayload);
            var root = document.RootElement;
            if (!root.TryGetProperty("cpuWindows", out var windowsElement) || windowsElement.ValueKind is not JsonValueKind.Array)
            {
                return existing;
            }

            var builder = existing.ToBuilder();
            var known = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            foreach (var item in builder)
            {
                known.Add(item.Window);
            }

            foreach (var windowElement in windowsElement.EnumerateArray())
            {
                var name = windowElement.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String ? nameElement.GetString() : null;
                if (string.IsNullOrWhiteSpace(name))
                {
                    continue;
                }

                if (!known.Add(name))
                {
                    continue;
                }

                DateTimeOffset releaseDate = default;
                if (windowElement.TryGetProperty("releaseDate", out var releaseElement) && releaseElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(releaseElement.GetString(), out var parsed))
                {
                    releaseDate = parsed;
                }

                builder.Add(new OracleCpuRelease(name!, releaseDate));
            }

            return builder.ToImmutable();
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            _logger.LogWarning(ex, "Failed to parse Oracle CPU calendar payload; continuing with existing schedule data.");
            return existing;
        }
    }

    private ImmutableArray<string> ParseStringArray(JsonElement element)
    {
        if (element.ValueKind is not JsonValueKind.Array)
        {
            return ImmutableArray<string>.Empty;
        }

        var builder = ImmutableArray.CreateBuilder<string>();
        foreach (var item in element.EnumerateArray())
        {
            if (item.ValueKind == JsonValueKind.String)
            {
                var value = item.GetString();
                if (!string.IsNullOrWhiteSpace(value))
                {
                    builder.Add(value);
                }
            }
        }

        return builder.ToImmutable();
    }

    private void PersistSnapshotIfNeeded(OracleConnectorOptions options, OracleCatalogMetadata metadata, DateTimeOffset fetchedAt)
    {
        if (!options.PersistOfflineSnapshot || string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
        {
            return;
        }

        try
        {
            var snapshot = new OracleCatalogSnapshot(metadata, fetchedAt);
            var payload = JsonSerializer.Serialize(snapshot, _serializerOptions);
            _fileSystem.File.WriteAllText(options.OfflineSnapshotPath, payload);
            _logger.LogDebug("Persisted Oracle CSAF catalog snapshot to {Path}.", options.OfflineSnapshotPath);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to persist Oracle CSAF catalog snapshot to {Path}.", options.OfflineSnapshotPath);
        }
    }

    private static string CreateCacheKey(OracleConnectorOptions options)
        => $"{CachePrefix}:{options.CatalogUri}:{options.CpuCalendarUri}";

    private sealed record CacheEntry(OracleCatalogMetadata Metadata, DateTimeOffset FetchedAt, DateTimeOffset CachedAt, TimeSpan MetadataCacheDuration, bool FromOfflineSnapshot)
    {
        public bool IsExpired(DateTimeOffset now)
            => MetadataCacheDuration > TimeSpan.Zero && now >= CachedAt + MetadataCacheDuration;

        public OracleCatalogResult ToResult(bool fromCache)
            => new(Metadata, FetchedAt, fromCache, FromOfflineSnapshot);
    }

    private sealed record OracleCatalogSnapshot(OracleCatalogMetadata Metadata, DateTimeOffset FetchedAt);
}

public sealed record OracleCatalogMetadata(
    DateTimeOffset GeneratedAt,
    ImmutableArray<OracleCatalogEntry> Entries,
    ImmutableArray<OracleCpuRelease> CpuSchedule);

public sealed record OracleCatalogEntry(
    string Id,
    string Title,
    Uri DocumentUri,
    DateTimeOffset PublishedAt,
    string? Revision,
    string? Sha256,
    long? Size,
    ImmutableArray<string> Products);

public sealed record OracleCpuRelease(string Window, DateTimeOffset ReleaseDate);

public sealed record OracleCatalogResult(
    OracleCatalogMetadata Metadata,
    DateTimeOffset FetchedAt,
    bool FromCache,
    bool FromOfflineSnapshot);
