using System.IO.Compression;
using BrassAndSigil.Server.Models;

namespace BrassAndSigil.Server.Services;

/// <summary>
/// World backups: ZIP the level-name dir into a separate (typically slower-but-bigger)
/// backup directory. Online backups via /save-all flush + /save-off while the server is
/// running mean players don't see downtime -- just a brief save lag.
/// </summary>
public sealed class BackupService
{
    private readonly ServerConfig _config;
    private readonly ServerProcess _proc;
    private readonly Broadcaster _broadcast;
    private readonly Action<string> _log;
    private readonly SemaphoreSlim _gate = new(1, 1);

    public BackupService(ServerConfig config, ServerProcess proc, Broadcaster broadcast, Action<string> log)
    {
        _config    = config;
        _proc      = proc;
        _broadcast = broadcast;
        _log       = log;
    }

    public sealed record BackupInfo(string Name, long SizeBytes, DateTimeOffset CreatedAt);
    public sealed record CreateResult(bool Ok, string? Name, long SizeBytes, string? Error);

    public string BackupDir => ResolveBackupDir();

    public List<BackupInfo> List()
    {
        var dir = BackupDir;
        if (!Directory.Exists(dir)) return new();
        return Directory.EnumerateFiles(dir, "*.zip")
            .Select(p =>
            {
                var fi = new FileInfo(p);
                return new BackupInfo(fi.Name, fi.Length, new DateTimeOffset(fi.CreationTimeUtc, TimeSpan.Zero));
            })
            .OrderByDescending(b => b.CreatedAt)
            .ToList();
    }

    /// <summary>
    /// Create a ZIP backup of the world dir. Online (no shutdown) when the server is
    /// running.
    /// <para>
    /// <paramref name="flush"/> = false (default): just <c>save-off</c> + brief drain +
    /// ZIP + <c>save-on</c>. Near-zero player-visible lag. Backup captures state up to
    /// MC's last autosave (within ~5 min) -- fine for hourly snapshots.
    /// </para>
    /// <para>
    /// <paramref name="flush"/> = true: also runs <c>save-all flush</c> first, which
    /// synchronously serialises every loaded chunk before the ZIP. Captures state up
    /// to NOW. Causes a tick spike of seconds-to-tens-of-seconds depending on world
    /// size. Used only for irreversible operations (pre-wipe) where freshness matters.
    /// </para>
    /// </summary>
    public async Task<CreateResult> CreateAsync(string? reason = null, bool flush = false, CancellationToken ct = default)
    {
        if (!await _gate.WaitAsync(0, ct))
            return new CreateResult(false, null, 0, "Another backup is already in progress.");

        var dir = ResolveBackupDir();
        try
        {
            Directory.CreateDirectory(dir);
        }
        catch (Exception ex)
        {
            _gate.Release();
            return new CreateResult(false, null, 0, $"Couldn't create backup dir '{dir}': {ex.Message}");
        }

        var levelName = ReadLevelName(_config.ServerDir) ?? "world";
        var worldDir = Path.Combine(_config.ServerDir, levelName);
        if (!Directory.Exists(worldDir))
        {
            _gate.Release();
            return new CreateResult(false, null, 0, $"World directory not found at '{worldDir}'.");
        }

        var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
        var slug = string.IsNullOrWhiteSpace(reason) ? "" : "-" + Slugify(reason);
        var name = $"{levelName}-{stamp}{slug}.zip";
        var path = Path.Combine(dir, name);

        var serverRunning = _proc.IsRunning;
        try
        {
            if (serverRunning)
            {
                if (flush)
                {
                    // Loud path: fresh state, but pays the tick spike. Tell players.
                    try { await _broadcast.SayAsync("Saving world for backup (brief lag possible)...", ct); } catch { }
                    _log("[backup] save-all flush");
                    await _proc.SendInputAsync("save-all flush", ct);
                    await Task.Delay(2500, ct);
                }
                _log("[backup] save-off");
                await _proc.SendInputAsync("save-off", ct);
                // Brief drain so any save tasks already enqueued can finish before we
                // start reading from disk for the ZIP.
                await Task.Delay(500, ct);
            }

            _log($"[backup] Archiving {worldDir} -> {name}");
            // Run on a worker thread so the request thread doesn't block on disk I/O.
            await Task.Run(() =>
                ZipFile.CreateFromDirectory(worldDir, path, CompressionLevel.Fastest, includeBaseDirectory: false), ct);

            var size = new FileInfo(path).Length;
            _log($"[backup] Created {name} ({size / (1024 * 1024)} MB).");
            // No completion broadcast for silent path -- backup was invisible to players,
            // no need to tell them it finished. Loud path is wipe-only and the wipe
            // sequence has its own messaging.

            RotateOldest(dir, _config.BackupKeep);

            return new CreateResult(true, name, size, null);
        }
        catch (Exception ex)
        {
            _log($"[backup] Failed: {ex.Message}");
            try { File.Delete(path); } catch { } // partial archive
            return new CreateResult(false, null, 0, ex.Message);
        }
        finally
        {
            try
            {
                if (serverRunning && _proc.IsRunning)
                {
                    _log("[backup] save-on");
                    await _proc.SendInputAsync("save-on");
                }
            }
            catch { }
            _gate.Release();
        }
    }

