using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.Manifests;
using PSLauncher.Models;

namespace PSLauncher.Core.Lan;

/// <summary>
/// Serveur HTTP local pour le partage de ZIPs LAN, basé sur <see cref="TcpListener"/>.
///
/// Pourquoi pas <c>HttpListener</c> ? Parce que <c>HttpListener.Start()</c> exige une
/// URL ACL admin pour TOUT prefix qui n'est pas <c>localhost</c> — y compris quand on
/// bind sur une IP spécifique (j'avais cru le contraire et c'était faux). Avec
/// <c>TcpListener</c>, n'importe quel utilisateur peut bind sur <c>0.0.0.0:port</c>
/// sans URL ACL, sans installer le moindre handler système. On parse HTTP à la main
/// (request line + headers + Range), c'est ~80 LOC et le protocole est trivial pour
/// nos 2 routes en GET.
///
/// Sécurité (inchangée) :
/// - Filtre RFC1918 / link-local sur RemoteEndPoint avant tout File.IO
/// - Validation regex stricte du path version (anti-path-traversal)
/// - SemaphoreSlim(4) pour rate-limit
/// </summary>
public sealed class LanCacheServer : BackgroundService, ILanCacheServer
{
    private const int MaxConcurrentServes = 4;
    private const int CopyBufferSize = 1 << 16; // 64 KiB
    private const int HeaderReadTimeoutMs = 5_000;
    private static readonly Regex VersionRegex = new(@"^[0-9A-Za-z._-]+$", RegexOptions.Compiled);

    private readonly Func<LanCacheConfig> _configProvider;
    private readonly IZipCacheStore _cache;
    private readonly IManifestCache _manifestCache;
    private readonly ILogger<LanCacheServer> _logger;
    private readonly SemaphoreSlim _serveLimit = new(MaxConcurrentServes, MaxConcurrentServes);

    private TcpListener? _listener;
    private List<string> _boundEndpoints = new();
    private int? _boundPort;

    public LanCacheServer(
        Func<LanCacheConfig> configProvider,
        IZipCacheStore cache,
        IManifestCache manifestCache,
        ILogger<LanCacheServer> logger)
    {
        _configProvider = configProvider;
        _cache = cache;
        _manifestCache = manifestCache;
        _logger = logger;
    }

    public bool IsRunning => _listener?.Server.IsBound == true;
    public int? BoundPort => _boundPort;
    public IReadOnlyList<string> BoundEndpoints => _boundEndpoints;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var cfg = _configProvider();
        if (!cfg.ServerEnabled)
        {
            _logger.LogInformation("LAN cache server disabled (ServerEnabled=false)");
            return;
        }

        try
        {
            _listener = new TcpListener(IPAddress.Any, cfg.ServerPort);
            _listener.Start();
            _boundPort = cfg.ServerPort;

            // Liste informative (affichée dans Settings UI). Le bind réel est
            // sur 0.0.0.0 donc toutes les interfaces, mais on n'affiche que les
            // IPs LAN — c'est elles que les peers vont contacter.
            _boundEndpoints = GetLocalLanIPv4()
                .Select(ip => $"http://{ip}:{cfg.ServerPort}")
                .ToList();
            if (_boundEndpoints.Count == 0)
                _boundEndpoints.Add($"http://0.0.0.0:{cfg.ServerPort}");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to start LAN cache server on port {Port}. Le port est-il déjà utilisé ?", cfg.ServerPort);
            try { _listener?.Stop(); } catch { }
            _listener = null;
            return;
        }

        _logger.LogInformation("LAN cache server listening on TCP :{Port} ({Endpoints})",
            cfg.ServerPort, string.Join(", ", _boundEndpoints));

        while (!stoppingToken.IsCancellationRequested)
        {
            TcpClient client;
            try
            {
                client = await _listener.AcceptTcpClientAsync(stoppingToken).ConfigureAwait(false);
            }
            catch (OperationCanceledException) { break; }
            catch (ObjectDisposedException) { break; }
            catch (Exception ex)
            {
                _logger.LogDebug(ex, "AcceptTcpClient failed (will retry)");
                continue;
            }

            // Une task par connexion, fire-and-forget.
            _ = Task.Run(() => HandleConnectionAsync(client, stoppingToken), stoppingToken);
        }

