using System.Globalization;
using System.Net;
using System.Text;

namespace Sunfish.Http;

/// <summary>
/// Lee peticiones de una conexion. Es tolerante a proposito con clientes antiguos: acepta lineas
/// acabadas solo en LF, HTTP/0.9, HTTP/1.0 sin Host, lineas en blanco antes de la peticion,
/// cabeceras partidas en varias lineas y lineas de cabecera que no se entienden (se ignoran).
/// </summary>
internal sealed class HttpRequestReader
{
    public const int MaxLineLength = 8192;
    public const int MaxHeaderBytes = 16384;
    public const int MaxHeaderCount = 100;

    private readonly Stream _stream;
    private readonly byte[] _buffer = new byte[16384];
    private int _pos;
    private int _len;
    private int _headerBytes;

    public HttpRequestReader(Stream stream) => _stream = stream;

    public IPEndPoint? Remote { get; init; }
    public IPEndPoint? Local { get; init; }

    /// <summary>
    /// Lee la siguiente peticion. Devuelve null si la conexion se cierra limpiamente antes de
    /// empezar otra. Lanza <see cref="HttpParseException"/> si esta mal formada.
    /// </summary>
    public async Task<HttpRequest?> ReadRequestAsync(Func<Task> sendContinue, CancellationToken ct)
    {
        _headerBytes = 0;
        string? line;
        // Lineas en blanco sueltas antes de la peticion (restos de un POST anterior en clientes viejos).
        do
        {
            line = await ReadLineAsync(ct);
            if (line is null)
                return null;
        } while (line.Length == 0);

        var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        if (parts.Length < 2 || parts.Length > 3)
            throw new HttpParseException(400, "Bad request line");

        string method = parts[0].ToUpperInvariant();
        int major = 0, minor = 9;
        if (parts.Length == 3)
        {
            if (!TryParseVersion(parts[2], out major, out minor))
                throw new HttpParseException(400, "Bad HTTP version");
            if (major > 1)
                throw new HttpParseException(505, "HTTP version not supported");
        }
        else if (method != "GET")
        {
            throw new HttpParseException(400, "HTTP/0.9 only allows GET");
        }

        var (path, query) = SplitTarget(parts[1]);
        var request = new HttpRequest
        {
            Method = method,
            Path = path,
            QueryString = query,
            VersionMajor = major,
            VersionMinor = minor,
            RemoteEndPoint = Remote,
            LocalEndPoint = Local,
        };
        if (major == 0)
            return request; // HTTP/0.9: no hay cabeceras

        while (true)
        {
            line = await ReadLineAsync(ct) ?? throw new HttpParseException(400, "Connection closed in headers");
            if (line.Length == 0)
                break;
            if (line[0] == ' ' || line[0] == '\t')
            {
                request.Headers.AppendToLast(line.Trim());
                continue;
            }
            int colon = line.IndexOf(':');
            if (colon <= 0)
                continue;
            if (request.Headers.All.Count() >= MaxHeaderCount)
                throw new HttpParseException(431, "Too many headers");
            request.Headers.Add(line[..colon].Trim(), line[(colon + 1)..].Trim());
        }

        request.Body = CreateBody(request, sendContinue);
        return request;
    }

    private Stream CreateBody(HttpRequest request, Func<Task> sendContinue)
    {
        bool expectContinue = request.IsHttp11OrLater &&
            string.Equals(request.Headers["Expect"], "100-continue", StringComparison.OrdinalIgnoreCase);
        Func<Task>? onFirstRead = expectContinue ? sendContinue : null;

        if (request.Headers.HasToken("Transfer-Encoding", "chunked"))
            return new ChunkedReadStream(this, onFirstRead);
        string? length = request.Headers["Content-Length"];
        if (length is not null)
        {
            if (!long.TryParse(length, NumberStyles.None, CultureInfo.InvariantCulture, out long value))
                throw new HttpParseException(400, "Bad Content-Length");
            if (value > 0)
                return new ContentLengthReadStream(this, value, onFirstRead);
        }
        return Stream.Null;
    }

