using System.Collections.Concurrent;
using System.Diagnostics;
using System.Drawing;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Windows.Forms;

internal static class Program
{
    [STAThread]
    public static int Main(string[] args)
    {
        return RunAsync(args).GetAwaiter().GetResult();
    }

    private static async Task<int> RunAsync(string[] args)
    {
        var options = BridgeOptions.Parse(args);
        var app = new DownloadBridgeApp(options);

        using var mutex = SingleInstance.TryAcquire(app, options);
        if (!mutex.Acquired)
        {
            app.Log("already running mutex=locked");
            return 0;
        }

        using var stop = new CancellationTokenSource();
        Task? serverTask = null;
        Console.CancelKeyPress += (_, eventArgs) =>
        {
            eventArgs.Cancel = true;
            stop.Cancel();
        };

        try
        {
            serverTask = app.RunAsync(stop.Token);
            if (OperatingSystem.IsWindows() && !options.NoTray)
            {
                ApplicationConfiguration.Initialize();
                using var tray = new BridgeTrayContext(app, stop);
                _ = serverTask.ContinueWith(task =>
                {
                    if (!stop.IsCancellationRequested && task.IsFaulted)
                    {
                        app.Log($"server stopped error={task.Exception?.GetBaseException().Message}");
                    }

                    if (!stop.IsCancellationRequested)
                    {
                        stop.Cancel();
                        Application.Exit();
                    }
                }, TaskScheduler.Default);
                Application.Run(tray);
                stop.Cancel();
            }

            await serverTask;
            return 0;
        }
        catch (OperationCanceledException)
        {
            app.Log("stop requested");
            return 0;
        }
        catch (Exception error)
        {
            app.Log($"fatal error={error}");
            return 1;
        }
        finally
        {
            if (serverTask is { IsCompleted: false })
            {
                stop.Cancel();
            }
        }
    }
}