        try { _listener.Stop(); } catch { }
        _listener = null;
        _logger.LogInformation("LAN cache server stopped");
    }

    private async Task HandleConnectionAsync(TcpClient client, CancellationToken ct)
    {
        try
        {
            var remoteIp = (client.Client.RemoteEndPoint as IPEndPoint)?.Address;
            using (client)
            await using (var stream = client.GetStream())
            {
                // 1) Filtre RFC1918 sur l'IP source. Defense in depth.
                if (remoteIp is null || !IsRfc1918OrLinkLocal(remoteIp))
                {
                    _logger.LogWarning("Rejecting non-LAN request from {Remote}", remoteIp);
                    await WriteSimpleResponseAsync(stream, 403, "forbidden: LAN-only", ct).ConfigureAwait(false);
                    return;
                }

                // 2) Lit la request line + headers (timeout 5s pour une connexion qui
                //    se connecte sans rien envoyer — anti-slow-loris basique).
                using var headerCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
                headerCts.CancelAfter(HeaderReadTimeoutMs);
                var request = await ReadRequestAsync(stream, headerCts.Token).ConfigureAwait(false);
                if (request is null)
                {
                    await WriteSimpleResponseAsync(stream, 400, "bad request", ct).ConfigureAwait(false);
                    return;
                }

                // 3) Routing : seul GET, sur /v1/has/{ver} ou /v1/zip/{ver}
                if (!string.Equals(request.Method, "GET", StringComparison.OrdinalIgnoreCase))
                {
                    await WriteSimpleResponseAsync(stream, 405, "method not allowed", ct).ConfigureAwait(false);
                    return;
                }

                const string hasPrefix = "/v1/has/";
                const string zipPrefix = "/v1/zip/";
                const string manifestPath = "/v1/manifest";
                const string infoPath = "/v1/info";
                if (request.Path.StartsWith(hasPrefix, StringComparison.Ordinal))
                {
                    var version = request.Path.Substring(hasPrefix.Length);
                    await HandleHasAsync(stream, version, ct).ConfigureAwait(false);
                }
                else if (request.Path.StartsWith(zipPrefix, StringComparison.Ordinal))
                {
                    var version = request.Path.Substring(zipPrefix.Length);
                    await HandleZipAsync(stream, version, request.RangeHeader, ct).ConfigureAwait(false);
                }
                else if (request.Path == manifestPath)
                {
                    await HandleManifestAsync(stream, ct).ConfigureAwait(false);
                }
                else if (request.Path == infoPath)
                {
                    await HandleInfoAsync(stream, ct).ConfigureAwait(false);
                }
                else
                {
                    await WriteSimpleResponseAsync(stream, 404, "not found", ct).ConfigureAwait(false);
                }
            }
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Error handling LAN cache connection");
        }
    }

    private async Task HandleHasAsync(NetworkStream stream, string version, CancellationToken ct)
    {
        if (!VersionRegex.IsMatch(version))
        {
            await WriteSimpleResponseAsync(stream, 400, "invalid version", ct).ConfigureAwait(false);
            return;
        }

        if (!_cache.TryGetCachedEntry(version, out var entry))
        {
            await WriteSimpleResponseAsync(stream, 404, "not in cache", ct).ConfigureAwait(false);
            return;
        }

        var json = JsonSerializer.Serialize(new
        {
            sha256 = entry.Sha256,
            size = entry.SizeBytes,
            mtime = entry.ModifiedUtc,
        });
        var body = Encoding.UTF8.GetBytes(json);
        await WriteResponseAsync(stream, 200, "OK", "application/json", body.LongLength, null, body, ct).ConfigureAwait(false);
    }

    /// <summary>
    /// Endpoint léger d'auto-discovery active. Retourne la même structure que
    /// le beacon UDP : <c>{v, host, port, versions}</c>. Sert au cold-start
    /// d'un client qui veut probe ses peers connus du cache disque ou manuels
    /// SANS attendre les 15 s du prochain beacon UDP.
    /// </summary>
    private async Task HandleInfoAsync(NetworkStream stream, CancellationToken ct)
    {
        var versions = _cache.ListCached().Select(c => c.Version).ToList();
        var json = JsonSerializer.Serialize(new
        {
            v = 1,
            host = (_boundEndpoints.Count > 0 ? _boundEndpoints[0] : $"http://0.0.0.0:{_boundPort}")
                       .Replace("http://", "").Split(':')[0],
            port = _boundPort ?? 0,
            versions,
        });
        var body = Encoding.UTF8.GetBytes(json);
        await WriteResponseAsync(stream, 200, "OK", "application/json", body.LongLength, null, body, ct).ConfigureAwait(false);
    }

    /// <summary>
    /// Sert le manifest cached byte-for-byte aux peers du LAN qui n'ont pas
    /// d'accès Internet. Le client validera la signature Ed25519 du manifest
    /// (trust chain inchangée). 404 si pas de cache (le serveur PC n'a jamais
    /// fetché OVH avec succès).
    /// </summary>
    private async Task HandleManifestAsync(NetworkStream stream, CancellationToken ct)
    {
        var path = _manifestCache.GetCachedFilePath();
        if (!File.Exists(path))
        {
            await WriteSimpleResponseAsync(stream, 404, "no manifest cached", ct).ConfigureAwait(false);
            return;
        }

        try
        {
            var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
            await WriteResponseAsync(stream, 200, "OK", "application/json", bytes.LongLength, null, bytes, ct).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to serve cached manifest");
            await WriteSimpleResponseAsync(stream, 500, "manifest read error", ct).ConfigureAwait(false);
        }
    }

    private async Task HandleZipAsync(NetworkStream stream, string version, string? rangeHeader, CancellationToken ct)
    {
        if (!VersionRegex.IsMatch(version))
        {
            await WriteSimpleResponseAsync(stream, 400, "invalid version", ct).ConfigureAwait(false);
            return;
        }

        if (!_cache.TryGetCachedEntry(version, out var entry))
        {
            await WriteSimpleResponseAsync(stream, 404, "not in cache", ct).ConfigureAwait(false);
            return;
        }

        if (!await _serveLimit.WaitAsync(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false))
        {
            await WriteSimpleResponseAsync(stream, 503, "server busy, try later", ct).ConfigureAwait(false);
            return;
        }

        try
        {
            await using var fs = new FileStream(entry.Path, FileMode.Open, FileAccess.Read, FileShare.Read,
                CopyBufferSize, useAsync: true);

            long total = fs.Length;
            long start = 0;
            long end = total - 1;
            bool isPartial = false;

            if (!string.IsNullOrEmpty(rangeHeader) && rangeHeader.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase))
            {
                var spec = rangeHeader.Substring("bytes=".Length);
                var parts = spec.Split('-', 2);
                if (parts.Length == 2 && long.TryParse(parts[0], out var s) && s >= 0 && s < total)
                {
                    start = s;
                    if (parts[1].Length > 0 && long.TryParse(parts[1], out var e) && e >= start && e < total)
                        end = e;
                    isPartial = true;
                }
                else
                {
                    var headers = $"Content-Range: bytes */{total}\r\n";
                    await WriteSimpleResponseAsync(stream, 416, "range not satisfiable", ct, headers).ConfigureAwait(false);
                    return;
                }
            }

            var length = end - start + 1;
            int code = isPartial ? 206 : 200;
            string status = isPartial ? "Partial Content" : "OK";
            string? extraHeaders = null;
            if (isPartial) extraHeaders = $"Content-Range: bytes {start}-{end}/{total}\r\n";

            // Headers : Content-Type + Length + Accept-Ranges + ETag + (Content-Range si 206) + Connection: close
            var sb = new StringBuilder();
            sb.Append("HTTP/1.1 ").Append(code).Append(' ').Append(status).Append("\r\n");
            sb.Append("Content-Type: application/zip\r\n");
            sb.Append("Content-Length: ").Append(length).Append("\r\n");
            sb.Append("Accept-Ranges: bytes\r\n");
            sb.Append("ETag: \"").Append(entry.Sha256).Append("\"\r\n");
            if (extraHeaders is not null) sb.Append(extraHeaders);
            sb.Append("Connection: close\r\n\r\n");
            var headerBytes = Encoding.ASCII.GetBytes(sb.ToString());
            await stream.WriteAsync(headerBytes, ct).ConfigureAwait(false);

            // Body : stream le fichier en chunks
            fs.Seek(start, SeekOrigin.Begin);
            var buffer = new byte[CopyBufferSize];
            long remaining = length;
            while (remaining > 0)
            {
                ct.ThrowIfCancellationRequested();
                int toRead = (int)Math.Min(buffer.Length, remaining);
                int n = await fs.ReadAsync(buffer.AsMemory(0, toRead), ct).ConfigureAwait(false);
                if (n <= 0) break;
                await stream.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
                remaining -= n;
            }
        }
        finally
        {
            _serveLimit.Release();
        }
    }

    /// <summary>
    /// Parser HTTP minimal : juste la request line + le header Range (le seul qu'on
    /// utilise). Les autres headers sont lus mais ignorés. Limite : 8 KiB de headers
    /// max, suffisamment pour notre cas (typique 200-500 octets).
    /// </summary>
    private static async Task<ParsedRequest?> ReadRequestAsync(NetworkStream stream, CancellationToken ct)
    {
        var buffer = new byte[8192];
        int total = 0;
        int headerEnd = -1;
        while (total < buffer.Length)
        {
            int n = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), ct).ConfigureAwait(false);
            if (n <= 0) break;
            total += n;
            // Cherche \r\n\r\n (fin des headers HTTP)
            for (int i = 3; i < total; i++)
            {
                if (buffer[i - 3] == (byte)'\r' && buffer[i - 2] == (byte)'\n'
                 && buffer[i - 1] == (byte)'\r' && buffer[i] == (byte)'\n')
                {
                    headerEnd = i + 1;
                    break;
                }
            }
            if (headerEnd >= 0) break;
        }
        if (headerEnd < 0) return null;

        var raw = Encoding.ASCII.GetString(buffer, 0, headerEnd);
        var lines = raw.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
        if (lines.Length == 0) return null;

        // Ligne 1 : "GET /path HTTP/1.1"
        var first = lines[0].Split(' ', 3);
        if (first.Length < 3) return null;
        var method = first[0];
        var path = first[1];

        string? rangeHeader = null;
        for (int i = 1; i < lines.Length; i++)
        {
            var line = lines[i];
            var colon = line.IndexOf(':');
            if (colon <= 0) continue;
            var name = line.Substring(0, colon).Trim();
            var value = line.Substring(colon + 1).Trim();
            if (string.Equals(name, "Range", StringComparison.OrdinalIgnoreCase))
                rangeHeader = value;
        }

        return new ParsedRequest(method, path, rangeHeader);
    }

    private sealed record ParsedRequest(string Method, string Path, string? RangeHeader);

    private static Task WriteSimpleResponseAsync(NetworkStream stream, int code, string text, CancellationToken ct, string? extraHeaders = null)
    {
        var body = Encoding.UTF8.GetBytes(text);
        return WriteResponseAsync(stream, code, ReasonPhrase(code), "text/plain; charset=utf-8", body.LongLength, extraHeaders, body, ct);
    }

    private static async Task WriteResponseAsync(NetworkStream stream, int code, string status, string contentType, long contentLength, string? extraHeaders, byte[]? body, CancellationToken ct)
    {
        var sb = new StringBuilder();
        sb.Append("HTTP/1.1 ").Append(code).Append(' ').Append(status).Append("\r\n");
        sb.Append("Content-Type: ").Append(contentType).Append("\r\n");
        sb.Append("Content-Length: ").Append(contentLength).Append("\r\n");
        if (extraHeaders is not null) sb.Append(extraHeaders);
        sb.Append("Connection: close\r\n\r\n");
        var headerBytes = Encoding.ASCII.GetBytes(sb.ToString());
        await stream.WriteAsync(headerBytes, ct).ConfigureAwait(false);
        if (body is not null && body.Length > 0)
            await stream.WriteAsync(body, ct).ConfigureAwait(false);
    }

    private static string ReasonPhrase(int code) => code switch
    {
        200 => "OK",
        206 => "Partial Content",
        400 => "Bad Request",
        403 => "Forbidden",
        404 => "Not Found",
        405 => "Method Not Allowed",
        416 => "Range Not Satisfiable",
        500 => "Internal Server Error",
        503 => "Service Unavailable",
        _ => "Status",
    };

    /// <summary>
    /// Filtre RFC1918 + link-local (169.254.x.x) + loopback. Pas de bypass
    /// possible : c'est ce qui garantit qu'aucun ZIP ne sortira accidentellement
    /// du LAN même si la conf firewall est désactivée.
    /// </summary>
    private static bool IsRfc1918OrLinkLocal(IPAddress addr)
    {
        if (IPAddress.IsLoopback(addr)) return true;
        if (addr.AddressFamily != AddressFamily.InterNetwork) return false;
        var b = addr.GetAddressBytes();
        if (b[0] == 10) return true;
        if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true;
        if (b[0] == 192 && b[1] == 168) return true;
        if (b[0] == 169 && b[1] == 254) return true;
        return false;
    }

    private static IEnumerable<IPAddress> GetLocalLanIPv4()
    {
        return NetworkInterface.GetAllNetworkInterfaces()
            .Where(n => n.OperationalStatus == OperationalStatus.Up
                     && n.NetworkInterfaceType != NetworkInterfaceType.Loopback)
            .SelectMany(n => n.GetIPProperties().UnicastAddresses)
            .Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork
                     && IsRfc1918OrLinkLocal(a.Address))
            .Select(a => a.Address);
    }
}
