using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using BrassAndSigil.Server.Models;

namespace BrassAndSigil.Server.Services;

/// <summary>
/// Server-side mod sync. Downloads only mods that the server needs:
/// queries Modrinth's project metadata for each mod's `server_side` field
/// and skips anything marked "unsupported" (Iris, Sodium, JEI, etc).
/// CurseForge mods can't be auto-classified without an API key, so they
/// are downloaded as-is and the server admin can manually delete unwanted ones.
/// </summary>
public sealed class ManifestSync
{
    private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(5) };
    private const string PackVersionFile = "pack-version.json";
    private const string ServerManifestCache = "server-pack.cache.json";

    public sealed record SyncResult(int Downloaded, int Removed, int Skipped, string PackVersion);

    public async Task<Manifest> FetchManifestAsync(string url, CancellationToken ct = default)
    {
        var json = await _http.GetStringAsync(url, ct);
        var manifest = JsonSerializer.Deserialize<Manifest>(json, JsonOpts.CaseInsensitive)
            ?? throw new InvalidOperationException("Manifest is empty.");
        manifest.Files ??= new();
        return manifest;
    }

    public async Task<SyncResult> SyncAsync(
        string manifestUrl, string serverDir, IProgress<string>? progress = null, CancellationToken ct = default)
    {
        progress?.Report("Fetching manifest...");
        var manifest = await FetchManifestAsync(manifestUrl, ct);

        progress?.Report($"Pack: {manifest.Name} v{manifest.Version}");
        Directory.CreateDirectory(serverDir);

        // Build-time tagging is authoritative: anything marked "client" is dropped
        // outright, anything marked "server" or "both" is kept. We only fall back
        // to the runtime Modrinth lookup for files with NO explicit side -- this
        // keeps un-tagged (or CurseForge-only) mods safe and matches pre-side
        // behaviour for them.
        var needsLookup = manifest.Files.Where(f => string.IsNullOrEmpty(f.Side)).ToList();
        var skipSlugs = needsLookup.Count > 0
            ? await ResolveServerSideSkipListAsync(
                new Manifest { Files = needsLookup }, ct)
            : new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        // Build the filtered list of files to keep on the server.
        var keepFiles = manifest.Files
            .Where(f => !IsClientOnly(f) && (!string.IsNullOrEmpty(f.Side) || !ShouldSkipFile(f.Path, skipSlugs)))
            .ToList();
        var skippedCount = manifest.Files.Count - keepFiles.Count;

        // Prune managed files that aren't in the keep set.
        var wantedPaths = new HashSet<string>(
            keepFiles.Select(f => f.Path.Replace('\\', '/')),
            StringComparer.OrdinalIgnoreCase);
        var toRemove = ListManagedFiles(serverDir).Where(p => !wantedPaths.Contains(p)).ToList();
        foreach (var rel in toRemove)
        {
            var full = Path.Combine(serverDir, rel);
            try { File.Delete(full); progress?.Report($"  Removed: {rel}"); }
            catch (Exception ex) { progress?.Report($"  Could not remove {rel}: {ex.Message}"); }
        }

        // Download missing or hash-mismatched files.
        var toDownload = new List<ManifestFile>();
        foreach (var file in keepFiles)
        {
            ct.ThrowIfCancellationRequested();
            var dest = Path.Combine(serverDir, file.Path);
            if (!File.Exists(dest)) { toDownload.Add(file); continue; }
            if (!string.IsNullOrEmpty(file.Sha1))
            {
                var actual = await ComputeSha1Async(dest, ct);
                if (!string.Equals(actual, file.Sha1, StringComparison.OrdinalIgnoreCase))
                    toDownload.Add(file);
            }
        }

        progress?.Report(toDownload.Count == 0 ? "Already up-to-date." : $"Downloading {toDownload.Count} files...");
        for (int i = 0; i < toDownload.Count; i++)
        {
            var file = toDownload[i];
            progress?.Report($"  [{i + 1}/{toDownload.Count}] {file.Path}");
            await DownloadFileAsync(file.Url, Path.Combine(serverDir, file.Path), file.Sha1, ct);
        }

        // Write pack-version.json marker.
        var record = new
        {
            name = manifest.Name,
            version = manifest.Version,
            syncedAt = DateTime.UtcNow.ToString("o"),
            includedFiles = keepFiles.Count,
            skippedFiles = skippedCount
        };
        await File.WriteAllTextAsync(
            Path.Combine(serverDir, PackVersionFile),
            JsonSerializer.Serialize(record, new JsonSerializerOptions { WriteIndented = true }),
            ct);

        return new SyncResult(toDownload.Count, toRemove.Count, skippedCount, manifest.Version ?? "?");
    }

    private static bool ShouldSkipFile(string filePath, HashSet<string> skipSlugs)
    {
        if (skipSlugs.Count == 0) return false;
        var name = Path.GetFileNameWithoutExtension(filePath).ToLowerInvariant();
        // Match if any skip slug appears at the start of the filename (slug-version.jar)
        return skipSlugs.Any(slug => name.StartsWith(slug + "-", StringComparison.OrdinalIgnoreCase)
                                  || name.Equals(slug, StringComparison.OrdinalIgnoreCase));
    }

    private static bool IsClientOnly(ManifestFile f)
        => string.Equals(f.Side, "client", StringComparison.OrdinalIgnoreCase);

    /// <summary>Walk the manifest's mod URLs; for Modrinth ones, look up server_side; build a skip set.</summary>
    private async Task<HashSet<string>> ResolveServerSideSkipListAsync(Manifest manifest, CancellationToken ct)
    {
        var skip = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var modrinthIds = new HashSet<string>();

        foreach (var file in manifest.Files)
        {
            if (!file.Path.StartsWith("mods/", StringComparison.OrdinalIgnoreCase)) continue;
            // Modrinth URL pattern: https://cdn.modrinth.com/data/{projectId}/versions/{versionId}/...
            var url = file.Url;
            const string prefix = "cdn.modrinth.com/data/";
            var idx = url.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
            if (idx < 0) continue;
            var rest = url.Substring(idx + prefix.Length);
            var slash = rest.IndexOf('/');
            if (slash < 0) continue;
            modrinthIds.Add(rest.Substring(0, slash));
        }

        foreach (var pid in modrinthIds)
        {
            ct.ThrowIfCancellationRequested();
            try
            {
                var info = await _http.GetFromJsonAsync<JsonElement>(
                    $"https://api.modrinth.com/v2/project/{pid}", ct);
                var slug = info.TryGetProperty("slug", out var s) ? s.GetString() : null;
                var serverSide = info.TryGetProperty("server_side", out var ss) ? ss.GetString() : null;
                if (!string.IsNullOrEmpty(slug) && string.Equals(serverSide, "unsupported", StringComparison.OrdinalIgnoreCase))
                {
                    skip.Add(slug);
                }
            }
            catch
            {
                // Best-effort: if we can't classify, keep the mod (safer to ship extra than missing)
            }
        }

        return skip;
    }

    private static List<string> ListManagedFiles(string serverDir)
    {
        var roots = new[] { "mods", "config", "resourcepacks", "kubejs", "defaultconfigs" };
        var result = new List<string>();
        foreach (var root in roots)
        {
            var rootDir = Path.Combine(serverDir, root);
            if (!Directory.Exists(rootDir)) continue;
            foreach (var f in Directory.EnumerateFiles(rootDir, "*", SearchOption.AllDirectories))
                result.Add(Path.GetRelativePath(serverDir, f).Replace('\\', '/'));
        }
        return result;
    }

    private static async Task DownloadFileAsync(string url, string destPath, string? expectedSha1, CancellationToken ct)
    {
        Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
        var tmp = destPath + ".part";
        using (var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct))
        {
            resp.EnsureSuccessStatusCode();
            await using var src = await resp.Content.ReadAsStreamAsync(ct);
            await using var dst = File.Create(tmp);
            await src.CopyToAsync(dst, ct);
        }
        if (!string.IsNullOrEmpty(expectedSha1))
        {
            var actual = await ComputeSha1Async(tmp, ct);
            if (!string.Equals(actual, expectedSha1, StringComparison.OrdinalIgnoreCase))
            {
                File.Delete(tmp);
                throw new InvalidOperationException($"Hash mismatch for {Path.GetFileName(destPath)}");
            }
        }
        if (File.Exists(destPath)) File.Delete(destPath);
        File.Move(tmp, destPath);
    }

    private static async Task<string> ComputeSha1Async(string path, CancellationToken ct)
    {
        using var sha = SHA1.Create();
        await using var stream = File.OpenRead(path);
        var bytes = await sha.ComputeHashAsync(stream, ct);
        return Convert.ToHexString(bytes).ToLowerInvariant();
    }
}