    /// <summary>
    /// "/a%20b?x=1" o "http://host/a?x" -> ("/a%20b", "x=1"). Los bytes no ASCII que algunos
    /// clientes envian sin codificar se pasan a %XX para decodificarlos despues como el resto.
    /// </summary>
    internal static (string Path, string Query) SplitTarget(string target)
    {
        int hash = target.IndexOf('#');
        if (hash >= 0)
            target = target[..hash];
        if (target.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
            target.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
        {
            int start = target.IndexOf("//", StringComparison.Ordinal) + 2;
            int slash = target.IndexOf('/', start);
            target = slash < 0 ? "/" : target[slash..];
        }
        if (target.Length == 0 || target[0] != '/')
            target = "/" + target;

        var sb = new StringBuilder(target.Length);
        foreach (char c in target)
        {
            if (c >= 0x80 || c == ' ')
                sb.Append('%').Append(((int)c).ToString("X2", CultureInfo.InvariantCulture));
            else
                sb.Append(c);
        }
        string encoded = sb.ToString();
        int q = encoded.IndexOf('?');
        return q < 0 ? (encoded, "") : (encoded[..q], encoded[(q + 1)..]);
    }

    private static bool TryParseVersion(string text, out int major, out int minor)
    {
        major = minor = 0;
        if (!text.StartsWith("HTTP/", StringComparison.OrdinalIgnoreCase))
            return false;
        var nums = text[5..].Split('.');
        return nums.Length == 2 &&
               int.TryParse(nums[0], NumberStyles.None, CultureInfo.InvariantCulture, out major) &&
               int.TryParse(nums[1], NumberStyles.None, CultureInfo.InvariantCulture, out minor);
    }

    /// <summary>
    /// Lee una linea hasta LF (quitando el CR si lo hay), como Latin-1: cada byte es un caracter.
    /// Devuelve null si la conexion se cierra sin datos.
    /// </summary>
    internal async ValueTask<string?> ReadLineAsync(CancellationToken ct, bool countAsHeader = true)
    {
        if (!countAsHeader)
            _headerBytes = 0; // lineas de trozos del cuerpo: solo cuenta el limite de cada linea
        var sb = new StringBuilder();
        bool any = false;
        while (true)
        {
            if (_pos >= _len)
            {
                if (!await FillAsync(ct))
                {
                    if (!any)
                        return null;
                    throw new HttpParseException(400, "Connection closed in line");
                }
            }
            any = true;
            int lf = Array.IndexOf(_buffer, (byte)'\n', _pos, _len - _pos);
            int end = lf < 0 ? _len : lf;
            sb.Append(Encoding.Latin1.GetString(_buffer, _pos, end - _pos));
            _headerBytes += end - _pos + (lf < 0 ? 0 : 1);
            _pos = lf < 0 ? _len : lf + 1;
            if (sb.Length > MaxLineLength)
                throw new HttpParseException(414, "Line too long");
            if (_headerBytes > MaxHeaderBytes)
                throw new HttpParseException(431, "Headers too large");
            if (lf >= 0)
            {
                if (sb.Length > 0 && sb[^1] == '\r')
                    sb.Length--;
                return sb.ToString();
            }
        }
    }

    /// <summary>Lee datos del cuerpo: primero lo que quede en el bufer, despues del socket.</summary>
    internal async ValueTask<int> ReadRawAsync(Memory<byte> destination, CancellationToken ct)
    {
        if (_pos < _len)
        {
            int n = Math.Min(destination.Length, _len - _pos);
            _buffer.AsMemory(_pos, n).CopyTo(destination);
            _pos += n;
            return n;
        }
        return await _stream.ReadAsync(destination, ct);
    }

    private async ValueTask<bool> FillAsync(CancellationToken ct)
    {
        _pos = 0;
        _len = await _stream.ReadAsync(_buffer, ct);
        return _len > 0;
    }
}
