using Metin2Launcher.Apply;
using Metin2Launcher.Config;
using Metin2Launcher.Formats;
using Metin2Launcher.Logging;
using Metin2Launcher.Manifest;
using Metin2Launcher.Runtime;
using Metin2Launcher.Telemetry;
using Metin2Launcher.Transfer;

namespace Metin2Launcher.Orchestration;

public enum LauncherState
{
    Idle,
    FetchingManifest,
    VerifyingSignature,
    Diffing,
    Downloading,
    Applying,
    UpToDate,
    OfflineFallback,
    SignatureFailed,
    Launching,
}

public sealed class UpdateProgress
{
    public LauncherState State { get; init; }
    public double? Percent { get; init; }      // 0..100, null = indeterminate
    public string? Detail { get; init; }       // current file / target path
    public LoadedManifest? Manifest { get; init; }
}

/// <summary>
/// Owns the update flow extracted from the original CLI Program.cs. Drives an
/// <see cref="IProgress{UpdateProgress}"/> sink instead of writing to the console.
/// All logic — manifest fetch, signature verify, diff, download, apply — is preserved.
/// </summary>
public sealed class UpdateOrchestrator
{
    public sealed record Result(
        bool Success,
        LauncherState FinalState,
        LoadedManifest? Manifest,
        IReleaseFormat? Format = null,
        RuntimeKey? RuntimeKey = null);

    private readonly string _clientRoot;
    private readonly string _stagingDir;
    private readonly LauncherSettings _settings;
    private readonly HttpClient _http;
    private readonly ClientAppliedReporter? _reporter;

    public UpdateOrchestrator(
        string clientRoot,
        LauncherSettings settings,
        HttpClient http,
        ClientAppliedReporter? reporter = null)
    {
        _clientRoot = clientRoot;
        _stagingDir = Path.Combine(clientRoot, LauncherConfig.StateDirName, LauncherConfig.StagingDirName);
        _settings = settings;
        _http = http;
        _reporter = reporter;
    }