internal sealed class DownloadBridgeApp
{
    private const string DefaultHost = "127.0.0.1";
    private const int DefaultPort = 17661;
    private const long MinRangeSize = 8L * 1024 * 1024;
    private const int ChunkSize = 4 * 1024 * 1024;
    private const int MaxConnections = 8;
    private const int MaxRetries = 3;
    private const int RequestTimeoutSeconds = 30;
    private const int JobHistoryLimit = 100;

    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        WriteIndented = false,
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
    };

    private static readonly JsonSerializerOptions PersistedJsonOptions = new()
    {
        WriteIndented = true,
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
    };

    private static readonly HashSet<string> RunningProgressKeys = new(StringComparer.Ordinal)
    {
        "status",
        "downloaded",
        "total",
        "partsDone",
        "partsTotal"
    };

    private static readonly HttpClient Http = new(new SocketsHttpHandler
    {
        AutomaticDecompression = DecompressionMethods.None,
        AllowAutoRedirect = true,
        UseCookies = false,
        MaxConnectionsPerServer = MaxConnections + 4
    })
    {
        Timeout = Timeout.InfiniteTimeSpan
    };

    private readonly object _logLock = new();
    private readonly object _jobsLock = new();
    private readonly object _saveLock = new();
    private readonly ConcurrentDictionary<string, JobControl> _controls = new(StringComparer.Ordinal);
    private readonly SemaphoreSlim _downloadSlots = new(4, 4);
    private readonly string _root;
    private readonly string _host;
    private readonly int _port;
    private readonly string _userDataDir;
    private readonly string _logPath;
    private readonly string _jobsPath;
    private readonly string _downloadDir;
    private readonly Dictionary<string, JsonObject> _jobs = new(StringComparer.Ordinal);

    public DownloadBridgeApp(BridgeOptions options)
    {
        _root = Path.GetFullPath(options.Root);
        _host = options.Host;
        _port = options.Port;
        _userDataDir = Path.Combine(_root, "UserData");
        _logPath = Path.Combine(_userDataDir, "download_bridge.log");
        _jobsPath = Path.Combine(_userDataDir, "download_jobs.json");
        _downloadDir = ResolveDefaultDownloadDir();
    }

    public string DownloadDir => _downloadDir;

    public string Root => _root;

    public string LogPath => _logPath;

    public string StatusText
    {
        get
        {
            var jobs = ListJobs();
            var running = jobs.Count(job => GetString(job, "status") is "running" or "queued" or "retrying" or "pausing" or "cancelling");
            return running == 0 ? "실행 중, 활성 작업 없음" : $"실행 중, 활성 작업 {running}개";
        }
    }

    public async Task RunAsync(CancellationToken stopToken)
    {
        Directory.CreateDirectory(_downloadDir);
        Directory.CreateDirectory(_userDataDir);
        MigrateUserDataFile("download_bridge.log");
        MigrateUserDataFile("download_jobs.json");
        LoadJobs();

        var server = new LocalHttpServer(_host, _port, this);
        Log($"start host={_host} port={_port} dir={_downloadDir} runtime=csharp");
        await server.RunAsync(stopToken);
    }

    public static async Task<bool> IsHealthyAsync(string host = DefaultHost, int port = DefaultPort)
    {
        try
        {
            using var probe = new TcpClient();
            var connectTask = probe.ConnectAsync(IPAddress.Parse(host), port);
            if (await Task.WhenAny(connectTask, Task.Delay(TimeSpan.FromMilliseconds(500))) != connectTask)
            {
                return false;
            }

            await connectTask;
            using var client = new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromMilliseconds(500) })
            {
                Timeout = TimeSpan.FromSeconds(1)
            };
            using var response = await client.GetAsync($"http://{host}:{port}/health");
            if (!response.IsSuccessStatusCode)
            {
                return false;
            }

            var text = await response.Content.ReadAsStringAsync();
            using var document = JsonDocument.Parse(text);
            return document.RootElement.TryGetProperty("ok", out var ok) && ok.ValueKind == JsonValueKind.True;
        }
        catch
        {
            return false;
        }
    }

    public async Task HandleAsync(LocalHttpRequest request, LocalHttpResponse response)
    {
        try
        {
            if (request.Method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase))
            {
                await response.SendJsonAsync(new JsonObject(), 204);
                return;
            }

            if (request.Method.Equals("GET", StringComparison.OrdinalIgnoreCase))
            {
                await HandleGetAsync(request, response);
                return;
            }

            if (request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase))
            {
                await HandlePostAsync(request, response);
                return;
            }

            await response.SendJsonAsync(new { ok = false, error = "method not allowed" }, 405);
        }
        catch (Exception error)
        {
            Log($"request error={error}");
            await response.SendJsonAsync(new { ok = false, error = error.Message }, 500);
        }
    }

    public void Log(string message)
    {
        var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {message}{Environment.NewLine}";
        lock (_logLock)
        {
            Directory.CreateDirectory(_userDataDir);
            File.AppendAllText(_logPath, line, Encoding.UTF8);
        }
    }

    private void MigrateUserDataFile(string fileName)
    {
        var oldPath = Path.Combine(_root, fileName);
        var newPath = Path.Combine(_userDataDir, fileName);
        if (!File.Exists(oldPath))
        {
            return;
        }

        try
        {
            Directory.CreateDirectory(_userDataDir);
            if (!File.Exists(newPath))
            {
                File.Move(oldPath, newPath);
            }
            else
            {
                File.Delete(oldPath);
            }
        }
        catch (Exception error)
        {
            Log($"migrate user data failed file={fileName} error={error.Message}");
        }
    }

    private async Task HandleGetAsync(LocalHttpRequest request, LocalHttpResponse response)
    {
        if (request.Path == "/health")
        {
            await response.SendJsonAsync(new { ok = true, name = "ONE Download Bridge" });
            return;
        }

        if (request.Path == "/config")
        {
            await response.SendJsonAsync(new { ok = true, defaultDownloadDir = _downloadDir });
            return;
        }

        if (request.Path == "/status")
        {
            await response.SendJsonAsync(new { ok = true, jobs = ListJobs(HasQueryFlag(request.Target, "includeDone")) });
            return;
        }

        await response.SendJsonAsync(new { ok = false, error = "not found" }, 404);
    }

    private async Task HandlePostAsync(LocalHttpRequest request, LocalHttpResponse response)
    {
        if (request.Path == "/control")
        {
            await HandleControlAsync(request, response);
            return;
        }

        if (request.Path != "/download")
        {
            await response.SendJsonAsync(new { ok = false, error = "not found" }, 404);
            return;
        }

        try
        {
            var payload = JsonNode.Parse(request.Body.Length == 0 ? "{}" : Encoding.UTF8.GetString(request.Body)) as JsonObject
                ?? new JsonObject();
            var jobId = SubmitDownload(payload);
            await response.SendJsonAsync(new { ok = true, jobId });
        }
        catch (Exception error)
        {
            Log($"post error={error}");
            await response.SendJsonAsync(new { ok = false, error = error.Message }, 500);
        }
    }

    private async Task HandleControlAsync(LocalHttpRequest request, LocalHttpResponse response)
    {
        try
        {
            var payload = JsonNode.Parse(request.Body.Length == 0 ? "{}" : Encoding.UTF8.GetString(request.Body)) as JsonObject
                ?? new JsonObject();
            var result = ControlJob(GetString(payload, "action"), GetString(payload, "jobId"));
            await response.SendJsonAsync(result, GetBool(result, "ok") ? 200 : 400);
        }
        catch (Exception error)
        {
            Log($"control error={error}");
            await response.SendJsonAsync(new { ok = false, error = error.Message }, 500);
        }
    }

    private void LoadJobs()
    {
        if (!File.Exists(_jobsPath))
        {
            return;
        }

        try
        {
            var root = JsonNode.Parse(File.ReadAllText(_jobsPath, Encoding.UTF8)) as JsonObject;
            if (root?["jobs"] is not JsonArray loaded)
            {
                return;
            }

            var changed = false;
            lock (_jobsLock)
            {
                foreach (var node in loaded.TakeLast(JobHistoryLimit))
                {
                    if (node is JsonObject job && !string.IsNullOrWhiteSpace(GetString(job, "id")))
                    {
                        _jobs[GetString(job, "id")] = CloneObject(job);
                    }
                    else
                    {
                        changed = true;
                    }
                }
            }

            if (changed)
            {
                SaveJobs();
            }
        }
        catch (Exception error)
        {
            Log($"load jobs failed error={error.Message}");
        }
    }

    private void SaveJobs()
    {
        lock (_saveLock)
        {
            try
            {
                var items = SnapshotPersistedJobs();
                var root = new JsonObject { ["jobs"] = new JsonArray(items.Select<JsonObject, JsonNode?>(item => item).ToArray()) };
                var tempPath = _jobsPath + ".tmp";
                Directory.CreateDirectory(_userDataDir);
                File.WriteAllText(tempPath, root.ToJsonString(PersistedJsonOptions), Encoding.UTF8);
                MoveReplace(tempPath, _jobsPath);
            }
            catch (Exception error)
            {
                Log($"save jobs failed error={error.Message}");
            }
        }
    }

    private List<JsonObject> SnapshotPersistedJobs()
    {
        lock (_jobsLock)
        {
            return _jobs.Values
                .Select(ToPersistedJob)
                .OrderBy(job => GetDouble(job, "updatedAt"))
                .TakeLast(JobHistoryLimit)
                .ToList();
        }
    }

    private List<JsonObject> ListJobs(bool includeDone = false)
    {
        lock (_jobsLock)
        {
            return _jobs.Values
                .Where(job => includeDone || GetString(job, "status") != "done")
                .Select(ToVisibleJob)
                .OrderBy(job => IsActiveStatus(GetString(job, "status")) ? 0 : 1)
                .ThenByDescending(job => GetDouble(job, "updatedAt"))
                .ToList();
        }
    }

    private JsonObject? GetJob(string jobId)
    {
        lock (_jobsLock)
        {
            return _jobs.TryGetValue(jobId, out var job) ? CloneObject(job) : null;
        }
    }

    private void RemoveJob(string jobId)
    {
        lock (_jobsLock)
        {
            _jobs.Remove(jobId);
        }

        ClearControl(jobId);
        SaveJobs();
    }

    private void UpdateJob(string jobId, params (string Key, object? Value)[] changes)
    {
        if (string.IsNullOrWhiteSpace(jobId))
        {
            return;
        }

        var changeKeys = changes.Select(change => change.Key).ToArray();
        var now = NowSeconds();
        string previousStatus;
        var pruned = false;

        lock (_jobsLock)
        {
            if (!_jobs.TryGetValue(jobId, out var job))
            {
                job = new JsonObject
                {
                    ["id"] = jobId,
                    ["createdAt"] = NowSeconds()
                };
                _jobs[jobId] = job;
            }

            previousStatus = GetString(job, "status");
            foreach (var (key, value) in changes)
            {
                job[key] = ToNode(value);
            }

            if (changeKeys.Contains("downloaded"))
            {
                UpdateProgressMetrics(job, now);
            }

            var status = GetString(job, "status");
            if (status == "done" && GetDouble(job, "completedAt") <= 0)
            {
                job["completedAt"] = now;
            }

            job["updatedAt"] = now;

            if (_jobs.Count > JobHistoryLimit)
            {
                var oldIds = _jobs
                    .OrderBy(pair => GetDouble(pair.Value, "updatedAt"))
                    .Take(_jobs.Count - JobHistoryLimit)
                    .Select(pair => pair.Key)
                    .ToList();
                foreach (var oldId in oldIds)
                {
                    _jobs.Remove(oldId);
                    pruned = true;
                }
            }
        }

        if (ShouldSaveJob(previousStatus, changeKeys, changes, pruned))
        {
            SaveJobs();
        }
    }

    private static bool ShouldSaveJob(string previousStatus, string[] changeKeys, (string Key, object? Value)[] changes, bool pruned)
    {
        if (pruned)
        {
            return true;
        }

        if (changes.Length == 0)
        {
            return false;
        }

        var statusChange = changes.FirstOrDefault(change => change.Key == "status");
        var nextStatus = statusChange.Key is null ? previousStatus : Convert.ToString(statusChange.Value, System.Globalization.CultureInfo.InvariantCulture) ?? "";
        return !(nextStatus == "running"
            && previousStatus == "running"
            && changeKeys.All(key => RunningProgressKeys.Contains(key)));
    }

    private static void UpdateProgressMetrics(JsonObject job, double now)
    {
        var downloaded = GetDouble(job, "downloaded");
        var total = GetDouble(job, "total");
        var sampleAt = GetDouble(job, "_progressSampleAt");
        var sampleBytes = GetDouble(job, "_progressSampleBytes");
        if (sampleAt > 0 && now > sampleAt && downloaded >= sampleBytes)
        {
            var elapsed = now - sampleAt;
            var speed = elapsed > 0 ? (downloaded - sampleBytes) / elapsed : 0;
            if (speed >= 0)
            {
                job["speedBytesPerSec"] = speed;
                job["etaSeconds"] = total > downloaded && speed > 1 ? (total - downloaded) / speed : 0;
            }
        }

        job["_progressSampleAt"] = now;
        job["_progressSampleBytes"] = downloaded;
    }

    private string SubmitDownload(JsonObject payload)
    {
        var jobId = GetString(payload, "jobId");
        if (string.IsNullOrWhiteSpace(jobId))
        {
            var duplicateJobId = FindActiveDuplicateJob(payload);
            if (!string.IsNullOrWhiteSpace(duplicateJobId))
            {
                return duplicateJobId;
            }

            jobId = MakeJobId(payload);
        }

        payload["jobId"] = jobId;
        ResetControl(jobId);
        UpdateJob(
            jobId,
            ("status", "queued"),
            ("url", GetString(payload, "url")),
            ("source", GetString(payload, "source")),
            ("qualityLabel", GetString(payload, "qualityLabel")),
            ("_payload", payload));

        var workerPayload = CloneObject(payload);
        _ = Task.Run(async () =>
        {
            await _downloadSlots.WaitAsync();
            try
            {
                await DownloadWorkerAsync(workerPayload);
            }
            catch (Exception error)
            {
                Log($"worker crashed jobId={jobId} error={error}");
                UpdateJob(jobId, ("status", "failed"), ("error", error.Message));
                ClearControl(jobId);
            }
            finally
            {
                _downloadSlots.Release();
            }
        });

        return jobId;
    }

    private string FindActiveDuplicateJob(JsonObject payload)
    {
        var url = NormalizeComparableUrl(GetString(payload, "url"));
        if (string.IsNullOrWhiteSpace(url))
        {
            return "";
        }

        var requestedFilename = GetString(payload, "filename");
        var filename = string.IsNullOrWhiteSpace(requestedFilename) ? "" : SanitizeFilename(requestedFilename);
        var saveDir = ExpandPath(GetString(payload, "saveDir"));
        lock (_jobsLock)
        {
            foreach (var job in _jobs.Values)
            {
                if (!IsActiveStatus(GetString(job, "status")))
                {
                    continue;
                }

                if (NormalizeComparableUrl(GetString(job, "url")) != url)
                {
                    continue;
                }

                var jobPayload = job["_payload"] as JsonObject;
                var jobFilename = GetString(job, "filename");
                if (string.IsNullOrWhiteSpace(jobFilename) && jobPayload is not null)
                {
                    var payloadFilename = GetString(jobPayload, "filename");
                    jobFilename = string.IsNullOrWhiteSpace(payloadFilename) ? "" : SanitizeFilename(payloadFilename);
                }

                if (!string.IsNullOrWhiteSpace(filename)
                    && !string.Equals(jobFilename, filename, StringComparison.OrdinalIgnoreCase))
                {
                    continue;
                }

                var jobSaveDir = Path.GetDirectoryName(GetString(job, "file")) ?? "";
                if (string.IsNullOrWhiteSpace(jobSaveDir) && jobPayload is not null)
                {
                    jobSaveDir = ExpandPath(GetString(jobPayload, "saveDir"));
                }

                if (!string.IsNullOrWhiteSpace(saveDir)
                    && !string.Equals(jobSaveDir, saveDir, StringComparison.OrdinalIgnoreCase))
                {
                    continue;
                }

                return GetString(job, "id");
            }
        }

        return "";
    }

    private JsonObject ControlJob(string action, string jobId)
    {
        var job = GetJob(jobId);
        if (job is null)
        {
            return new JsonObject { ["ok"] = false, ["error"] = "작업을 찾지 못했습니다." };
        }

        if (action == "pause")
        {
            SetControl(jobId, pause: true);
            UpdateJob(jobId, ("status", "pausing"));
            return new JsonObject { ["ok"] = true };
        }

        if (action == "cancel")
        {
            SetControl(jobId, cancel: true);
            UpdateJob(jobId, ("status", "cancelling"));
            return new JsonObject { ["ok"] = true };
        }

        if (action is "resume" or "retry")
        {
            if (job["_payload"] is not JsonObject payload)
            {
                return new JsonObject { ["ok"] = false, ["error"] = "재시도할 작업 정보가 없습니다." };
            }

            var retryPayload = CloneObject(payload);
            retryPayload["jobId"] = jobId;
            SubmitDownload(retryPayload);
            return new JsonObject { ["ok"] = true };
        }

        if (action == "clear")
        {
            RemoveJob(jobId);
            return new JsonObject { ["ok"] = true };
        }

        if (action == "open")
        {
            return OpenJobTarget(job, folder: false);
        }

        if (action == "folder")
        {
            return OpenJobTarget(job, folder: true);
        }

        return new JsonObject { ["ok"] = false, ["error"] = "지원하지 않는 작업입니다." };
    }

    private JsonObject OpenJobTarget(JsonObject job, bool folder)
    {
        var file = GetString(job, "file");
        if (string.IsNullOrWhiteSpace(file))
        {
            file = GetString(job, "finalPath");
        }

        try
        {
            if (!folder)
            {
                if (string.IsNullOrWhiteSpace(file) || !File.Exists(file))
                {
                    return new JsonObject { ["ok"] = false, ["error"] = "파일을 찾지 못했습니다." };
                }

                Process.Start(new ProcessStartInfo(file) { UseShellExecute = true });
                return new JsonObject { ["ok"] = true };
            }

            if (!string.IsNullOrWhiteSpace(file) && File.Exists(file))
            {
                Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{file}\"") { UseShellExecute = true });
                return new JsonObject { ["ok"] = true };
            }

            var directory = !string.IsNullOrWhiteSpace(file)
                ? Path.GetDirectoryName(file)
                : GetString(job, "saveDir");
            if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
            {
                return new JsonObject { ["ok"] = false, ["error"] = "폴더를 찾지 못했습니다." };
            }

            Process.Start(new ProcessStartInfo(directory) { UseShellExecute = true });
            return new JsonObject { ["ok"] = true };
        }
        catch (Exception error)
        {
            Log($"open target failed file={file} folder={folder} error={error.Message}");
            return new JsonObject { ["ok"] = false, ["error"] = folder ? "폴더를 열지 못했습니다." : "파일을 열지 못했습니다." };
        }
    }

    private async Task DownloadWorkerAsync(JsonObject payload)
    {
        var jobId = GetString(payload, "jobId");
        EnsureControl(jobId);
        var url = GetString(payload, "url");
        if (!IsHttpUrl(url))
        {
            Log($"reject invalid url: {url}");
            UpdateJob(jobId, ("status", "failed"), ("url", url), ("error", "invalid url"));
            return;
        }

        payload["url"] = url;
        UpdateJob(
            jobId,
            ("status", "running"),
            ("url", url),
            ("source", GetString(payload, "source")),
            ("qualityLabel", GetString(payload, "qualityLabel")),
            ("_payload", payload));

        try
        {
            var headers = NormalizeHeaders(payload);
            if (IsPlaylistDownload(payload))
            {
                await DownloadPlaylistAsync(payload, headers, jobId);
                return;
            }

            var body = DecodePostBody(payload);
            var info = await ProbeAsync(payload, headers, body, jobId);
            var filename = PickFilename(payload, info.Headers);
            var finalPath = ResolveFinalPath(payload, filename);
            payload["finalPath"] = finalPath;
            payload["saveDir"] = Path.GetDirectoryName(finalPath) ?? "";

            var tempPath = Path.Combine(Path.GetDirectoryName(finalPath) ?? _downloadDir, Path.GetFileName(finalPath) + ".onepart");
            var useAccelerated = NormalizeMethod(GetString(payload, "method")) == "GET" && info.Range && info.Size >= MinRangeSize;
            var partCount = useAccelerated ? Math.Min(MaxConnections, Math.Max(1, (int)Math.Ceiling(info.Size / (double)ChunkSize))) : 0;
            UpdateJob(
                jobId,
                ("status", "running"),
                ("file", finalPath),
                ("filename", Path.GetFileName(finalPath)),
                ("total", info.Size),
                ("range", info.Range),
                ("mode", useAccelerated ? "accelerated" : "single"),
                ("connections", useAccelerated ? partCount : 1),
                ("partsTotal", useAccelerated ? Math.Max(1, (int)Math.Ceiling(info.Size / (double)ChunkSize)) : 0),
                ("partsDone", 0),
                ("downloaded", 0L),
                ("qualityLabel", GetString(payload, "qualityLabel")),
                ("_payload", payload));

            Exception? lastAttemptError = null;
            for (var attempt = 1; attempt <= MaxRetries; attempt++)
            {
                try
                {
                    TryDelete(tempPath);
                    if (useAccelerated)
                    {
                        Log($"accelerated attempt={attempt} url={url} file={finalPath}");
                        await DownloadAcceleratedAsync(payload, headers, info.Size, tempPath, jobId);
                    }
                    else
                    {
                        Log($"single attempt={attempt} url={url} file={finalPath}");
                        await DownloadSingleAsync(payload, headers, body, tempPath, jobId);
                    }

                    var expected = info.Range ? info.Size : 0;
                    if (VerifyFile(tempPath, expected))
                    {
                        MoveReplace(tempPath, finalPath);
                        Log($"done file={finalPath}");
                        UpdateJob(
                            jobId,
                            ("status", "done"),
                            ("downloaded", new FileInfo(finalPath).Length),
                            ("total", info.Size > 0 ? info.Size : new FileInfo(finalPath).Length),
                            ("error", ""));
                        ClearControl(jobId);
                        return;
                    }

                    throw new InvalidOperationException("file verification failed");
                }
                catch (DownloadPaused)
                {
                    Log($"paused url={url}");
                    UpdateJob(jobId, ("status", "paused"), ("error", ""), ("attempt", attempt));
                    TryDelete(tempPath);
                    return;
                }
                catch (DownloadCancelled)
                {
                    Log($"cancelled url={url}");
                    CleanupDownloadParts(tempPath);
                    UpdateJob(jobId, ("status", "cancelled"), ("error", ""), ("attempt", attempt));
                    ClearControl(jobId);
                    return;
                }
                catch (OperationCanceledException) when (IsCancelled(jobId))
                {
                    Log($"cancelled url={url}");
                    CleanupDownloadParts(tempPath);
                    UpdateJob(jobId, ("status", "cancelled"), ("error", ""), ("attempt", attempt));
                    ClearControl(jobId);
                    return;
                }
                catch (OperationCanceledException) when (IsPaused(jobId))
                {
                    Log($"paused url={url}");
                    UpdateJob(jobId, ("status", "paused"), ("error", ""), ("attempt", attempt));
                    TryDelete(tempPath);
                    return;
                }
                catch (Exception error)
                {
                    lastAttemptError = error;
                    var errorMessage = FormatDownloadError(error);
                    Log($"failed attempt={attempt} url={url} error={errorMessage}");
                    UpdateJob(jobId, ("status", "retrying"), ("error", errorMessage), ("attempt", attempt));
                    TryDelete(tempPath);
                    await Task.Delay(TimeSpan.FromMilliseconds(500 * attempt));
                }
            }

            var finalError = lastAttemptError is null ? "max retries exceeded" : FormatDownloadError(lastAttemptError);
            Log($"give up url={url} error={finalError}");
            UpdateJob(jobId, ("status", "failed"), ("error", finalError));
            ClearControl(jobId);
        }
        catch (DownloadPaused)
        {
            Log($"paused url={url}");
            UpdateJob(jobId, ("status", "paused"), ("error", ""));
        }
        catch (DownloadCancelled)
        {
            Log($"cancelled url={url}");
            UpdateJob(jobId, ("status", "cancelled"), ("error", ""));
            ClearControl(jobId);
        }
        catch (OperationCanceledException) when (IsCancelled(jobId))
        {
            Log($"cancelled url={url}");
            UpdateJob(jobId, ("status", "cancelled"), ("error", ""));
            ClearControl(jobId);
        }
        catch (OperationCanceledException) when (IsPaused(jobId))
        {
            Log($"paused url={url}");
            UpdateJob(jobId, ("status", "paused"), ("error", ""));
        }
        catch (Exception error)
        {
            var errorMessage = FormatDownloadError(error);
            Log($"failed url={url} error={errorMessage}");
            UpdateJob(jobId, ("status", "failed"), ("error", errorMessage));
            ClearControl(jobId);
        }
    }

    private async Task<ProbeInfo> ProbeAsync(JsonObject payload, Dictionary<string, string> headers, byte[]? body, string jobId)
    {
        var url = GetString(payload, "url");
        var method = NormalizeMethod(GetString(payload, "method"));
        var info = new ProbeInfo();
        var methods = method == "GET" ? new[] { "HEAD", method } : new[] { method };

        foreach (var probeMethod in methods)
        {
            var probeHeaders = new Dictionary<string, string>(headers, StringComparer.OrdinalIgnoreCase);
            var probeData = probeMethod is "GET" or "HEAD" ? null : body;
            if (probeMethod == "GET")
            {
                probeHeaders["Range"] = "bytes=0-0";
            }

            try
            {
                CheckControl(jobId);
                using var response = await SendRequestAsync(url, probeMethod, probeHeaders, probeData, GetCancelToken(jobId), timeoutHeaders: true);
                var responseHeaders = CollectHeaders(response);
                info = new ProbeInfo
                {
                    Status = (int)response.StatusCode,
                    Headers = responseHeaders,
                    Size = ParseSize(responseHeaders),
                    Range = response.StatusCode == HttpStatusCode.PartialContent
                        || (responseHeaders.TryGetValue("accept-ranges", out var ranges) && ranges.Equals("bytes", StringComparison.OrdinalIgnoreCase))
                };

                if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.PartialContent)
                {
                    return info;
                }

                if (response.StatusCode is HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotImplemented)
                {
                    continue;
                }
            }
            catch (DownloadPaused)
            {
                throw;
            }
            catch (DownloadCancelled)
            {
                throw;
            }
            catch
            {
                continue;
            }
        }

        return info;
    }

    private async Task DownloadSingleAsync(JsonObject payload, Dictionary<string, string> headers, byte[]? body, string path, string jobId)
    {
        var method = NormalizeMethod(GetString(payload, "method"));
        var data = method is "GET" or "HEAD" ? null : body;
        using var response = await SendRequestAsync(GetString(payload, "url"), method, headers, data, GetCancelToken(jobId), timeoutHeaders: true);
        if (!response.IsSuccessStatusCode)
        {
            throw new HttpRequestException($"HTTP {(int)response.StatusCode}");
        }

        await StreamToFileAsync(response, path, jobId);
    }

    private async Task DownloadAcceleratedAsync(JsonObject payload, Dictionary<string, string> headers, long size, string path, string jobId)
    {
        var parts = new List<DownloadPart>();
        long start = 0;
        var index = 0;
        while (start < size)
        {
            var end = Math.Min(size - 1, start + ChunkSize - 1);
            parts.Add(new DownloadPart(start, end, $"{path}.part{index:000}"));
            start = end + 1;
            index++;
        }

        using var partSlots = new SemaphoreSlim(Math.Min(MaxConnections, Math.Max(1, parts.Count)));
        var completedParts = 0;
        UpdateJob(jobId, ("partsTotal", parts.Count), ("partsDone", 0));
        var tasks = parts.Select(async part =>
        {
            await partSlots.WaitAsync();
            try
            {
                await DownloadRangePartAsync(GetString(payload, "url"), headers, part, jobId);
                var done = Interlocked.Increment(ref completedParts);
                var downloaded = SumCompletedPartBytes(parts);
                UpdateJob(jobId, ("status", "running"), ("downloaded", downloaded), ("total", size), ("partsDone", done), ("partsTotal", parts.Count));
            }
            finally
            {
                partSlots.Release();
            }
        }).ToArray();

        await Task.WhenAll(tasks);

        await using (var output = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024, useAsync: true))
        {
            foreach (var part in parts)
            {
                CheckControl(jobId);
                await using var input = OpenReadWithRetry(part.Path);
                await input.CopyToAsync(output, 1024 * 1024, GetCancelToken(jobId));
                TryDelete(part.Path);
            }
        }
    }

    private async Task DownloadPlaylistAsync(JsonObject payload, Dictionary<string, string> headers, string jobId)
    {
        var url = GetString(payload, "url");
        var mediaType = GetString(payload, "mediaType");
        var filename = EnsureVideoFilename(PickFilename(payload, new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)), mediaType);
        var finalPath = ResolveFinalPath(payload, filename);
        payload["finalPath"] = finalPath;
        payload["saveDir"] = Path.GetDirectoryName(finalPath) ?? "";

        var finalDirectory = Path.GetDirectoryName(finalPath) ?? _downloadDir;
        var finalExtension = Path.GetExtension(finalPath);
        var tempName = $"{Path.GetFileNameWithoutExtension(finalPath)}.onepart{(string.IsNullOrWhiteSpace(finalExtension) ? ".mp4" : finalExtension)}";
        var tempPath = Path.Combine(finalDirectory, tempName);
        UpdateJob(
            jobId,
            ("status", "running"),
            ("file", finalPath),
            ("filename", Path.GetFileName(finalPath)),
            ("total", 0L),
            ("range", false),
            ("mode", "stream"),
            ("mediaType", string.IsNullOrWhiteSpace(mediaType) ? "playlist" : mediaType),
            ("videoUrl", GetString(payload, "videoUrl")),
            ("audioUrl", GetString(payload, "audioUrl")),
            ("manifestUrl", GetString(payload, "manifestUrl")),
            ("connections", 1),
            ("downloaded", 0L),
            ("qualityLabel", GetString(payload, "qualityLabel")),
            ("_payload", payload));

        var ffmpeg = FindFfmpegExecutable();
        if (string.IsNullOrWhiteSpace(ffmpeg))
        {
            throw new InvalidOperationException("ffmpeg를 찾지 못했습니다. HLS/DASH 다운로드에는 ffmpeg가 필요합니다.");
        }

        TryDelete(tempPath);
        if (IsDashSplitDownload(payload))
        {
            var videoUrl = GetString(payload, "videoUrl");
            var audioUrl = GetString(payload, "audioUrl");
            Log($"dash split ffmpeg video={videoUrl} audio={audioUrl} file={finalPath}");
            await RunFfmpegMuxAsync(ffmpeg, videoUrl, audioUrl, tempPath, headers, jobId);
        }
        else
        {
            Log($"stream ffmpeg url={url} file={finalPath}");
            await RunFfmpegAsync(ffmpeg, url, tempPath, headers, jobId);
        }

        if (!VerifyFile(tempPath, 0))
        {
            throw new InvalidOperationException("ffmpeg output verification failed");
        }

        MoveReplace(tempPath, finalPath);
        Log($"done stream file={finalPath}");
        UpdateJob(
            jobId,
            ("status", "done"),
            ("downloaded", new FileInfo(finalPath).Length),
            ("total", new FileInfo(finalPath).Length),
            ("error", ""));
        ClearControl(jobId);
    }

    private async Task DownloadRangePartAsync(string url, Dictionary<string, string> headers, DownloadPart part, string jobId)
    {
        var expectedSize = part.End - part.Start + 1;
        if (VerifyFile(part.Path, expectedSize))
        {
            return;
        }

        TryDelete(part.Path);
        var tempPath = part.Path + ".tmp";
        var partHeaders = new Dictionary<string, string>(headers, StringComparer.OrdinalIgnoreCase)
        {
            ["Range"] = $"bytes={part.Start}-{part.End}"
        };
        Exception? lastError = null;

        for (var attempt = 1; attempt <= MaxRetries; attempt++)
        {
            try
            {
                CheckControl(jobId);
                TryDelete(tempPath);
                using var response = await SendRequestAsync(url, "GET", partHeaders, null, GetCancelToken(jobId), timeoutHeaders: true);
                if (response.StatusCode != HttpStatusCode.PartialContent)
                {
                    throw new InvalidOperationException($"Range request failed: {(int)response.StatusCode}");
                }

                await StreamToFileAsync(response, tempPath, jobId);
                if (VerifyFile(tempPath, expectedSize))
                {
                    MoveReplace(tempPath, part.Path);
                    return;
                }

                throw new InvalidOperationException("Range part verification failed");
            }
            catch (DownloadPaused)
            {
                TryDelete(tempPath);
                throw;
            }
            catch (DownloadCancelled)
            {
                TryDelete(tempPath);
                throw;
            }
            catch (OperationCanceledException) when (IsCancelled(jobId))
            {
                TryDelete(tempPath);
                throw new DownloadCancelled();
            }
            catch (OperationCanceledException) when (IsPaused(jobId))
            {
                TryDelete(tempPath);
                throw new DownloadPaused();
            }
            catch (Exception error)
            {
                lastError = error;
                Log($"part failed attempt={attempt} range={part.Start}-{part.End} error={error.Message}");
                TryDelete(tempPath);
                await Task.Delay(TimeSpan.FromMilliseconds(500 * attempt));
            }
        }

        if (lastError is not null)
        {
            throw lastError;
        }

        throw new InvalidOperationException("Range part failed");
    }

    private static FileStream OpenReadWithRetry(string path)
    {
        for (var attempt = 1; attempt <= 10; attempt++)
        {
            try
            {
                return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1024 * 1024, useAsync: true);
            }
            catch when (attempt < 10)
            {
                Thread.Sleep(100 * attempt);
            }
        }

        return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1024 * 1024, useAsync: true);
    }

    private static long SumCompletedPartBytes(IEnumerable<DownloadPart> parts)
    {
        long downloaded = 0;
        foreach (var part in parts)
        {
            try
            {
                if (File.Exists(part.Path))
                {
                    downloaded += new FileInfo(part.Path).Length;
                }
            }
            catch (IOException)
            {
            }
            catch (UnauthorizedAccessException)
            {
            }
        }

        return downloaded;
    }

    private async Task<HttpResponseMessage> SendRequestAsync(
        string url,
        string method,
        Dictionary<string, string> headers,
        byte[]? data,
        CancellationToken cancelToken,
        bool timeoutHeaders)
    {
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancelToken);
        if (timeoutHeaders)
        {
            timeout.CancelAfter(TimeSpan.FromSeconds(RequestTimeoutSeconds));
        }

        var request = new HttpRequestMessage(new HttpMethod(method), url);
        if (data is not null)
        {
            request.Content = new ByteArrayContent(data);
        }

        foreach (var (name, value) in headers)
        {
            if (!request.Headers.TryAddWithoutValidation(name, value))
            {
                request.Content?.Headers.TryAddWithoutValidation(name, value);
            }
        }

        return await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
    }

    private async Task RunFfmpegAsync(string ffmpeg, string url, string outputPath, Dictionary<string, string> headers, string jobId)
    {
        Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? _downloadDir);
        var startInfo = new ProcessStartInfo
        {
            FileName = ffmpeg,
            WorkingDirectory = Path.GetDirectoryName(outputPath) ?? _downloadDir,
            UseShellExecute = false,
            CreateNoWindow = true,
            RedirectStandardError = true,
            RedirectStandardOutput = true
        };

        startInfo.ArgumentList.Add("-hide_banner");
        startInfo.ArgumentList.Add("-nostdin");
        startInfo.ArgumentList.Add("-y");
        startInfo.ArgumentList.Add("-loglevel");
        startInfo.ArgumentList.Add("warning");
        AddFfmpegInput(startInfo, url, headers);
        startInfo.ArgumentList.Add("-c");
        startInfo.ArgumentList.Add("copy");
        startInfo.ArgumentList.Add("-movflags");
        startInfo.ArgumentList.Add("+faststart");
        startInfo.ArgumentList.Add(outputPath);

        using var process = new Process { StartInfo = startInfo };
        var errorBuilder = new StringBuilder();
        process.ErrorDataReceived += (_, eventArgs) =>
        {
            if (!string.IsNullOrWhiteSpace(eventArgs.Data) && errorBuilder.Length < 6000)
            {
                errorBuilder.AppendLine(eventArgs.Data);
            }
        };

        if (!process.Start())
        {
            throw new InvalidOperationException("ffmpeg start failed");
        }

        process.BeginErrorReadLine();
        _ = process.StandardOutput.ReadToEndAsync();
        try
        {
            while (!process.HasExited)
            {
                CheckControl(jobId);
                UpdateJob(jobId, ("status", "running"), ("downloaded", File.Exists(outputPath) ? new FileInfo(outputPath).Length : 0L));
                await Task.Delay(500, GetCancelToken(jobId));
            }
        }
        catch (DownloadPaused)
        {
            KillProcess(process);
            TryDelete(outputPath);
            throw;
        }
        catch (DownloadCancelled)
        {
            KillProcess(process);
            TryDelete(outputPath);
            throw;
        }
        catch (OperationCanceledException) when (IsCancelled(jobId))
        {
            KillProcess(process);
            TryDelete(outputPath);
            throw new DownloadCancelled();
        }
        catch (OperationCanceledException) when (IsPaused(jobId))
        {
            KillProcess(process);
            TryDelete(outputPath);
            throw new DownloadPaused();
        }

        if (process.ExitCode != 0)
        {
            var error = errorBuilder.ToString().Trim();
            throw new InvalidOperationException(string.IsNullOrWhiteSpace(error) ? $"ffmpeg failed: {process.ExitCode}" : error);
        }

        UpdateJob(jobId, ("status", "running"), ("downloaded", File.Exists(outputPath) ? new FileInfo(outputPath).Length : 0L));
    }

    private async Task RunFfmpegMuxAsync(string ffmpeg, string videoUrl, string audioUrl, string outputPath, Dictionary<string, string> headers, string jobId)
    {
        if (!IsHttpUrl(videoUrl))
        {
            throw new InvalidOperationException("DASH video URL이 올바르지 않습니다.");
        }

        Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? _downloadDir);
        var startInfo = new ProcessStartInfo
        {
            FileName = ffmpeg,
            WorkingDirectory = Path.GetDirectoryName(outputPath) ?? _downloadDir,
            UseShellExecute = false,
            CreateNoWindow = true,
            RedirectStandardError = true,
            RedirectStandardOutput = true
        };

        startInfo.ArgumentList.Add("-hide_banner");
        startInfo.ArgumentList.Add("-nostdin");
        startInfo.ArgumentList.Add("-y");
        startInfo.ArgumentList.Add("-loglevel");
        startInfo.ArgumentList.Add("warning");
        AddFfmpegInput(startInfo, videoUrl, headers);
        var hasAudio = IsHttpUrl(audioUrl);
        if (hasAudio)
        {
            AddFfmpegInput(startInfo, audioUrl, headers);
            startInfo.ArgumentList.Add("-map");
            startInfo.ArgumentList.Add("0:v:0");
            startInfo.ArgumentList.Add("-map");
            startInfo.ArgumentList.Add("1:a:0");
        }
        startInfo.ArgumentList.Add("-c");
        startInfo.ArgumentList.Add("copy");
        if (hasAudio)
        {
            startInfo.ArgumentList.Add("-shortest");
        }
        startInfo.ArgumentList.Add("-movflags");
        startInfo.ArgumentList.Add("+faststart");
        startInfo.ArgumentList.Add(outputPath);

        using var process = new Process { StartInfo = startInfo };
        var errorBuilder = new StringBuilder();
        process.ErrorDataReceived += (_, eventArgs) =>
        {
            if (!string.IsNullOrWhiteSpace(eventArgs.Data) && errorBuilder.Length < 6000)
            {
                errorBuilder.AppendLine(eventArgs.Data);
            }
        };

        if (!process.Start())
        {
            throw new InvalidOperationException("ffmpeg start failed");
        }

        process.BeginErrorReadLine();
        _ = process.StandardOutput.ReadToEndAsync();
        try
        {
            while (!process.HasExited)
            {
                CheckControl(jobId);
                UpdateJob(jobId, ("status", "running"), ("downloaded", File.Exists(outputPath) ? new FileInfo(outputPath).Length : 0L));
                await Task.Delay(500, GetCancelToken(jobId));
            }
        }
        catch (DownloadPaused)
        {
            KillProcess(process);
            TryDelete(outputPath);
            throw;
        }
        catch (DownloadCancelled)
        {
            KillProcess(process);
            TryDelete(outputPath);
            throw;
        }
        catch (OperationCanceledException) when (IsCancelled(jobId))
        {
            KillProcess(process);
            TryDelete(outputPath);
            throw new DownloadCancelled();
        }
        catch (OperationCanceledException) when (IsPaused(jobId))
        {
            KillProcess(process);
            TryDelete(outputPath);
            throw new DownloadPaused();
        }

        if (process.ExitCode != 0)
        {
            var error = errorBuilder.ToString().Trim();
            throw new InvalidOperationException(string.IsNullOrWhiteSpace(error) ? $"ffmpeg failed: {process.ExitCode}" : error);
        }

        UpdateJob(jobId, ("status", "running"), ("downloaded", File.Exists(outputPath) ? new FileInfo(outputPath).Length : 0L));
    }

    private async Task StreamToFileAsync(HttpResponseMessage response, string path, string jobId)
    {
        Directory.CreateDirectory(Path.GetDirectoryName(path) ?? _downloadDir);
        await using var input = await response.Content.ReadAsStreamAsync(GetCancelToken(jobId));
        await using var output = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024, useAsync: true);
        var buffer = new byte[1024 * 1024];
        var downloaded = 0L;
        var lastProgressAt = Stopwatch.GetTimestamp();

        while (true)
        {
            CheckControl(jobId);
            using var timeout = CancellationTokenSource.CreateLinkedTokenSource(GetCancelToken(jobId));
            timeout.CancelAfter(TimeSpan.FromSeconds(RequestTimeoutSeconds));
            var read = await input.ReadAsync(buffer.AsMemory(0, buffer.Length), timeout.Token);
            if (read == 0)
            {
                break;
            }

            await output.WriteAsync(buffer.AsMemory(0, read), GetCancelToken(jobId));
            downloaded += read;
            var now = Stopwatch.GetTimestamp();
            if ((now - lastProgressAt) * 1000 / Stopwatch.Frequency >= 500)
            {
                UpdateJob(jobId, ("status", "running"), ("downloaded", downloaded));
                lastProgressAt = now;
            }
        }

        UpdateJob(jobId, ("status", "running"), ("downloaded", downloaded));
    }

    private static bool IsPlaylistDownload(JsonObject payload)
    {
        var mediaType = GetString(payload, "mediaType");
        if (mediaType.Equals("hls", StringComparison.OrdinalIgnoreCase)
            || mediaType.Equals("dash", StringComparison.OrdinalIgnoreCase)
            || mediaType.Equals("dash-pair", StringComparison.OrdinalIgnoreCase)
            || mediaType.Equals("dash-video", StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        var url = GetString(payload, "url");
        return Regex.IsMatch(url, @"\.(m3u8|mpd)(\?|#|$)", RegexOptions.IgnoreCase);
    }

    private static bool IsDashSplitDownload(JsonObject payload)
    {
        var mediaType = GetString(payload, "mediaType");
        return (mediaType.Equals("dash-pair", StringComparison.OrdinalIgnoreCase)
                || mediaType.Equals("dash-video", StringComparison.OrdinalIgnoreCase))
            && IsHttpUrl(GetString(payload, "videoUrl"));
    }

    private static string EnsureVideoFilename(string filename, string mediaType)
    {
        var extension = Path.GetExtension(filename);
        if (mediaType.Equals("hls", StringComparison.OrdinalIgnoreCase)
            || mediaType.Equals("dash", StringComparison.OrdinalIgnoreCase)
            || mediaType.Equals("dash-pair", StringComparison.OrdinalIgnoreCase)
            || mediaType.Equals("dash-video", StringComparison.OrdinalIgnoreCase)
            || extension.Equals(".m3u8", StringComparison.OrdinalIgnoreCase)
            || extension.Equals(".mpd", StringComparison.OrdinalIgnoreCase))
        {
            var stem = Path.GetFileNameWithoutExtension(filename);
            return SanitizeFilename(string.IsNullOrWhiteSpace(stem) ? "video.mp4" : $"{stem}.mp4");
        }

        return SanitizeFilename(string.IsNullOrWhiteSpace(extension) ? $"{filename}.mp4" : filename);
    }

    private static void AddFfmpegHeaders(ProcessStartInfo startInfo, Dictionary<string, string> headers)
    {
        var headerLines = new List<string>();
        foreach (var (name, value) in headers)
        {
            if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value))
            {
                continue;
            }

            if (name.Equals("User-Agent", StringComparison.OrdinalIgnoreCase))
            {
                startInfo.ArgumentList.Add("-user_agent");
                startInfo.ArgumentList.Add(value);
                continue;
            }

            if (name.Equals("Referer", StringComparison.OrdinalIgnoreCase))
            {
                startInfo.ArgumentList.Add("-referer");
                startInfo.ArgumentList.Add(value);
                continue;
            }

            headerLines.Add($"{name}: {value}");
        }

        if (headerLines.Count > 0)
        {
            startInfo.ArgumentList.Add("-headers");
            startInfo.ArgumentList.Add(string.Join("\r\n", headerLines) + "\r\n");
        }
    }

    private static void AddFfmpegInput(ProcessStartInfo startInfo, string url, Dictionary<string, string> headers)
    {
        AddFfmpegHeaders(startInfo, headers);
        startInfo.ArgumentList.Add("-i");
        startInfo.ArgumentList.Add(url);
    }

    private string FindFfmpegExecutable()
    {
        var candidates = new[]
        {
            Environment.GetEnvironmentVariable("ONE_FFMPEG_PATH") ?? "",
            Path.Combine(_root, "ffmpeg.exe"),
            Path.Combine(_root, "FFmpeg", "ffmpeg.exe"),
            Path.Combine(_root, "Tools", "ffmpeg.exe"),
            @"C:\Drive\Program\ShareX\Tools\ffmpeg.exe",
            @"C:\Drive\Program\ShareX\ffmpeg.exe",
            FindExecutableInPath("ffmpeg.exe")
        };

        return candidates.FirstOrDefault(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path)) ?? "";
    }

    private static string FindExecutableInPath(string name)
    {
        var pathValue = Environment.GetEnvironmentVariable("PATH") ?? "";
        foreach (var directory in pathValue.Split(Path.PathSeparator))
        {
            if (string.IsNullOrWhiteSpace(directory))
            {
                continue;
            }

            try
            {
                var candidate = Path.Combine(directory.Trim(), name);
                if (File.Exists(candidate))
                {
                    return candidate;
                }
            }
            catch
            {
            }
        }

        return "";
    }

    private static void KillProcess(Process process)
    {
        try
        {
            if (!process.HasExited)
            {
                process.Kill(entireProcessTree: true);
            }
        }
        catch
        {
        }
    }

    private Dictionary<string, string> NormalizeHeaders(JsonObject payload)
    {
        var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        if (payload["headers"] is JsonObject headerObject)
        {
            foreach (var (key, value) in headerObject)
            {
                headers[key] = NodeToString(value);
            }
        }

        foreach (var blocked in new[] { "Host", "Content-Length", "Connection", "Accept-Encoding" })
        {
            headers.Remove(blocked);
        }

        var referrer = GetString(payload, "referrer");
        if (!string.IsNullOrWhiteSpace(referrer) && !headers.ContainsKey("Referer"))
        {
            headers["Referer"] = referrer;
        }

        if (!headers.ContainsKey("User-Agent"))
        {
            headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/145 Safari/537.36";
        }

        if (!headers.ContainsKey("Accept"))
        {
            headers["Accept"] = "*/*";
        }

        return headers;
    }

    private static byte[]? DecodePostBody(JsonObject payload)
    {
        var encoded = GetString(payload, "postDataBase64");
        return string.IsNullOrEmpty(encoded) ? null : Convert.FromBase64String(encoded);
    }

    private string PickFilename(JsonObject payload, Dictionary<string, string> headers)
    {
        foreach (var candidate in new[]
        {
            GetString(payload, "filename"),
            FilenameFromContentDisposition(headers.TryGetValue("content-disposition", out var disposition) ? disposition : ""),
            FilenameFromUrl(GetString(payload, "url"))
        })
        {
            if (!string.IsNullOrWhiteSpace(candidate))
            {
                return SanitizeFilename(candidate);
            }
        }

        var contentType = "";
        if (headers.TryGetValue("content-type", out var value))
        {
            contentType = value.Split(';', 2)[0].Trim();
        }

        var extension = MimeExtension(contentType);
        return $"download_{DateTime.Now:yyyyMMdd_HHmmss}{extension}";
    }

    private string ResolveFinalPath(JsonObject payload, string filename)
    {
        var finalPathValue = GetString(payload, "finalPath").Trim();
        if (!string.IsNullOrWhiteSpace(finalPathValue))
        {
            var finalPath = ExpandPath(finalPathValue);
            if (Path.IsPathFullyQualified(finalPath))
            {
                Directory.CreateDirectory(Path.GetDirectoryName(finalPath) ?? _downloadDir);
                return finalPath;
            }
        }

        var saveDirValue = GetString(payload, "saveDir").Trim();
        var saveDir = string.IsNullOrWhiteSpace(saveDirValue) ? _downloadDir : ExpandPath(saveDirValue);
        if (!Path.IsPathFullyQualified(saveDir))
        {
            saveDir = _downloadDir;
        }

        return UniquePath(saveDir, filename);
    }

    private static string SanitizeFilename(string name)
    {
        var cleaned = Regex.Replace(name ?? "", """[\\/:*?"<>|]""", "_");
        cleaned = Regex.Replace(cleaned, "[\r\n]+", " ");
        cleaned = Regex.Replace(cleaned, "\\s+", " ").Trim(' ', '.');
        return string.IsNullOrWhiteSpace(cleaned) ? $"download_{DateTime.Now:yyyyMMdd_HHmmss}.bin" : cleaned;
    }

    private static string UniquePath(string directory, string filename)
    {
        Directory.CreateDirectory(directory);
        var candidate = Path.Combine(directory, SanitizeFilename(filename));
        if (!File.Exists(candidate))
        {
            return candidate;
        }

        var stem = Path.GetFileNameWithoutExtension(candidate);
        var suffix = Path.GetExtension(candidate);
        for (var index = 2; index < 10000; index++)
        {
            candidate = Path.Combine(directory, $"{stem}_{index}{suffix}");
            if (!File.Exists(candidate))
            {
                return candidate;
            }
        }

        return Path.Combine(directory, $"{stem}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}{suffix}");
    }

    private static string FilenameFromContentDisposition(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return "";
        }

        var parameters = ParseContentDispositionParameters(value);
        if (parameters.TryGetValue("filename*", out var encodedFilename)
            && TryDecodeRfc5987(encodedFilename, out var decodedFilename)
            && !string.IsNullOrWhiteSpace(decodedFilename))
        {
            return decodedFilename.Trim();
        }

        if (parameters.TryGetValue("filename", out var filename))
        {
            return DecodeMimeEncodedWords(filename).Trim();
        }

        var encoded = Regex.Match(value, "filename\\*=UTF-8''([^;]+)", RegexOptions.IgnoreCase);
        if (encoded.Success)
        {
            try
            {
                return Uri.UnescapeDataString(encoded.Groups[1].Value);
            }
            catch
            {
                return encoded.Groups[1].Value;
            }
        }

        var match = Regex.Match(value, "filename=\"?([^\";]+)\"?", RegexOptions.IgnoreCase);
        return match.Success ? match.Groups[1].Value.Trim() : "";
    }

    private static Dictionary<string, string> ParseContentDispositionParameters(string value)
    {
        var parameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var segment in SplitDispositionSegments(value))
        {
            var separator = segment.IndexOf('=');
            if (separator <= 0)
            {
                continue;
            }

            var name = segment[..separator].Trim();
            var parameterValue = UnquoteDispositionValue(segment[(separator + 1)..].Trim());
            if (!string.IsNullOrWhiteSpace(name))
            {
                parameters[name] = parameterValue;
            }
        }

        return parameters;
    }

    private static IEnumerable<string> SplitDispositionSegments(string value)
    {
        var segment = new StringBuilder();
        var inQuotes = false;
        var escaped = false;

        foreach (var character in value)
        {
            if (escaped)
            {
                segment.Append(character);
                escaped = false;
                continue;
            }

            if (inQuotes && character == '\\')
            {
                escaped = true;
                continue;
            }

            if (character == '"')
            {
                inQuotes = !inQuotes;
                segment.Append(character);
                continue;
            }

            if (character == ';' && !inQuotes)
            {
                yield return segment.ToString();
                segment.Clear();
                continue;
            }

            segment.Append(character);
        }

        yield return segment.ToString();
    }

    private static string UnquoteDispositionValue(string value)
    {
        if (value.Length < 2 || value[0] != '"' || value[^1] != '"')
        {
            return value;
        }

        var result = new StringBuilder();
        var escaped = false;
        foreach (var character in value[1..^1])
        {
            if (escaped)
            {
                result.Append(character);
                escaped = false;
                continue;
            }

            if (character == '\\')
            {
                escaped = true;
                continue;
            }

            result.Append(character);
        }

        if (escaped)
        {
            result.Append('\\');
        }

        return result.ToString();
    }

    private static bool TryDecodeRfc5987(string value, out string decoded)
    {
        decoded = "";
        var text = UnquoteDispositionValue(value.Trim());
        var first = text.IndexOf('\'');
        var second = first >= 0 ? text.IndexOf('\'', first + 1) : -1;
        var charset = "utf-8";
        var encoded = text;
        if (first > 0 && second > first)
        {
            charset = text[..first];
            encoded = text[(second + 1)..];
        }

        try
        {
            decoded = EncodingForName(charset).GetString(PercentDecodeBytes(encoded));
            return true;
        }
        catch
        {
            return false;
        }
    }

    private static string DecodeMimeEncodedWords(string value)
    {
        return Regex.Replace(value, "=\\?([^?\\s]+)\\?([bBqQ])\\?([^?]*)\\?=", match =>
        {
            try
            {
                var bytes = match.Groups[2].Value.Equals("B", StringComparison.OrdinalIgnoreCase)
                    ? Convert.FromBase64String(match.Groups[3].Value)
                    : DecodeMimeQBytes(match.Groups[3].Value);
                return EncodingForName(match.Groups[1].Value).GetString(bytes);
            }
            catch
            {
                return match.Value;
            }
        });
    }

    private static byte[] DecodeMimeQBytes(string value)
    {
        using var output = new MemoryStream();
        for (var index = 0; index < value.Length; index++)
        {
            if (value[index] == '_')
            {
                output.WriteByte(0x20);
                continue;
            }

            if (value[index] == '='
                && index + 2 < value.Length
                && HexValue(value[index + 1]) >= 0
                && HexValue(value[index + 2]) >= 0)
            {
                output.WriteByte((byte)((HexValue(value[index + 1]) << 4) + HexValue(value[index + 2])));
                index += 2;
                continue;
            }

            output.WriteByte((byte)value[index]);
        }

        return output.ToArray();
    }

    private static byte[] PercentDecodeBytes(string value)
    {
        using var output = new MemoryStream();
        for (var index = 0; index < value.Length; index++)
        {
            if (value[index] == '%'
                && index + 2 < value.Length
                && HexValue(value[index + 1]) >= 0
                && HexValue(value[index + 2]) >= 0)
            {
                output.WriteByte((byte)((HexValue(value[index + 1]) << 4) + HexValue(value[index + 2])));
                index += 2;
                continue;
            }

            var bytes = Encoding.UTF8.GetBytes(value[index].ToString());
            output.Write(bytes, 0, bytes.Length);
        }

        return output.ToArray();
    }

    private static int HexValue(char value)
    {
        if (value is >= '0' and <= '9')
        {
            return value - '0';
        }

        if (value is >= 'a' and <= 'f')
        {
            return value - 'a' + 10;
        }

        if (value is >= 'A' and <= 'F')
        {
            return value - 'A' + 10;
        }

        return -1;
    }

    private static Encoding EncodingForName(string value)
    {
        return value.Trim().Trim('"').ToLowerInvariant() switch
        {
            "utf-8" or "utf8" => Encoding.UTF8,
            "us-ascii" or "ascii" => Encoding.ASCII,
            "iso-8859-1" or "latin1" or "latin-1" => Encoding.Latin1,
            "utf-16" or "utf-16le" => Encoding.Unicode,
            "utf-16be" => Encoding.BigEndianUnicode,
            _ => Encoding.UTF8
        };
    }

    private static string FilenameFromUrl(string url)
    {
        if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
        {
            return "";
        }

        var name = Path.GetFileName(Uri.UnescapeDataString(uri.AbsolutePath));
        return name ?? "";
    }

    private static string MimeExtension(string contentType)
    {
        return contentType.ToLowerInvariant() switch
        {
            "application/gzip" => ".gz",
            "text/html" => ".html",
            "text/plain" => ".txt",
            "text/css" => ".css",
            "text/csv" => ".csv",
            "text/javascript" => ".js",
            "text/xml" => ".xml",
            "application/json" => ".json",
            "application/javascript" => ".js",
            "application/pdf" => ".pdf",
            "application/xml" => ".xml",
            "application/octet-stream" => ".bin",
            "application/wasm" => ".wasm",
            "application/x-7z-compressed" => ".7z",
            "application/x-msdownload" => ".exe",
            "application/x-rar-compressed" => ".rar",
            "application/x-tar" => ".tar",
            "application/vnd.android.package-archive" => ".apk",
            "application/vnd.ms-excel" => ".xls",
            "application/vnd.ms-powerpoint" => ".ppt",
            "application/msword" => ".doc",
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => ".xlsx",
            "application/vnd.openxmlformats-officedocument.presentationml.presentation" => ".pptx",
            "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => ".docx",
            "audio/mpeg" => ".mp3",
            "audio/ogg" => ".oga",
            "audio/wav" => ".wav",
            "audio/webm" => ".weba",
            "font/otf" => ".otf",
            "font/ttf" => ".ttf",
            "font/woff" => ".woff",
            "font/woff2" => ".woff2",
            "image/bmp" => ".bmp",
            "image/jpeg" => ".jpg",
            "image/png" => ".png",
            "image/gif" => ".gif",
            "image/svg+xml" => ".svg",
            "image/webp" => ".webp",
            "image/x-icon" => ".ico",
            "application/zip" => ".zip",
            "video/mp4" => ".mp4",
            "video/mpeg" => ".mpeg",
            "video/quicktime" => ".mov",
            "video/webm" => ".webm",
            "video/x-matroska" => ".mkv",
            _ => ".bin"
        };
    }

    private static long ParseSize(Dictionary<string, string> headers)
    {
        if (headers.TryGetValue("content-range", out var contentRange))
        {
            var match = Regex.Match(contentRange, "/(\\d+)$");
            if (match.Success && long.TryParse(match.Groups[1].Value, out var rangeSize))
            {
                return rangeSize;
            }
        }

        return headers.TryGetValue("content-length", out var contentLength) && long.TryParse(contentLength, out var size)
            ? size
            : 0;
    }

    private static Dictionary<string, string> CollectHeaders(HttpResponseMessage response)
    {
        var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var header in response.Headers)
        {
            headers[header.Key.ToLowerInvariant()] = string.Join(", ", header.Value);
        }

        foreach (var header in response.Content.Headers)
        {
            headers[header.Key.ToLowerInvariant()] = string.Join(", ", header.Value);
        }

        return headers;
    }

    private static bool VerifyFile(string path, long expectedSize)
    {
        if (!File.Exists(path))
        {
            return false;
        }

        var length = new FileInfo(path).Length;
        return length > 0 && (expectedSize <= 0 || length == expectedSize);
    }

    private static void MoveReplace(string source, string destination)
    {
        Directory.CreateDirectory(Path.GetDirectoryName(destination) ?? ".");
        for (var attempt = 1; attempt <= 5; attempt++)
        {
            try
            {
                File.Move(source, destination, overwrite: true);
                return;
            }
            catch when (attempt < 5)
            {
                Thread.Sleep(100 * attempt);
            }
        }
    }

    private static void TryDelete(string path)
    {
        for (var attempt = 1; attempt <= 3; attempt++)
        {
            try
            {
                if (File.Exists(path))
                {
                    File.Delete(path);
                }
                return;
            }
            catch when (attempt < 3)
            {
                Thread.Sleep(100 * attempt);
            }
        }
    }

    private static void CleanupDownloadParts(string tempPath)
    {
        TryDelete(tempPath);
        var directory = Path.GetDirectoryName(tempPath);
        if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
        {
            return;
        }

        foreach (var path in Directory.EnumerateFiles(directory, Path.GetFileName(tempPath) + ".part*"))
        {
            TryDelete(path);
        }
    }

    private static string FormatDownloadError(Exception error)
    {
        if (error.Message.Contains("Access to the path", StringComparison.OrdinalIgnoreCase)
            && error.Message.Contains("denied", StringComparison.OrdinalIgnoreCase))
        {
            return $"file permission denied: {error.Message}";
        }

        return error switch
        {
            UnauthorizedAccessException => $"file permission denied: {error.Message}",
            IOException => $"file write failed: {error.Message}",
            _ => error.Message
        };
    }

    private JobControl EnsureControl(string jobId)
    {
        return _controls.GetOrAdd(jobId, _ => new JobControl());
    }

    private void ResetControl(string jobId)
    {
        if (_controls.TryRemove(jobId, out var old))
        {
            old.Dispose();
        }

        _controls[jobId] = new JobControl();
    }

    private void SetControl(string jobId, bool? pause = null, bool? cancel = null)
    {
        var control = EnsureControl(jobId);
        lock (control)
        {
            if (pause.HasValue)
            {
                control.Pause = pause.Value;
                if (pause.Value)
                {
                    control.CancelSource.Cancel();
                }
            }

            if (cancel.HasValue)
            {
                control.Cancel = cancel.Value;
                if (cancel.Value)
                {
                    control.CancelSource.Cancel();
                }
            }
        }
    }

    private void ClearControl(string jobId)
    {
        if (_controls.TryRemove(jobId, out var control))
        {
            control.Dispose();
        }
    }

    private void CheckControl(string jobId)
    {
        if (string.IsNullOrWhiteSpace(jobId) || !_controls.TryGetValue(jobId, out var control))
        {
            return;
        }

        lock (control)
        {
            if (control.Cancel)
            {
                throw new DownloadCancelled();
            }

            if (control.Pause)
            {
                throw new DownloadPaused();
            }
        }
    }

    private bool IsCancelled(string jobId)
    {
        if (string.IsNullOrWhiteSpace(jobId) || !_controls.TryGetValue(jobId, out var control))
        {
            return false;
        }

        lock (control)
        {
            return control.Cancel;
        }
    }

    private bool IsPaused(string jobId)
    {
        if (string.IsNullOrWhiteSpace(jobId) || !_controls.TryGetValue(jobId, out var control))
        {
            return false;
        }

        lock (control)
        {
            return control.Pause;
        }
    }

    private CancellationToken GetCancelToken(string jobId)
    {
        return !string.IsNullOrWhiteSpace(jobId) && _controls.TryGetValue(jobId, out var control)
            ? control.CancelSource.Token
            : CancellationToken.None;
    }

    private string ResolveDefaultDownloadDir()
    {
        var configured = Environment.GetEnvironmentVariable("ONE_DOWNLOAD_DIR");
        if (!string.IsNullOrWhiteSpace(configured))
        {
            return ExpandPath(configured);
        }

        var userProfile = Environment.GetEnvironmentVariable("USERPROFILE")
            ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
        var desktop = Path.Combine(userProfile, "Desktop");
        return Directory.Exists(desktop) ? desktop : userProfile;
    }

    private static string ExpandPath(string value)
    {
        var expanded = Environment.ExpandEnvironmentVariables(value.Trim());
        if (expanded == "~")
        {
            return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
        }

        if (expanded.StartsWith("~" + Path.DirectorySeparatorChar, StringComparison.Ordinal)
            || expanded.StartsWith("~" + Path.AltDirectorySeparatorChar, StringComparison.Ordinal))
        {
            return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), expanded[2..]);
        }

        return expanded;
    }

    private static bool IsHttpUrl(string value)
    {
        return Uri.TryCreate(value, UriKind.Absolute, out var uri)
            && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
    }

    private static bool HasQueryFlag(string target, string name)
    {
        var queryStart = target.IndexOf('?', StringComparison.Ordinal);
        if (queryStart < 0 || queryStart == target.Length - 1)
        {
            return false;
        }

        foreach (var pair in target[(queryStart + 1)..].Split('&', StringSplitOptions.RemoveEmptyEntries))
        {
            var parts = pair.Split('=', 2);
            var key = Uri.UnescapeDataString(parts[0]);
            if (!key.Equals(name, StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            var value = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : "1";
            return value != "0" && !value.Equals("false", StringComparison.OrdinalIgnoreCase);
        }

        return false;
    }

    private static bool IsActiveStatus(string status)
    {
        return status is "queued" or "running" or "retrying" or "pausing" or "paused" or "cancelling";
    }

    private static string NormalizeComparableUrl(string url)
    {
        if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
        {
            return url.Trim();
        }

        var builder = new UriBuilder(uri)
        {
            Fragment = ""
        };
        return builder.Uri.AbsoluteUri;
    }

    private static string NormalizeMethod(string method)
    {
        return string.IsNullOrWhiteSpace(method) ? "GET" : method.ToUpperInvariant();
    }

    private static string MakeJobId(JsonObject payload)
    {
        var seed = $"{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}|{Stopwatch.GetTimestamp()}|{GetString(payload, "url")}|{Environment.CurrentManagedThreadId}";
        var hash = SHA1.HashData(Encoding.UTF8.GetBytes(seed));
        return Convert.ToHexString(hash).ToLowerInvariant()[..16];
    }

    private static JsonObject ToPersistedJob(JsonObject job)
    {
        var result = new JsonObject();
        foreach (var (key, value) in job)
        {
            if (!key.StartsWith("_", StringComparison.Ordinal))
            {
                result[key] = CloneNode(value);
            }
        }

        return result;
    }

    private static JsonObject ToVisibleJob(JsonObject job)
    {
        return ToPersistedJob(job);
    }

    private static JsonObject CloneObject(JsonObject source)
    {
        return (source.DeepClone() as JsonObject) ?? new JsonObject();
    }

    private static JsonNode? CloneNode(JsonNode? source)
    {
        return source?.DeepClone();
    }

    private static JsonNode? ToNode(object? value)
    {
        return value switch
        {
            null => null,
            JsonNode node => CloneNode(node),
            string text => JsonValue.Create(text),
            bool flag => JsonValue.Create(flag),
            int number => JsonValue.Create(number),
            long number => JsonValue.Create(number),
            double number => JsonValue.Create(number),
            float number => JsonValue.Create(number),
            decimal number => JsonValue.Create(number),
            _ => JsonSerializer.SerializeToNode(value, JsonOptions)
        };
    }

    private static string GetString(JsonObject obj, string key)
    {
        return obj.TryGetPropertyValue(key, out var value) ? NodeToString(value) : "";
    }

    private static string NodeToString(JsonNode? value)
    {
        if (value is null)
        {
            return "";
        }

        if (value is JsonValue jsonValue && jsonValue.TryGetValue<string>(out var text))
        {
            return text;
        }

        return value.ToString();
    }

    private static double GetDouble(JsonObject obj, string key)
    {
        if (!obj.TryGetPropertyValue(key, out var value) || value is null)
        {
            return 0;
        }

        if (value is JsonValue jsonValue && jsonValue.TryGetValue<double>(out var number))
        {
            return number;
        }

        return double.TryParse(value.ToString(), out var parsed) ? parsed : 0;
    }

    private static bool GetBool(JsonObject obj, string key)
    {
        return obj.TryGetPropertyValue(key, out var value)
            && value is JsonValue jsonValue
            && jsonValue.TryGetValue<bool>(out var flag)
            && flag;
    }

    private static double NowSeconds()
    {
        return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
    }

    private sealed record ProbeInfo
    {
        public int Status { get; init; }
        public long Size { get; init; }
        public bool Range { get; init; }
        public Dictionary<string, string> Headers { get; init; } = new(StringComparer.OrdinalIgnoreCase);
    }

    private sealed record DownloadPart(long Start, long End, string Path);
}

internal sealed class BridgeTrayContext : ApplicationContext
{
    private const int MaxTrayTextLength = 63;

    private readonly DownloadBridgeApp _app;
    private readonly CancellationTokenSource _stop;
    private readonly ContextMenuStrip _menu;
    private readonly ToolStripMenuItem _statusItem;
    private readonly ToolStripMenuItem _updateItem;
    private readonly NotifyIcon _notifyIcon;
    private readonly Icon _trayIcon;

    public BridgeTrayContext(DownloadBridgeApp app, CancellationTokenSource stop)
    {
        _app = app;
        _stop = stop;
        _statusItem = new ToolStripMenuItem("상태: 시작 중") { Enabled = false };
        _menu = new ContextMenuStrip();
        _menu.Items.Add(_statusItem);
        _menu.Items.Add(new ToolStripSeparator());
        _menu.Items.Add(new ToolStripMenuItem("상태 표시", null, (_, _) => ShowStatus()));
        _menu.Items.Add(new ToolStripMenuItem("다운로드 폴더 열기", null, (_, _) => OpenDirectory(_app.DownloadDir)));
        _updateItem = new ToolStripMenuItem("업데이트 확인", null, async (_, _) => await CheckForUpdateAsync());
        _menu.Items.Add(_updateItem);
        _menu.Items.Add(new ToolStripMenuItem("로그 열기", null, (_, _) => OpenLog()));
        _menu.Items.Add(new ToolStripSeparator());
        _menu.Items.Add(new ToolStripMenuItem("종료", null, (_, _) => RequestExit()));
        _menu.Opening += (_, _) => UpdateStatus();
        _trayIcon = LoadTrayIcon(_app.Root);

        _notifyIcon = new NotifyIcon
        {
            ContextMenuStrip = _menu,
            Icon = _trayIcon,
            Text = "ONE Download Bridge",
            Visible = true
        };
        _notifyIcon.DoubleClick += (_, _) => ShowStatus();
        UpdateStatus();
        _app.Log("tray ready");
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            _notifyIcon.Visible = false;
            _notifyIcon.Dispose();
            _trayIcon.Dispose();
            _menu.Dispose();
        }

        base.Dispose(disposing);
    }

    private void UpdateStatus()
    {
        var text = _app.StatusText;
        _statusItem.Text = "상태: " + text;
        _notifyIcon.Text = TrimTrayText("ONE Download Bridge - " + text);
    }

    private void ShowStatus()
    {
        UpdateStatus();
        _notifyIcon.ShowBalloonTip(3000, "ONE Download Bridge", _app.StatusText, ToolTipIcon.Info);
    }

    private void OpenDirectory(string path)
    {
        try
        {
            Directory.CreateDirectory(path);
            Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
        }
        catch (Exception error)
        {
            _app.Log($"open download dir failed path={path} error={error.Message}");
            ShowError("다운로드 폴더를 열지 못했습니다.");
        }
    }

    private void OpenLog()
    {
        try
        {
            Directory.CreateDirectory(Path.GetDirectoryName(_app.LogPath) ?? ".");
            if (!File.Exists(_app.LogPath))
            {
                File.WriteAllText(_app.LogPath, "", Encoding.UTF8);
            }

            Process.Start(new ProcessStartInfo(_app.LogPath) { UseShellExecute = true });
        }
        catch (Exception error)
        {
            _app.Log($"open log failed path={_app.LogPath} error={error.Message}");
            ShowError("로그를 열지 못했습니다.");
        }
    }

    private async Task CheckForUpdateAsync()
    {
        var updatePath = Path.Combine(_app.Root, "update.cmd");
        if (!File.Exists(updatePath))
        {
            ShowError("update.cmd를 찾지 못했습니다.");
            return;
        }

        _updateItem.Enabled = false;
        _updateItem.Text = "업데이트 확인 중...";
        try
        {
            var comSpec = Environment.GetEnvironmentVariable("ComSpec");
            if (string.IsNullOrWhiteSpace(comSpec))
            {
                comSpec = "cmd.exe";
            }

            var startInfo = new ProcessStartInfo
            {
                FileName = comSpec,
                WorkingDirectory = _app.Root,
                UseShellExecute = false,
                CreateNoWindow = true,
                RedirectStandardOutput = true,
                RedirectStandardError = true
            };
            startInfo.ArgumentList.Add("/d");
            startInfo.ArgumentList.Add("/c");
            startInfo.ArgumentList.Add($"\"{updatePath}\" --quiet");

            using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("update process start failed");
            var outputTask = process.StandardOutput.ReadToEndAsync();
            var errorTask = process.StandardError.ReadToEndAsync();
            await process.WaitForExitAsync();
            var output = await outputTask;
            var error = await errorTask;
            var log = (output + Environment.NewLine + error).Trim();
            if (!string.IsNullOrWhiteSpace(log))
            {
                _app.Log($"manual update output={log.Replace(Environment.NewLine, " | ", StringComparison.Ordinal)}");
            }

            if (process.ExitCode == 0)
            {
                _notifyIcon.ShowBalloonTip(3000, "ONE 업데이트", "업데이트 확인을 완료했습니다.", ToolTipIcon.Info);
                return;
            }

            _app.Log($"manual update failed exit={process.ExitCode}");
            ShowError($"업데이트 확인 실패: {process.ExitCode}");
        }
        catch (Exception error)
        {
            _app.Log($"manual update failed error={error.Message}");
            ShowError("업데이트 확인을 실행하지 못했습니다.");
        }
        finally
        {
            _updateItem.Text = "업데이트 확인";
            _updateItem.Enabled = true;
        }
    }

    private void ShowError(string message)
    {
        _notifyIcon.ShowBalloonTip(3000, "ONE Download Bridge", message, ToolTipIcon.Error);
    }

    private void RequestExit()
    {
        _app.Log("exit requested from tray");
        _notifyIcon.Visible = false;
        _stop.Cancel();
        ExitThread();
    }

    private static string TrimTrayText(string value)
    {
        return value.Length <= MaxTrayTextLength ? value : value[..MaxTrayTextLength];
    }

    private static Icon LoadTrayIcon(string root)
    {
        var iconPath = Path.Combine(root, "icons", "one.ico");
        if (File.Exists(iconPath))
        {
            return new Icon(iconPath);
        }

        return (Icon)SystemIcons.Application.Clone();
    }
}

