using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

namespace Lumi.Companion.SongOverlay.Spotify;

internal sealed record SpotifyEnrichment(string Link, string ReleaseYear, byte[]? CoverBytes, string CoverMime);

internal sealed class SpotifyWebApiEnricher : IDisposable
{
    private const string Scope = "user-read-currently-playing";
    private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
    private readonly SongOverlaySecretProtector _secrets;
    private readonly Action _save;
    private readonly Action<string, Exception?> _log;
    private readonly SongOverlaySettings _settings;
    private string _accessToken = "";
    private DateTimeOffset _accessTokenExpiresAt = DateTimeOffset.MinValue;

    public SpotifyWebApiEnricher(SongOverlaySettings settings, SongOverlaySecretProtector secrets, Action save, Action<string, Exception?> log)
    {
        _settings = settings;
        _secrets = secrets;
        _save = save;
        _log = log;
        _http.DefaultRequestHeaders.UserAgent.ParseAdd("Lumi-Companion-NowPlaying/0.1.0");
    }

    public bool IsConfigured => !string.IsNullOrWhiteSpace(_settings.SpotifyClientId) && !string.IsNullOrWhiteSpace(_settings.ProtectedSpotifyRefreshToken);

    public async Task AuthorizeAsync(CancellationToken cancellationToken)
    {
        var clientId = _settings.SpotifyClientId.Trim();
        if (string.IsNullOrWhiteSpace(clientId)) throw new InvalidOperationException("Enter your Spotify application Client ID first.");

        var verifier = Base64Url(RandomNumberGenerator.GetBytes(64));
        var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
        var state = Base64Url(RandomNumberGenerator.GetBytes(24));
        var port = FindFreePort();
        var redirectUri = $"http://127.0.0.1:{port}/callback/";
        using var listener = new HttpListener();
        listener.Prefixes.Add(redirectUri);
        listener.Start();

        var authorizationUrl = "https://accounts.spotify.com/authorize?" + BuildQuery(new Dictionary<string, string>
        {
            ["client_id"] = clientId,
            ["response_type"] = "code",
            ["redirect_uri"] = redirectUri,
            ["scope"] = Scope,
            ["code_challenge_method"] = "S256",
            ["code_challenge"] = challenge,
            ["state"] = state
        });
        Process.Start(new ProcessStartInfo(authorizationUrl) { UseShellExecute = true });

        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        timeout.CancelAfter(TimeSpan.FromMinutes(3));
        var contextTask = listener.GetContextAsync();
        var completed = await Task.WhenAny(contextTask, Task.Delay(Timeout.InfiniteTimeSpan, timeout.Token)).ConfigureAwait(false);
        if (completed != contextTask) throw new TimeoutException("Spotify authorization timed out.");
        var context = await contextTask.ConfigureAwait(false);
        var query = context.Request.QueryString;
        var responseText = "Spotify is connected to Lumi Companion. You can close this page.";
        try
        {
            if (!string.Equals(query["state"], state, StringComparison.Ordinal)) throw new InvalidOperationException("Spotify returned an invalid authorization state.");
            if (!string.IsNullOrWhiteSpace(query["error"])) throw new InvalidOperationException("Spotify authorization was declined: " + query["error"]);
            var code = query["code"] ?? throw new InvalidOperationException("Spotify did not return an authorization code.");
            var token = await ExchangeAsync(new Dictionary<string, string>
            {
                ["client_id"] = clientId,
                ["grant_type"] = "authorization_code",
                ["code"] = code,
                ["redirect_uri"] = redirectUri,
                ["code_verifier"] = verifier
            }, cancellationToken).ConfigureAwait(false);
            ApplyToken(token);
            if (string.IsNullOrWhiteSpace(token.RefreshToken)) throw new InvalidOperationException("Spotify did not provide a refresh token.");
            _settings.ProtectedSpotifyRefreshToken = _secrets.Protect(token.RefreshToken);
            _save();
        }
        catch
        {
            responseText = "Spotify could not be connected to Lumi Companion. Return to the app for details.";
            throw;
        }
        finally
        {
            var bytes = Encoding.UTF8.GetBytes($"<!doctype html><meta charset=\"utf-8\"><title>Lumi Companion</title><style>body{{font:18px system-ui;max-width:720px;margin:15vh auto;padding:24px;background:#111827;color:#f9fafb}}</style><h1>Lumi Companion</h1><p>{WebUtility.HtmlEncode(responseText)}</p>");
            context.Response.ContentType = "text/html; charset=utf-8";
            context.Response.ContentLength64 = bytes.Length;
            await context.Response.OutputStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
            context.Response.Close();
        }
    }

    public void Disconnect()
    {
        _accessToken = "";
        _accessTokenExpiresAt = DateTimeOffset.MinValue;
        _settings.ProtectedSpotifyRefreshToken = "";
        _save();
    }

