using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using ClaudeDo.Data.Environment;
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;

namespace ClaudeDo.Worker.Usage.TokenTracker;

/// <summary>
/// Starts the TokenTracker CLI. Resolution goes through <see cref="ExecutableResolver"/> because
/// an npm-installed CLI is a <c>.cmd</c> shim, which <c>UseShellExecute = false</c> cannot exec
/// directly. We deliberately never fall back to <c>npx</c> — that would silently download a
/// package behind the user's back.
/// </summary>
public sealed class TokenTrackerClient : ITokenTrackerClient, IDisposable
{
    private static readonly TimeSpan ExportTimeout = TimeSpan.FromSeconds(120);
    private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(20);
    private static readonly TimeSpan InstallTimeout = TimeSpan.FromMinutes(5);
    private static readonly TimeSpan DashboardReadyTimeout = TimeSpan.FromSeconds(30);
    private const int MinimumNodeMajor = 20;

    private static readonly HttpClient ReadinessProbe = new() { Timeout = TimeSpan.FromSeconds(2) };

    private readonly SemaphoreSlim _dashboardLock = new(1, 1);
    private Process? _dashboardProcess;
    private string? _dashboardUrl;

    public async Task<TokenTrackerProbe> ProbeAsync(CancellationToken ct = default)
    {
        var node = await RunAsync("node", ["--version"], ProbeTimeout, null, ct);
        var nodeVersion = node.Ok ? node.StdOut.Trim() : null;
        var nodeOk = NodeMajor(nodeVersion) >= MinimumNodeMajor;

        var cli = await RunAsync(TokenTrackerArgs.Command, TokenTrackerArgs.Version(), ProbeTimeout, null, ct);
        return cli.Ok
            ? new TokenTrackerProbe(true, cli.StdOut.Trim(), nodeOk, nodeVersion, null)
            : new TokenTrackerProbe(false, null, nodeOk, nodeVersion, cli.Error);
    }