    public async Task<Result> RunAsync(IProgress<UpdateProgress> progress, CancellationToken ct)
    {
        LoadedManifest? loaded = null;

        // 1. fetch manifest
        progress.Report(new UpdateProgress { State = LauncherState.FetchingManifest });
        var loader = new ManifestLoader(_http);
        try
        {
            using var fetchCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
            fetchCts.CancelAfter(LauncherConfig.ManifestFetchTimeout);
            loaded = await loader.FetchAsync(
                _settings.EffectiveManifestUrl(),
                _settings.EffectiveSignatureUrl(),
                fetchCts.Token).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            Log.Warn("manifest fetch failed: " + ex.Message);
            progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100 });
            return new Result(false, LauncherState.OfflineFallback, null);
        }

        // 2. verify signature
        progress.Report(new UpdateProgress { State = LauncherState.VerifyingSignature, Manifest = loaded });
        if (!SignatureVerifier.Verify(loaded.RawBytes, loaded.Signature, LauncherConfig.PublicKeyHex))
        {
            Log.Error("manifest signature verification FAILED — refusing to launch");
            progress.Report(new UpdateProgress { State = LauncherState.SignatureFailed, Percent = 0, Manifest = loaded });
            throw new ManifestSignatureException(
                "Ed25519 signature over manifest.json did not verify against the launcher's public key");
        }

        var manifest = loaded.Manifest;
        Log.Info($"manifest {manifest.Version} verified, format={manifest.EffectiveFormat}, {manifest.Files.Count} files");

        // 2b. dispatch on release format
        IReleaseFormat format;
        try
        {
            format = ReleaseFormatFactory.Resolve(manifest.EffectiveFormat);
        }
        catch (NotSupportedException ex)
        {
            Log.Error("unsupported release format, falling back offline: " + ex.Message);
            progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
            return new Result(false, LauncherState.OfflineFallback, loaded);
        }

        // 3. diff
        progress.Report(new UpdateProgress { State = LauncherState.Diffing, Manifest = loaded });

        // The client is always a Windows PE binary launched through Wine on
        // Linux hosts, so the manifest "platform" we apply is always "windows"
        // regardless of host OS. Using host OS would drop Metin2.exe and the
        // python314 / openssl / mingw runtime DLLs whenever the launcher runs
        // on Linux, leaving an unbootable install dir.
        const string platform = "windows";
        var applicable = format.FilterApplicable(manifest, platform);

        var needed = new List<(ManifestFile File, string FinalPath, string StagingPath)>();
        long totalBytes = 0;
        foreach (var file in applicable)
        {
            ct.ThrowIfCancellationRequested();
            string finalPath, stagingPath;
            try
            {
                finalPath = PathSafety.ResolveInside(_clientRoot, file.Path);
                stagingPath = PathSafety.ResolveInside(_stagingDir, file.Path);
            }
            catch (UnsafePathException ex)
            {
                if (file.IsRequired)
                {
                    Log.Error($"required file rejected by path safety: {ex.Message}");
                    progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
                    return new Result(false, LauncherState.OfflineFallback, loaded, format);
                }
                Log.Warn($"optional file rejected by path safety: {ex.Message}");
                continue;
            }

            var localHash = FileHasher.HashFileOrNull(finalPath);
            if (localHash == file.Sha256)
                continue;

            needed.Add((file, finalPath, stagingPath));
            totalBytes += file.Size;
        }

        if (needed.Count == 0)
        {
            Log.Info("client already up to date");
            var currentManifestPath = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "current-manifest.json");
            try { File.WriteAllBytes(currentManifestPath, loaded.RawBytes); } catch { }

            // Even on no-op we still need to re-load the runtime key so the
            // game process can receive it on this launch.
            var noopOutcome = new ReleaseOutcome();
            try { format.OnApplied(_clientRoot, loaded, noopOutcome); }
            catch (Exception ex) { Log.Warn("format OnApplied (no-op path) failed: " + ex.Message); }

            PruneStaleFiles(manifest);

            progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded });
            return new Result(true, LauncherState.UpToDate, loaded, format, noopOutcome.RuntimeKey);
        }

        // 4. download
        progress.Report(new UpdateProgress { State = LauncherState.Downloading, Percent = 0, Manifest = loaded });

        var downloader = new BlobDownloader(_http, _settings.EffectiveBlobBaseUrl(), LauncherConfig.MaxBlobRetries);
        var toApply = new List<AtomicApplier.StagedFile>();

        long completedBytes = 0;
        int idx = 0;
        foreach (var (file, finalPath, stagingPath) in needed)
        {
            ct.ThrowIfCancellationRequested();
            idx++;

            long lastReported = 0;
            var perFileProgress = new Progress<long>(b =>
            {
                var delta = b - lastReported;
                lastReported = b;
                var current = Interlocked.Add(ref completedBytes, delta);
                var pct = totalBytes > 0 ? (double)current / totalBytes * 100.0 : 0;
                progress.Report(new UpdateProgress
                {
                    State = LauncherState.Downloading,
                    Percent = Math.Min(100, pct),
                    Detail = $"{file.Path} ({idx}/{needed.Count})",
                    Manifest = loaded,
                });
            });

            try
            {
                await downloader.DownloadAsync(file.Sha256, file.Size, stagingPath, ct, perFileProgress).ConfigureAwait(false);
                toApply.Add(new AtomicApplier.StagedFile(stagingPath, finalPath));
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                if (file.IsRequired)
                {
                    Log.Error($"required file {file.Path} failed to download", ex);
                    progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
                    return new Result(false, LauncherState.OfflineFallback, loaded, format);
                }
                Log.Warn($"optional file {file.Path} skipped: {ex.Message}");
                // make sure the bytes from this file don't leave the bar stuck low
                Interlocked.Add(ref completedBytes, file.Size - lastReported);
            }
        }

        // 5. apply
        progress.Report(new UpdateProgress { State = LauncherState.Applying, Percent = 0, Manifest = loaded });
        try
        {
            int applied = 0;
            // AtomicApplier doesn't expose progress today; report begin and end.
            AtomicApplier.Apply(toApply);
            applied = toApply.Count;
            progress.Report(new UpdateProgress { State = LauncherState.Applying, Percent = 100, Detail = $"{applied} files", Manifest = loaded });
        }
        catch (Exception ex)
        {
            Log.Error("apply failed", ex);
            progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
            return new Result(false, LauncherState.OfflineFallback, loaded, format);
        }

        var current = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "current-manifest.json");
        try { File.WriteAllBytes(current, loaded.RawBytes); } catch { }

        // 6. format-specific post-apply hook (e.g. load the m2pack runtime key)
        var outcome = new ReleaseOutcome();
        try
        {
            format.OnApplied(_clientRoot, loaded, outcome);
        }
        catch (Exception ex)
        {
            Log.Error("format OnApplied hook failed", ex);
            progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
            return new Result(false, LauncherState.OfflineFallback, loaded, format);
        }

        // 7. opt-in telemetry ping (bounded, best-effort, never blocks)
        if (_reporter is not null && _reporter.IsEnabled)
        {
            try
            {
                await _reporter.ReportAsync(format.Name, manifest.Version, ct).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                Log.Warn("telemetry reporter threw: " + ex.Message);
            }
        }

        PruneStaleFiles(manifest);

        Log.Info($"update complete: {toApply.Count} files applied (format={format.Name})");
        progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded });
        return new Result(true, LauncherState.UpToDate, loaded, format, outcome.RuntimeKey);
    }

    // Delete files that were installed by a previous manifest but are no
    // longer in the current one. Called after both the normal apply path
    // and the already-up-to-date short-circuit so switching from a fat
    // release to a lean one doesn't leave orphaned files on disk.
    //
    // IMPORTANT SAFETY CONTRACT (see the post-mortem in commit message for
    // why this matters): prune is STRICTLY scoped to files listed in a
    // previous manifest that the launcher itself applied. It is NEVER a
    // recursive walk of clientRoot. The earlier version of this method did
    // a recursive walk and would have happily deleted a user's home
    // directory if they ran the launcher from ~/ instead of a dedicated
    // install dir. Fixed by switching to a content-addressed ledger:
    //
    //   .updates/applied-files.txt  — newline list of relative paths this
    //                                 launcher has ever written into
    //                                 clientRoot.
    //
    // On prune: read the ledger, subtract the current manifest's file set
    // (plus manifest.launcher.path), delete each leftover. Then rewrite
    // the ledger to contain exactly the current manifest's file set.
    //
    // Files we never wrote are never considered, so there is no blast
    // radius beyond what the launcher itself created.
    private void PruneStaleFiles(Manifest.Manifest manifest)
    {
        var keepRel = new HashSet<string>(
            manifest.Files.Select(f => f.Path.Replace('/', Path.DirectorySeparatorChar)),
            StringComparer.OrdinalIgnoreCase);
        if (manifest.Launcher?.Path is { Length: > 0 } launcherPath)
        {
            keepRel.Add(launcherPath.Replace('/', Path.DirectorySeparatorChar));
        }

        var ledgerPath = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "applied-files.txt");

        var previouslyApplied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        try
        {
            if (File.Exists(ledgerPath))
            {
                foreach (var line in File.ReadAllLines(ledgerPath))
                {
                    var trimmed = line.Trim();
                    if (trimmed.Length == 0) continue;
                    previouslyApplied.Add(trimmed.Replace('/', Path.DirectorySeparatorChar));
                }
            }
        }
        catch (Exception ex)
        {
            Log.Warn($"failed to read applied-files ledger at {ledgerPath}: {ex.Message}");
            previouslyApplied.Clear();
        }

        int pruned = 0;
        if (previouslyApplied.Count > 0)
        {
            foreach (var rel in previouslyApplied)
            {
                if (keepRel.Contains(rel)) continue;
                // Defense in depth: refuse to touch anything that escapes
                // clientRoot via symlink or path traversal. PathSafety rejects
                // any rel that resolves outside clientRoot.
                string absolute;
                try { absolute = PathSafety.ResolveInside(_clientRoot, rel.Replace(Path.DirectorySeparatorChar, '/')); }
                catch (UnsafePathException ex)
                {
                    Log.Warn($"prune refusing unsafe path {rel}: {ex.Message}");
                    continue;
                }
                if (!File.Exists(absolute)) continue;
                try { File.Delete(absolute); pruned++; }
                catch (Exception ex) { Log.Warn($"failed to prune {rel}: {ex.Message}"); }
            }
        }
        if (pruned > 0) Log.Info($"pruned {pruned} stale files not in manifest");

        // Rewrite ledger to match the current manifest. Launcher path is
        // included so a future run doesn't try to delete the updater.
        try
        {
            var stateDir = Path.Combine(_clientRoot, LauncherConfig.StateDirName);
            Directory.CreateDirectory(stateDir);
            var lines = keepRel
                .Select(r => r.Replace(Path.DirectorySeparatorChar, '/'))
                .OrderBy(s => s, StringComparer.OrdinalIgnoreCase);
            File.WriteAllLines(ledgerPath, lines);
        }
        catch (Exception ex)
        {
            Log.Warn($"failed to write applied-files ledger at {ledgerPath}: {ex.Message}");
        }
    }
}