internal sealed class LocalHttpServer
{
    private readonly string _host;
    private readonly int _port;
    private readonly DownloadBridgeApp _app;

    public LocalHttpServer(string host, int port, DownloadBridgeApp app)
    {
        _host = host;
        _port = port;
        _app = app;
    }

    public async Task RunAsync(CancellationToken stopToken)
    {
        var listener = new TcpListener(IPAddress.Parse(_host), _port);
        listener.Start();
        try
        {
            while (!stopToken.IsCancellationRequested)
            {
                var client = await listener.AcceptTcpClientAsync(stopToken);
                _ = Task.Run(() => HandleClientAsync(client, stopToken), CancellationToken.None);
            }
        }
        finally
        {
            listener.Stop();
        }
    }

    private async Task HandleClientAsync(TcpClient client, CancellationToken stopToken)
    {
        using var _ = client;
        try
        {
            using var stream = client.GetStream();
            var request = await LocalHttpRequest.ReadAsync(stream, stopToken);
            if (request is null)
            {
                return;
            }

            var response = new LocalHttpResponse(stream);
            await _app.HandleAsync(request, response);
        }
        catch (Exception error)
        {
            _app.Log($"client error={error.Message}");
        }
    }
}

internal sealed record LocalHttpRequest(string Method, string Target, string Path, byte[] Body)
{
    public static async Task<LocalHttpRequest?> ReadAsync(NetworkStream stream, CancellationToken stopToken)
    {
        var data = new MemoryStream();
        var buffer = new byte[8192];
        var headerEnd = -1;

        while (headerEnd < 0 && data.Length < 64 * 1024)
        {
            var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), stopToken);
            if (read == 0)
            {
                return null;
            }