    public Task<TokenTrackerRunResult> ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default) =>
        RunAsync(TokenTrackerArgs.Command, TokenTrackerArgs.Export(from, to), ExportTimeout, null, ct);

    public Task<TokenTrackerRunResult> InstallAsync(IProgress<string>? output = null, CancellationToken ct = default) =>
        RunAsync(TokenTrackerArgs.NpmCommand, TokenTrackerArgs.Install(), InstallTimeout, output, ct);

    public async Task<TokenTrackerDashboard> StartDashboardAsync(CancellationToken ct = default)
    {
        await _dashboardLock.WaitAsync(ct);
        try
        {
            if (_dashboardProcess is { HasExited: false } && _dashboardUrl is { } running)
                return new TokenTrackerDashboard(true, running, null);

            KillDashboard();

            var resolved = ExecutableResolver.Resolve(TokenTrackerArgs.Command);
            if (resolved is null)
                return new TokenTrackerDashboard(false, null,
                    $"'{TokenTrackerArgs.Command}' not found on PATH.");

            var port = FindFreePort();
            if (port is null)
                return new TokenTrackerDashboard(false, null,
                    $"No free port between {TokenTrackerArgs.DefaultDashboardPort} and " +
                    $"{TokenTrackerArgs.DefaultDashboardPort + TokenTrackerArgs.DashboardPortSpan - 1}.");

            var arguments = TokenTrackerArgs.Serve(port.Value);
            var startInfo = new ProcessStartInfo
            {
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true,
            };

            if (resolved.IsShim)
            {
                var shim = ExecutableResolver.BuildShimStartInfo(resolved.Path, arguments);
                startInfo.FileName = shim.FileName;
                startInfo.Arguments = shim.Arguments;
            }
            else
            {
                startInfo.FileName = resolved.Path;
                foreach (var arg in arguments) startInfo.ArgumentList.Add(arg);
            }

            Process process;
            try
            {
                process = Process.Start(startInfo)
                          ?? throw new InvalidOperationException("Process.Start returned null.");
            }
            catch (Exception ex)
            {
                return new TokenTrackerDashboard(false, null, $"Could not start the dashboard: {ex.Message}");
            }

            // The server is long-lived, so both pipes must keep draining — a full pipe buffer
            // would block the very server we are waiting for. The head is kept for the error
            // message ("port is still in use" arrives on stdout before the process exits).
            var output = new DashboardOutput();
            _ = DrainAsync(process.StandardOutput, output);
            _ = DrainAsync(process.StandardError, output);

            var url = $"http://127.0.0.1:{port.Value}/";
            bool serving;
            try
            {
                serving = await WaitUntilServingAsync(process, url, ct);
            }
            catch (OperationCanceledException)
            {
                // A caller that walked away must not leave a server we no longer track behind.
                KillProcessTree(process);
                throw;
            }

            if (!serving)
            {
                var reason = output.Head() is { Length: > 0 } head
                    ? head
                    : $"the dashboard did not answer on port {port.Value} within " +
                      $"{DashboardReadyTimeout.TotalSeconds:0}s.";
                KillProcessTree(process);
                return new TokenTrackerDashboard(false, null, $"TokenTracker dashboard failed: {reason}");
            }

            _dashboardProcess = process;
            _dashboardUrl = url;
            return new TokenTrackerDashboard(true, url, null);
        }
        finally
        {
            _dashboardLock.Release();
        }
    }

    public void Dispose() => KillDashboard();

    private void KillDashboard()
    {
        if (_dashboardProcess is { } process) KillProcessTree(process);
        _dashboardProcess = null;
        _dashboardUrl = null;
    }

    /// <summary>An npm CLI is launched through <c>cmd.exe</c>, so the process we hold is the shim,
    /// not node — killing it alone would leave the server listening.</summary>
    private static void KillProcessTree(Process process)
    {
        try
        {
            if (!process.HasExited) process.Kill(entireProcessTree: true);
        }
        catch { /* already gone, or we never had rights to it */ }
        finally { process.Dispose(); }
    }

    private static async Task<bool> WaitUntilServingAsync(Process process, string url, CancellationToken ct)
    {
        var deadline = DateTime.UtcNow + DashboardReadyTimeout;
        while (DateTime.UtcNow < deadline)
        {
            if (process.HasExited) return false;
            try
            {
                using var response = await ReadinessProbe.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
                if (response.IsSuccessStatusCode) return true;
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
            catch { /* not listening yet */ }

            await Task.Delay(250, ct);
        }
        return false;
    }

    /// <summary>First free port at or above TokenTracker's default. The probe binds dual-stack
    /// because node listens on <c>[::]</c>: an IPv4-only check calls 7680 free on a machine where
    /// Windows Delivery Optimization already holds <c>[::]:7680</c>, and <c>serve</c> then dies.</summary>
    private static int? FindFreePort()
    {
        for (var port = TokenTrackerArgs.DefaultDashboardPort;
             port < TokenTrackerArgs.DefaultDashboardPort + TokenTrackerArgs.DashboardPortSpan;
             port++)
        {
            if (IsPortFree(port)) return port;
        }
        return null;
    }

    private static bool IsPortFree(int port)
    {
        try
        {
            using var socket = new Socket(AddressFamily.InterNetworkV6, SocketType.Stream, ProtocolType.Tcp)
            {
                DualMode = true,
                ExclusiveAddressUse = true,
            };
            socket.Bind(new IPEndPoint(IPAddress.IPv6Any, port));
            return true;
        }
        catch (Exception)
        {
            return false;
        }
    }

    private static async Task DrainAsync(StreamReader reader, DashboardOutput sink)
    {
        try
        {
            while (await reader.ReadLineAsync() is { } line) sink.Add(line);
        }
        catch { /* the process went away; nothing left to drain */ }
    }

    /// <summary>Keeps only the first few lines — enough to explain a failed start, bounded so a
    /// server running for hours cannot grow it.</summary>
    private sealed class DashboardOutput
    {
        private const int MaxLines = 10;
        private readonly List<string> _lines = [];

        public void Add(string line)
        {
            lock (_lines)
            {
                if (_lines.Count < MaxLines && !string.IsNullOrWhiteSpace(line)) _lines.Add(line.Trim());
            }
        }

        public string Head()
        {
            lock (_lines) return string.Join(" ", _lines);
        }
    }

    /// <summary>Visible for the probe's version parsing; returns 0 when unreadable.</summary>
    internal static int NodeMajor(string? version)
    {
        if (string.IsNullOrWhiteSpace(version)) return 0;

        var trimmed = version.Trim().TrimStart('v', 'V');
        var major = trimmed.Split('.', 2)[0];
        return int.TryParse(major, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : 0;
    }

    private static async Task<TokenTrackerRunResult> RunAsync(
        string command,
        IReadOnlyList<string> arguments,
        TimeSpan timeout,
        IProgress<string>? output,
        CancellationToken ct)
    {
        var resolved = ExecutableResolver.Resolve(command);
        if (resolved is null)
            return new TokenTrackerRunResult(false, "", $"'{command}' not found on PATH.");

        var startInfo = new ProcessStartInfo
        {
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true,
        };

        if (resolved.IsShim)
        {
            var shim = ExecutableResolver.BuildShimStartInfo(resolved.Path, arguments);
            startInfo.FileName = shim.FileName;
            startInfo.Arguments = shim.Arguments;
        }
        else
        {
            startInfo.FileName = resolved.Path;
            foreach (var arg in arguments) startInfo.ArgumentList.Add(arg);
        }

        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        timeoutCts.CancelAfter(timeout);

        try
        {
            using var process = Process.Start(startInfo);
            if (process is null)
                return new TokenTrackerRunResult(false, "", $"Could not start '{command}'.");

            // Both pipes are drained concurrently — reading one to the end first deadlocks as
            // soon as the other fills its buffer, and a full-history export is ~1 MB of stdout.
            var stdoutTask = process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
            var stderrTask = ReadLinesAsync(process, output, timeoutCts.Token);

            await process.WaitForExitAsync(timeoutCts.Token);
            var stdout = await stdoutTask;
            var stderr = await stderrTask;

            return process.ExitCode == 0
                ? new TokenTrackerRunResult(true, stdout, null)
                : new TokenTrackerRunResult(false, stdout,
                    $"'{command}' exited with code {process.ExitCode}. {Head(stderr)}".TrimEnd());
        }
        catch (OperationCanceledException) when (!ct.IsCancellationRequested)
        {
            return new TokenTrackerRunResult(false, "", $"'{command}' timed out after {timeout.TotalSeconds:0}s.");
        }
        catch (Exception ex)
        {
            return new TokenTrackerRunResult(false, "", $"'{command}' failed: {ex.Message}");
        }
    }

    private static async Task<string> ReadLinesAsync(Process process, IProgress<string>? output, CancellationToken ct)
    {
        var lines = new List<string>();
        while (await process.StandardError.ReadLineAsync(ct) is { } line)
        {
            lines.Add(line);
            output?.Report(line);
        }
        return string.Join(System.Environment.NewLine, lines);
    }

    private static string Head(string text)
    {
        var trimmed = text.Trim();
        return trimmed.Length <= 300 ? trimmed : trimmed[..300] + "…";
    }
}