    /// <summary>
    /// Stop the server, move the current world out of the way as a "pre-restore" safety
    /// copy, extract the chosen archive, restart.
    /// </summary>
    public async Task<(bool Ok, string? Error)> RestoreAsync(string backupName, CancellationToken ct = default)
    {
        if (!await _gate.WaitAsync(0, ct))
            return (false, "Another backup operation is already in progress.");

        try
        {
            var dir = ResolveBackupDir();
            var path = Path.Combine(dir, Path.GetFileName(backupName));
            if (!File.Exists(path)) return (false, $"Backup '{backupName}' not found.");

            var levelName = ReadLevelName(_config.ServerDir) ?? "world";
            var worldDir = Path.Combine(_config.ServerDir, levelName);

            if (_proc.IsRunning)
            {
                _log("[restore] Stopping server before restore...");
                await _proc.StopAsync(TimeSpan.FromSeconds(30), ct);
            }

            // Always preserve the current world as a pre-restore snapshot in case the
            // chosen archive is corrupt or wrong.
            if (Directory.Exists(worldDir))
            {
                var preRestore = $"{worldDir}-prerestore-{DateTime.UtcNow:yyyyMMddHHmmss}";
                _log($"[restore] Moving current world to {Path.GetFileName(preRestore)}");
                Directory.Move(worldDir, preRestore);
            }

            _log($"[restore] Extracting {backupName}");
            Directory.CreateDirectory(worldDir);
            await Task.Run(() => ZipFile.ExtractToDirectory(path, worldDir, overwriteFiles: true), ct);

            _log("[restore] Starting server.");
            _proc.Start();
            return (true, null);
        }
        catch (Exception ex)
        {
            _log($"[restore] Failed: {ex.Message}");
            try { if (!_proc.IsRunning) _proc.Start(); } catch { }
            return (false, ex.Message);
        }
        finally
        {
            _gate.Release();
        }
    }

    public (bool Ok, string? Error) Delete(string backupName)
    {
        try
        {
            var path = Path.Combine(ResolveBackupDir(), Path.GetFileName(backupName));
            if (!File.Exists(path)) return (false, $"Backup '{backupName}' not found.");
            File.Delete(path);
            return (true, null);
        }
        catch (Exception ex) { return (false, ex.Message); }
    }

    private string ResolveBackupDir()
    {
        if (!string.IsNullOrWhiteSpace(_config.BackupDir))
            return Path.GetFullPath(_config.BackupDir);
        // Default: sibling of serverDir so it survives server-dir wipes.
        var serverFull = Path.GetFullPath(_config.ServerDir);
        var parent = Path.GetDirectoryName(serverFull) ?? serverFull;
        return Path.Combine(parent, "backups");
    }

    private static void RotateOldest(string dir, int keep)
    {
        if (keep <= 0) return;
        try
        {
            var zips = Directory.EnumerateFiles(dir, "*.zip")
                .Select(p => new FileInfo(p))
                .OrderByDescending(fi => fi.CreationTimeUtc)
                .ToList();
            foreach (var old in zips.Skip(keep))
            {
                try { old.Delete(); }
                catch { /* best-effort */ }
            }
        }
        catch { }
    }

    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;
    }

    private static string Slugify(string s)
    {
        var chars = s.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_').ToArray();
        var slug = new string(chars).ToLowerInvariant();
        return slug.Length > 32 ? slug.Substring(0, 32) : slug;
    }
}