            data.Write(buffer, 0, read);
            headerEnd = FindHeaderEnd(data.GetBuffer(), (int)data.Length);
        }

        if (headerEnd < 0)
        {
            return null;
        }

        var raw = data.ToArray();
        var headerText = Encoding.ASCII.GetString(raw, 0, headerEnd);
        var lines = headerText.Split(new[] { "\r\n" }, StringSplitOptions.None);
        if (lines.Length == 0)
        {
            return null;
        }

        var requestLine = lines[0].Split(' ');
        if (requestLine.Length < 2)
        {
            return null;
        }

        var contentLength = 0;
        foreach (var line in lines.Skip(1))
        {
            var separator = line.IndexOf(':');
            if (separator <= 0)
            {
                continue;
            }

            var name = line[..separator].Trim();
            var value = line[(separator + 1)..].Trim();
            if (name.Equals("Content-Length", StringComparison.OrdinalIgnoreCase))
            {
                int.TryParse(value, out contentLength);
            }
        }

        var body = new byte[contentLength];
        var bodyStart = headerEnd + 4;
        var copied = Math.Min(contentLength, Math.Max(0, raw.Length - bodyStart));
        if (copied > 0)
        {
            Buffer.BlockCopy(raw, bodyStart, body, 0, copied);
        }

        while (copied < contentLength)
        {
            var read = await stream.ReadAsync(body.AsMemory(copied, contentLength - copied), stopToken);
            if (read == 0)
            {
                break;
            }

            copied += read;
        }

        var target = requestLine[1];
        var path = target.Split('?', 2)[0];
        return new LocalHttpRequest(requestLine[0], target, path, body);
    }

    private static int FindHeaderEnd(byte[] buffer, int length)
    {
        for (var index = 3; index < length; index++)
        {
            if (buffer[index - 3] == '\r'
                && buffer[index - 2] == '\n'
                && buffer[index - 1] == '\r'
                && buffer[index] == '\n')
            {
                return index - 3;
            }
        }

        return -1;
    }
}