    public async Task<SpotifyEnrichment?> EnrichAsync(MediaTrack track, CancellationToken cancellationToken)
    {
        if (!IsConfigured) return null;
        try
        {
            var token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
            if (string.IsNullOrWhiteSpace(token)) return null;
            using var response = await SendCurrentlyPlayingAsync(token, cancellationToken).ConfigureAwait(false);
            if (response.StatusCode == HttpStatusCode.NoContent) return null;
            if (response.StatusCode == HttpStatusCode.Unauthorized)
            {
                _accessToken = "";
                token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
                if (string.IsNullOrWhiteSpace(token)) return null;
                using var retry = await SendCurrentlyPlayingAsync(token, cancellationToken).ConfigureAwait(false);
                if (retry.StatusCode == HttpStatusCode.NoContent || !retry.IsSuccessStatusCode) return null;
                return await ParseEnrichmentAsync(retry, track, cancellationToken).ConfigureAwait(false);
            }
            if (!response.IsSuccessStatusCode) return null;
            return await ParseEnrichmentAsync(response, track, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception error)
        {
            _log("Spotify metadata enrichment failed", error);
            return null;
        }
    }


    private async Task<HttpResponseMessage> SendCurrentlyPlayingAsync(string token, CancellationToken cancellationToken)
    {
        using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.spotify.com/v1/me/player/currently-playing?additional_types=track");
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
        return await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
    }

    private async Task<SpotifyEnrichment?> ParseEnrichmentAsync(HttpResponseMessage response, MediaTrack track, CancellationToken cancellationToken)
    {
        using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
        if (!document.RootElement.TryGetProperty("item", out var item) || item.ValueKind != JsonValueKind.Object) return null;
        var title = String(item, "name");
        var artists = item.TryGetProperty("artists", out var artistArray) && artistArray.ValueKind == JsonValueKind.Array
            ? string.Join(", ", artistArray.EnumerateArray().Select(value => String(value, "name")).Where(value => value.Length > 0))
            : "";
        if (!LooseMatch(track.Title, title) || (!string.IsNullOrWhiteSpace(track.Artist) && !LooseMatch(track.Artist, artists))) return null;
        var link = item.TryGetProperty("external_urls", out var urls) ? String(urls, "spotify") : "";
        var releaseYear = "";
        string imageUrl = "";
        if (item.TryGetProperty("album", out var album))
        {
            var releaseDate = String(album, "release_date");
            if (releaseDate.Length >= 4) releaseYear = releaseDate[..4];
            if (album.TryGetProperty("images", out var images) && images.ValueKind == JsonValueKind.Array)
                imageUrl = images.EnumerateArray().Select(value => String(value, "url")).FirstOrDefault(value => value.Length > 0) ?? "";
        }
        byte[]? cover = null;
        var mime = "image/jpeg";
        if (!string.IsNullOrWhiteSpace(imageUrl))
        {
            using var imageResponse = await _http.GetAsync(imageUrl, cancellationToken).ConfigureAwait(false);
            if (imageResponse.IsSuccessStatusCode)
            {
                cover = await imageResponse.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
                if (cover.Length > 3 * 1024 * 1024) cover = null;
                mime = imageResponse.Content.Headers.ContentType?.MediaType ?? mime;
            }
        }
        return new SpotifyEnrichment(link, releaseYear, cover, mime);
    }

    private async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
    {
        if (!string.IsNullOrWhiteSpace(_accessToken) && _accessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(1)) return _accessToken;
        var refreshToken = _secrets.Unprotect(_settings.ProtectedSpotifyRefreshToken);
        if (string.IsNullOrWhiteSpace(refreshToken)) return "";
        try
        {
            var token = await ExchangeAsync(new Dictionary<string, string>
            {
                ["client_id"] = _settings.SpotifyClientId.Trim(),
                ["grant_type"] = "refresh_token",
                ["refresh_token"] = refreshToken
            }, cancellationToken).ConfigureAwait(false);
            ApplyToken(token);
            if (!string.IsNullOrWhiteSpace(token.RefreshToken))
            {
                _settings.ProtectedSpotifyRefreshToken = _secrets.Protect(token.RefreshToken);
                _save();
            }
            return _accessToken;
        }
        catch (SpotifyInvalidGrantException)
        {
            Disconnect();
            return "";
        }
    }

    private async Task<TokenResponse> ExchangeAsync(Dictionary<string, string> values, CancellationToken cancellationToken)
    {
        using var response = await _http.PostAsync("https://accounts.spotify.com/api/token", new FormUrlEncodedContent(values), cancellationToken).ConfigureAwait(false);
        var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
        if (!response.IsSuccessStatusCode)
        {
            if (body.Contains("invalid_grant", StringComparison.OrdinalIgnoreCase)) throw new SpotifyInvalidGrantException();
            throw new InvalidOperationException($"Spotify token exchange failed with HTTP {(int)response.StatusCode}.");
        }
        using var document = JsonDocument.Parse(body);
        return new TokenResponse(
            String(document.RootElement, "access_token"),
            String(document.RootElement, "refresh_token"),
            document.RootElement.TryGetProperty("expires_in", out var expires) ? expires.GetInt32() : 3600);
    }

    private void ApplyToken(TokenResponse token)
    {
        _accessToken = token.AccessToken;
        _accessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, token.ExpiresIn));
    }

    private static string String(JsonElement element, string property) =>
        element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : "";

    private static bool LooseMatch(string left, string right)
    {
        static string Normalize(string value) => new(value.ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
        var a = Normalize(left); var b = Normalize(right);
        return a.Length > 0 && b.Length > 0 && (a == b || a.Contains(b, StringComparison.Ordinal) || b.Contains(a, StringComparison.Ordinal));
    }

    private static int FindFreePort()
    {
        var listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();
        var port = ((IPEndPoint)listener.LocalEndpoint).Port;
        listener.Stop();
        return port;
    }

    private static string BuildQuery(IEnumerable<KeyValuePair<string, string>> values) =>
        string.Join("&", values.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
    private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');

    public void Dispose() => _http.Dispose();
    private sealed record TokenResponse(string AccessToken, string RefreshToken, int ExpiresIn);
    private sealed class SpotifyInvalidGrantException : Exception { }
}
