using System.Diagnostics;
using BrassAndSigil.Server.Models;

namespace BrassAndSigil.Server.Services;

/// <summary>
/// Wraps BlueMap CLI as an out-of-process renderer.
///
/// The CLI jar is downloaded from GitHub releases on first render (mirrors the
/// JavaInstaller pattern -- keeps the brass-sigil-server binary lean and lets
/// BlueMap update independently). BlueMap 5.20+ requires Java 25, so we also
/// auto-install Adoptium Temurin JRE 25 alongside the JRE 21 we use for MC.
///
/// Renders are kicked off manually from the panel and produce static HTML/JS/PNG
/// output served at /map/. Zero impact on the running MC server (separate JVM,
/// separate memory pool -- only competes for disk I/O during render).
/// </summary>
public sealed class BlueMapService : IDisposable
{
    private const string BlueMapVersion = "5.20";
    private const string BlueMapJarUrl =
        "https://github.com/BlueMap-Minecraft/BlueMap/releases/download/v"
      + BlueMapVersion + "/bluemap-" + BlueMapVersion + "-cli.jar";
    private const int BlueMapJavaVersion = 25;
    private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(10) };

    private readonly ServerConfig _config;
    private readonly Action<string> _log;
    private readonly SemaphoreSlim _gate = new(1, 1);

    private Process? _renderProc;
    public sealed class RenderState
    {
        public bool InProgress       { get; set; }
        public string Phase          { get; set; } = "idle";
            // "idle" | "extracting" | "configuring" | "rendering" | "complete" | "failed"
        public string? Error         { get; set; }
        public DateTimeOffset? StartedAt  { get; set; }
        public DateTimeOffset? FinishedAt { get; set; }
        public int? ExitCode         { get; set; }
        public string? LastLogLine   { get; set; }
    }
    public RenderState State { get; private set; } = new();

    public string RootDir
    {
        get
        {
            // Configured override (e.g. /mnt/md0p1/brass-sigil/bluemap) wins. Default
            // is sibling of serverDir so it doesn't bloat the world dir or the
            // server install -- typically ~/brass-sigil-server/bluemap.
            if (!string.IsNullOrWhiteSpace(_config.BlueMapDir))
                return Path.GetFullPath(_config.BlueMapDir);
            var serverFull = Path.GetFullPath(_config.ServerDir);
            var parent = Path.GetDirectoryName(serverFull) ?? serverFull;
            return Path.Combine(parent, "bluemap");
        }
    }
    public string CliJarPath    => Path.Combine(RootDir, "cli.jar");
    public string WebDir        => Path.Combine(RootDir, "web");
    public string ConfigDir     => Path.Combine(RootDir, "config");

    public BlueMapService(ServerConfig config, Action<string> log)
    {
        _config = config;
        _log    = log;
    }

    public bool HasRendered => Directory.Exists(WebDir) &&
                               File.Exists(Path.Combine(WebDir, "index.html"));

    /// <summary>Kick off a render in the background. Returns false if one is already running.</summary>
    public bool StartRender()
    {
        if (!_gate.Wait(0)) return false;
        State = new RenderState { InProgress = true, Phase = "extracting", StartedAt = DateTimeOffset.UtcNow };
        _ = Task.Run(RenderAsync);
        return true;
    }

    /// <summary>
    /// Cancel an in-progress render by killing the BlueMap process. State on disk
    /// is preserved, so the next render resumes from where this one stopped.
    /// </summary>
    public bool CancelRender()
    {
        if (!State.InProgress) return false;
        try
        {
            // Kill the whole process tree (the BlueMap CLI may spawn worker JVMs
            // for parallel rendering). entireProcessTree=true on Linux uses
            // process group; on Windows it walks the tree via Job Objects.
            _renderProc?.Kill(entireProcessTree: true);
            State.Phase = "cancelled";
            State.Error = "Cancelled by user.";
            _log("[bluemap] Render cancelled by user.");
            return true;
        }
        catch (Exception ex)
        {
            _log($"[bluemap] Cancel failed: {ex.Message}");
            return false;
        }
    }

    /// <summary>
    /// Delete rendered map output + render state. Used after a world wipe -- the
    /// old tiles reference terrain that no longer exists. Preserves cli.jar and
    /// configs so a follow-up Render still works (and skips re-download +
    /// re-config). Returns true if anything was deleted.
    /// </summary>
    public bool ClearRenderOutput()
    {
        var dataDir = Path.Combine(RootDir, "data");
        var mapsDir = Path.Combine(RootDir, "web", "maps");
        var anyDeleted = false;
        try
        {
            if (Directory.Exists(dataDir)) { Directory.Delete(dataDir, true); anyDeleted = true; _log("[bluemap] Cleared render state."); }
            if (Directory.Exists(mapsDir)) { Directory.Delete(mapsDir, true); anyDeleted = true; _log("[bluemap] Cleared rendered tiles."); }
        }
        catch (Exception ex) { _log($"[bluemap] Couldn't clear output: {ex.Message}"); }
        return anyDeleted;
    }

    /// <summary>
    /// Called when brass-sigil-server itself shuts down -- kill any in-flight
    /// BlueMap process so it doesn't orphan to PID 1 and keep eating CPU after
    /// the daemon's gone. Render state on disk is preserved; next start can
    /// resume the render exactly where this one was killed.
    /// </summary>
    public void Dispose()
    {
        try
        {
            if (_renderProc is { HasExited: false })
            {
                _renderProc.Kill(entireProcessTree: true);
                _renderProc.WaitForExit(2000);
            }
        }
        catch { }
        _renderProc?.Dispose();
        _gate.Dispose();
    }

    private async Task RenderAsync()
    {
        try
        {
            Directory.CreateDirectory(RootDir);

            State.Phase = "downloading";
            await DownloadJarIfMissingAsync();

            // BlueMap 5.20 needs Java 25; our MC server runs Java 21. Maintain a
            // separate JRE 25 install for BlueMap only.
            var bluemapJava = await EnsureJava25Async();
            if (bluemapJava is null) throw new InvalidOperationException("Couldn't install JRE 25 for BlueMap.");

            // First run: BlueMap with no config writes default configs and exits.
            // We need to (a) run it once to seed configs, (b) patch the world path,
            // (c) re-run with -r to actually render.
            State.Phase = "configuring";
            EnsureConfig(bluemapJava);

            State.Phase = "rendering";
            // -r = render. --mods <modsDir> = pull textures from mod jars so Create
            // blocks etc. show real colours instead of magenta/grey fallback.
            var modsDir = Path.Combine(Path.GetFullPath(_config.ServerDir), "mods");
            var args = new List<string> { "-jar", CliJarPath, "-r" };
            if (Directory.Exists(modsDir))
            {
                args.Add("--mods");
                args.Add(modsDir);
            }

            var psi = new ProcessStartInfo
            {
                FileName = bluemapJava,
                WorkingDirectory = RootDir,
                UseShellExecute = false,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                CreateNoWindow = true,
            };
            foreach (var a in args) psi.ArgumentList.Add(a);

            _renderProc = new Process { StartInfo = psi, EnableRaisingEvents = true };
            _renderProc.OutputDataReceived += (_, e) => { if (e.Data is { } line) { State.LastLogLine = line; _log($"[bluemap] {line}"); } };
            _renderProc.ErrorDataReceived  += (_, e) => { if (e.Data is { } line) { State.LastLogLine = line; _log($"[bluemap] {line}"); } };

            _renderProc.Start();
            _renderProc.BeginOutputReadLine();
            _renderProc.BeginErrorReadLine();
            await _renderProc.WaitForExitAsync();

            State.ExitCode = _renderProc.ExitCode;
            State.FinishedAt = DateTimeOffset.UtcNow;
            if (_renderProc.ExitCode == 0) State.Phase = "complete";
            else { State.Phase = "failed"; State.Error = $"BlueMap exited with code {_renderProc.ExitCode}"; }
        }
        catch (Exception ex)
        {
            State.Phase = "failed";
            State.Error = ex.Message;
            State.FinishedAt = DateTimeOffset.UtcNow;
            _log($"[bluemap] Failed: {ex.Message}");
        }
        finally
        {
            State.InProgress = false;
            _renderProc?.Dispose();
            _renderProc = null;
            _gate.Release();
        }
    }

    private async Task DownloadJarIfMissingAsync()
    {
        if (File.Exists(CliJarPath)) return;
        _log($"[bluemap] Downloading BlueMap CLI v{BlueMapVersion} to {CliJarPath}");
        Directory.CreateDirectory(Path.GetDirectoryName(CliJarPath)!);
        using var resp = await _http.GetAsync(BlueMapJarUrl, HttpCompletionOption.ResponseHeadersRead);
        resp.EnsureSuccessStatusCode();
        await using var src = await resp.Content.ReadAsStreamAsync();
        await using var dst = File.Create(CliJarPath);
        await src.CopyToAsync(dst);
        _log($"[bluemap] Downloaded {new FileInfo(CliJarPath).Length / 1024} KB.");
    }

    /// <summary>Reuse JavaInstaller to drop a JRE 25 next to JRE 21 (separate dirs).</summary>
    private async Task<string?> EnsureJava25Async()
    {
        var installer = new JavaInstaller();
        var serverFull = Path.GetFullPath(_config.ServerDir);
        // Look for an existing JRE 25 install first (idempotent across renders).
        var existing = installer.FindBundledJava(serverFull, BlueMapJavaVersion);
        if (existing is not null) return existing;
        var installDir = installer.GetJavaInstallDir(serverFull, BlueMapJavaVersion);
        var progress = new Progress<string>(msg => _log("[bluemap] " + msg));
        return await installer.InstallJreAsync(BlueMapJavaVersion, serverFull, installDir, progress, default);
    }

    /// <summary>
    /// Run BlueMap with no flags so it writes default configs (bluemap.conf,
    /// maps/overworld.conf, etc.), then patch the overworld map's world path
    /// to point at our serverDir. Idempotent -- only writes configs that don't
    /// exist; existing user edits survive.
    /// </summary>
    private void EnsureConfig(string javaPath)
    {
        Directory.CreateDirectory(ConfigDir);
        var bluemapConf = Path.Combine(ConfigDir, "core.conf");
        var seeded = File.Exists(bluemapConf);
        if (!seeded)
        {
            _log("[bluemap] First run -- generating default configs.");
            var psi = new ProcessStartInfo
            {
                FileName = javaPath,
                WorkingDirectory = RootDir,
                UseShellExecute = false,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                CreateNoWindow = true,
            };
            psi.ArgumentList.Add("-jar");
            psi.ArgumentList.Add(CliJarPath);
            using var p = Process.Start(psi)!;
            p.WaitForExit(60_000);
        }

        // Patch the overworld map config to reference our world dir.
        var serverDirAbs = Path.GetFullPath(_config.ServerDir);
        var levelName = ReadLevelName(_config.ServerDir) ?? "world";
        var worldDir = Path.Combine(serverDirAbs, levelName).Replace('\\', '/');

        var mapsDir = Path.Combine(ConfigDir, "maps");
        Directory.CreateDirectory(mapsDir);
        var owConf = Path.Combine(mapsDir, "overworld.conf");
        if (!File.Exists(owConf) || !File.ReadAllText(owConf).Contains(worldDir))
        {
            File.WriteAllText(owConf, $@"# Generated by brass-sigil-server. Edit at your own risk.
world: ""{worldDir}""
dimension: ""minecraft:overworld""
name: ""Brass and Sigil -- Overworld""
sorting: 0
sky-color: ""#7dabff""
ambient-light: 0
");
            _log("[bluemap] Wrote map config: maps/overworld.conf");
        }

        // Tell core.conf to accept that we read its license/disclaimer (otherwise CLI exits with a notice).
        if (File.Exists(bluemapConf))
        {
            var text = File.ReadAllText(bluemapConf);
            if (!text.Contains("accept-download: true"))
            {
                text = text.Replace("accept-download: false", "accept-download: true");
                if (!text.Contains("accept-download:"))
                    text += "\naccept-download: true\n";
                File.WriteAllText(bluemapConf, text);
                _log("[bluemap] Set accept-download: true in core.conf");
            }
        }
    }

    private static string? ReadLevelName(string serverDir)
    {
        var path = Path.Combine(serverDir, "server.properties");
        if (!File.Exists(path)) return null;
        foreach (var line in File.ReadAllLines(path))
        {
            if (line.StartsWith("level-name=", StringComparison.Ordinal))
                return line.Substring("level-name=".Length).Trim();
        }
        return null;
    }
}