internal sealed class LocalHttpResponse
{
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
    };

    private readonly NetworkStream _stream;

    public LocalHttpResponse(NetworkStream stream)
    {
        _stream = stream;
    }

    public async Task SendJsonAsync(object data, int status = 200)
    {
        var body = status == 204 ? Array.Empty<byte>() : JsonSerializer.SerializeToUtf8Bytes(data, JsonOptions);
        var headers = new StringBuilder()
            .Append("HTTP/1.1 ").Append(status).Append(' ').Append(ReasonPhrase(status)).Append("\r\n")
            .Append("Server: ONEDownloadBridge/1.0\r\n")
            .Append("Access-Control-Allow-Origin: *\r\n")
            .Append("Access-Control-Allow-Methods: POST, GET, OPTIONS\r\n")
            .Append("Access-Control-Allow-Headers: Content-Type\r\n")
            .Append("Content-Type: application/json; charset=utf-8\r\n")
            .Append("Content-Length: ").Append(body.Length).Append("\r\n")
            .Append("Connection: close\r\n\r\n");

        var headerBytes = Encoding.ASCII.GetBytes(headers.ToString());
        await _stream.WriteAsync(headerBytes);
        if (body.Length > 0)
        {
            await _stream.WriteAsync(body);
        }
    }

    private static string ReasonPhrase(int status)
    {
        return status switch
        {
            200 => "OK",
            204 => "No Content",
            400 => "Bad Request",
            404 => "Not Found",
            405 => "Method Not Allowed",
            500 => "Internal Server Error",
            _ => "OK"
        };
    }
}

