using System.Globalization;
using LanguageExt;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using static LanguageExt.Prelude;

namespace ErsatzTV.Extensions;

/// <summary>Classification of a request's <c>If-Match</c> header for the #253 concurrency contract.</summary>
public enum IfMatchKind
{
    /// <summary>No <c>If-Match</c> header — Phase 1 force-write (Phase 2 will make this a 428).</summary>
    Absent,

    /// <summary><c>If-Match: *</c> — the scripted force-write escape hatch; skip the version check.</summary>
    Any,

    /// <summary>
    ///     A syntactically-valid <c>If-Match</c> entity-tag (or comma-separated list). The mutation
    ///     proceeds only when the loaded aggregate's version is one of
    ///     <see cref="IfMatchCondition.Versions" />; otherwise 412. An empty set (only weak and/or
    ///     non-canonical tags were supplied) can never strong-match → always 412 (issue #265).
    /// </summary>
    Version,

    /// <summary>A header that violates the RFC 7232 entity-tag grammar — the controller returns 400.</summary>
    Malformed
}

public readonly record struct IfMatchCondition(IfMatchKind Kind, Seq<int> Versions)
{
    /// <summary>
    ///     The candidate versions, any one of which strong-matches (RFC 7232 §2.3.2), or <c>None</c> for
    ///     absent/wildcard (force-write). A <see cref="IfMatchKind.Version" /> with an <b>empty</b> set is a
    ///     guaranteed no-match (→ 412): the caller sent a syntactically-valid tag that can never
    ///     strong-match what we emit (a weak tag, or a non-canonical/out-of-range strong tag).
    /// </summary>
    public Option<Seq<int>> ExpectedVersions =>
        Kind == IfMatchKind.Version ? Some(Versions) : Option<Seq<int>>.None;
}

/// <summary>
///     Parse/emit the optimistic-concurrency HTTP headers (issue #253). GET responses carry a strong
///     <c>ETag</c> of the aggregate's integer <c>Version</c>; PUT requests carry the last-seen version
///     in <c>If-Match</c>. See <c>docs/api-conventions.md</c> §7a.
/// </summary>
public static class ConcurrencyHeaders
{
    public static IfMatchCondition ParseIfMatch(HttpRequest request)
    {
        StringValues raw = request.Headers.IfMatch;
        if (StringValues.IsNullOrEmpty(raw))
        {
            return new IfMatchCondition(IfMatchKind.Absent, LanguageExt.Seq<int>.Empty);
        }

        // RFC 7230 OWS is SP / HTAB only. Do NOT use string.Trim(), which also strips NBSP and other
        // Unicode whitespace — that would let " * " masquerade as the "*" force-write escape.
        string value = raw.ToString().Trim(' ', '\t');
        if (value == "*")
        {
            return new IfMatchCondition(IfMatchKind.Any, LanguageExt.Seq<int>.Empty);
        }

        // If-Match = "*" / 1#entity-tag  (RFC 7232 §3.1). We SCAN the list rather than Split(',') because a
        // comma (0x2C) is a valid etagc and may appear INSIDE a quoted opaque-tag ("3,5" is one tag, not
        // two) — a comma separates members only outside the quotes. Each member is [ "W/" ] DQUOTE *etagc
        // DQUOTE; any grammar violation → Malformed (400). A well-formed member that does not strong-match
        // (a weak tag, or a strong tag whose opaque text isn't the exact canonical decimal we emit — "03",
        // "3.0", "", an out-of-range value) contributes no version; if none strong-match the result is a
        // guaranteed 412, never 400 (the pre-#265 400-of-any-non-canonical value was RFC-incorrect, #265).
        var versions = new List<int>();
        bool sawTag = false;
        int i = 0;
        int n = value.Length;
        while (i < n)
        {
            // Skip OWS and empty list elements (RFC 7230 §7: tolerate stray/empty members).
            while (i < n && (value[i] is ' ' or '\t' or ','))
            {
                i++;
            }

            if (i >= n)
            {
                break;
            }

            // Optional weak indicator — case-sensitive "W/" per %s"W/"; a weak tag never strong-matches.
            bool weak = false;
            if (i + 1 < n && value[i] == 'W' && value[i + 1] == '/')
            {
                weak = true;
                i += 2;
            }

            // opaque-tag = DQUOTE *etagc DQUOTE
            if (i >= n || value[i] != '"')
            {
                return new IfMatchCondition(IfMatchKind.Malformed, LanguageExt.Seq<int>.Empty);
            }

            i++; // opening quote
            int innerStart = i;
            while (i < n && value[i] != '"')
            {
                if (!IsEtagc(value[i]))
                {
                    return new IfMatchCondition(IfMatchKind.Malformed, LanguageExt.Seq<int>.Empty);
                }

                i++;
            }

            if (i >= n) // unterminated opaque-tag
            {
                return new IfMatchCondition(IfMatchKind.Malformed, LanguageExt.Seq<int>.Empty);
            }

            string inner = value[innerStart..i];
            i++; // closing quote
            sawTag = true;

            if (!weak && TryCanonicalVersion(inner, out int version))
            {
                versions.Add(version);
            }

            // After a member, only OWS then a comma (or end) is legal — anything else ("3"junk, "3""5")
            // is a grammar violation. The comma itself is consumed by the OWS/empty-element skip above.
            while (i < n && value[i] is ' ' or '\t')
            {
                i++;
            }

            if (i < n && value[i] != ',')
            {
                return new IfMatchCondition(IfMatchKind.Malformed, LanguageExt.Seq<int>.Empty);
            }
        }

        // A header that held only separators/whitespace has no entity-tag at all → grammar violation.
        return sawTag
            ? new IfMatchCondition(IfMatchKind.Version, versions.ToSeq())
            : new IfMatchCondition(IfMatchKind.Malformed, LanguageExt.Seq<int>.Empty);
    }

    public static void SetETag(HttpResponse response, int version) =>
        response.Headers.ETag = $"\"{version}\"";

    /// <summary>
    ///     The 400 response for a syntactically-invalid <c>If-Match</c> header
    ///     (<see cref="IfMatchKind.Malformed" />). Shared by the replace-all PUT endpoints so the
    ///     fail-safe rejection wording stays consistent (issue #253 §7a).
    /// </summary>
    public static IActionResult MalformedIfMatchProblem() =>
        new BadRequestObjectResult(
            new ProblemDetails
            {
                Status = StatusCodes.Status400BadRequest,
                Title = "Invalid If-Match header",
                Detail = "If-Match must be one or more entity-tags (e.g. \"3\") or \"*\"."
            });

    /// <summary>
    ///     Whether a strong tag's opaque text is the exact canonical decimal we emit — the only value that
    ///     can strong-match our ETag. An ETag is opaque, so only the canonical form matches: a non-negative
    ///     decimal, no sign, no surrounding whitespace, no leading zero (`NumberStyles.None` + the
    ///     leading-zero guard reject "+3", " 3 ", and "03", which must NOT be treated as equal to "3").
    /// </summary>
    private static bool TryCanonicalVersion(string inner, out int version)
    {
        if (inner.Length > 0 && (inner.Length == 1 || inner[0] != '0') &&
            int.TryParse(inner, NumberStyles.None, CultureInfo.InvariantCulture, out version))
        {
            return true;
        }

        version = 0;
        return false;
    }

    // RFC 7232 §2.3: etagc = %x21 / %x23-7E / obs-text (%x80-FF). Excludes DQUOTE (%x22) and controls.
    private static bool IsEtagc(char c) => c == '\x21' || (c >= '\x23' && c <= '\x7E') || (c >= '\x80' && c <= '\xFF');
}