internal sealed class JobControl : IDisposable
{
    public bool Pause { get; set; }
    public bool Cancel { get; set; }
    public CancellationTokenSource CancelSource { get; } = new();

    public void Dispose()
    {
        CancelSource.Dispose();
    }
}

internal sealed class SingleInstance : IDisposable
{
    private readonly Mutex? _mutex;

    private SingleInstance(Mutex? mutex, bool acquired)
    {
        _mutex = mutex;
        Acquired = acquired;
    }

    public bool Acquired { get; }

    public static SingleInstance TryAcquire(DownloadBridgeApp app, BridgeOptions options)
    {
        try
        {
            var mutexName = options.Host == "127.0.0.1" && options.Port == 17661
                ? "Local\\ONE.DownloadBridge"
                : $"Local\\ONE.DownloadBridge.{NormalizeMutexPart(options.Host)}.{options.Port}";
            var mutex = new Mutex(initiallyOwned: false, mutexName);
            var acquired = false;
            try
            {
                acquired = mutex.WaitOne(0);
            }
            catch (AbandonedMutexException)
            {
                acquired = true;
            }

            if (acquired)
            {
                return new SingleInstance(mutex, true);
            }

            mutex.Dispose();
            return new SingleInstance(null, false);
        }
        catch (Exception error)
        {
            app.Log($"mutex failed error={error.Message}");
            return new SingleInstance(null, true);
        }
    }

    private static string NormalizeMutexPart(string value)
    {
        return Regex.Replace(value, "[^A-Za-z0-9_.-]", "_");
    }

    public void Dispose()
    {
        if (!Acquired || _mutex is null)
        {
            return;
        }

        try
        {
            _mutex.ReleaseMutex();
        }
        catch
        {
        }

        _mutex.Dispose();
    }
}

internal sealed record BridgeOptions(string Root, string Host, int Port, bool NoTray)
{
    private const string DefaultHost = "127.0.0.1";
    private const int DefaultPort = 17661;

    public static BridgeOptions Parse(string[] args)
    {
        var root = BridgePaths.ResolveRoot(args);
        var host = GetValue(args, "--host") ?? DefaultHost;
        var portValue = GetValue(args, "--port");
        var port = int.TryParse(portValue, out var parsedPort) && parsedPort is > 0 and <= 65535
            ? parsedPort
            : DefaultPort;

        return new BridgeOptions(root, host, port, HasFlag(args, "--no-tray"));
    }

    private static string? GetValue(string[] args, string name)
    {
        for (var index = 0; index < args.Length - 1; index++)
        {
            if (args[index].Equals(name, StringComparison.OrdinalIgnoreCase))
            {
                return args[index + 1];
            }
        }

        return null;
    }

    private static bool HasFlag(string[] args, string name)
    {
        return args.Any(arg => arg.Equals(name, StringComparison.OrdinalIgnoreCase));
    }
}

internal static class BridgePaths
{
    public static string ResolveRoot(string[] args)
    {
        for (var index = 0; index < args.Length; index++)
        {
            if (args[index].Equals("--root", StringComparison.OrdinalIgnoreCase) && index + 1 < args.Length)
            {
                return Path.GetFullPath(args[index + 1]);
            }
        }

        var directory = new DirectoryInfo(AppContext.BaseDirectory);
        while (directory is not null)
        {
            if (File.Exists(Path.Combine(directory.FullName, "start-download-bridge.cmd")) ||
                File.Exists(Path.Combine(directory.FullName, "ONE.DownloadBridgeLauncher.exe")))
            {
                return directory.FullName;
            }

            directory = directory.Parent;
        }

        return Directory.GetCurrentDirectory();
    }
}

internal sealed class DownloadPaused : Exception;

internal sealed class DownloadCancelled : Exception;
