using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.RegularExpressions;
using Motus.Abstractions;

namespace Motus;

/// <summary>
/// Represents a single browser page (tab).
/// </summary>
internal sealed partial class Page : IPage
{
    private readonly IMotusSession _session;
    private readonly BrowserContext _context;
    private readonly string _targetId;
    private readonly CancellationTokenSource _pageCts = new();

    private readonly ConcurrentDictionary<string, Frame> _frames = new();
    private readonly ConcurrentDictionary<int, string> _executionContextToFrameId = new();
    private readonly ConcurrentDictionary<string, int> _frameIdToExecutionContext = new();
    private readonly ConcurrentDictionary<string, Download> _downloads = new();
    private readonly ConcurrentDictionary<string, Func<object?[], Task<object?>>> _bindings = new();
    private readonly List<string> _initScripts = [];

    private readonly Keyboard _keyboard;
    private readonly Mouse _mouse;
    private readonly Touchscreen _touchscreen;

    private string? _mainFrameId;
    private ViewportSize? _viewportSize;
    private volatile bool _isClosed;
    private VideoRecorder? _videoRecorder;
    private readonly ConcurrentBag<string> _uploadTempDirs = [];

    internal Page(IMotusSession session, BrowserContext context, string targetId)
    {
        _session = session;
        _context = context;
        _targetId = targetId;
        _keyboard = new Keyboard(session, _pageCts.Token);
        _mouse = new Mouse(session, _pageCts.Token);
        _touchscreen = new Touchscreen(session, _pageCts.Token);
    }

    public IBrowserContext Context => _context;

    public IFrame MainFrame =>
        _mainFrameId is not null && _frames.TryGetValue(_mainFrameId, out var frame)
            ? frame
            : throw new InvalidOperationException("Main frame has not been initialized.");

    public IReadOnlyList<IFrame> Frames => _frames.Values.ToList();

    public string Url => _mainFrameId is not null && _frames.TryGetValue(_mainFrameId, out var f)
        ? f.Url
        : string.Empty;

    public bool IsClosed => _isClosed;

    public ViewportSize? ViewportSize => _viewportSize;

    public IVideo? Video => _videoRecorder?.Video;

    public IKeyboard Keyboard => _keyboard;

    public IMouse Mouse => _mouse;

    public ITouchscreen Touchscreen => _touchscreen;

    // --- Events ---
    public event EventHandler? Close;
    public event EventHandler<ConsoleMessageEventArgs>? Console;
    public event EventHandler<DialogEventArgs>? Dialog;
    public event EventHandler<IDownload>? Download;
    public event EventHandler<IFileChooser>? FileChooser;
    public event EventHandler<PageErrorEventArgs>? PageError;
    public event EventHandler<IPage>? Popup;
    public event EventHandler<RequestEventArgs>? Request;
    public event EventHandler<RequestEventArgs>? RequestFailed;
    public event EventHandler<RequestEventArgs>? RequestFinished;
    public event EventHandler<ResponseEventArgs>? Response;

    // --- Internal events for extensions (e.g. Recorder) ---
    internal event Action<string>? FrameNavigated;
    internal event Action<string, bool, string?>? DialogHandled;

    internal IMotusSession Session => _session;

    internal CancellationToken PageLifetimeToken => _pageCts.Token;

    internal BrowserContext ContextInternal => _context;

    internal Keyboard KeyboardInternal => _keyboard;

    internal Mouse MouseInternal => _mouse;

    internal Touchscreen TouchscreenInternal => _touchscreen;

    internal void SetVideoRecorder(VideoRecorder recorder) => _videoRecorder = recorder;

    // Upload payloads are staged on disk and read lazily by the browser, so the
    // backing directories must survive until the page goes away (see
    // Locator.SetInputFilesAsync). Registered dirs are deleted on dispose.
    internal void RegisterUploadTempDir(string dir) => _uploadTempDirs.Add(dir);

    internal async Task InitializeAsync(CancellationToken ct)
    {
        // Subscribe to event channels BEFORE enabling CDP domains.
        // Chrome fires events immediately after Page.enable / Runtime.enable,
        // so the pump must already be listening or early events are dropped
        // (e.g. the initial executionContextCreated that provides the main-frame context ID).
        StartEventPump();

        await _session.SendAsync("Page.enable", CdpJsonContext.Default.PageEnableResult, ct).ConfigureAwait(false);
        await _session.SendAsync("Runtime.enable", CdpJsonContext.Default.RuntimeEnableResult, ct).ConfigureAwait(false);

        // Enable file chooser interception
        await _session.SendAsync(
            "Page.setInterceptFileChooserDialog",
            new PageSetInterceptFileChooserDialogParams(Enabled: true),
            CdpJsonContext.Default.PageSetInterceptFileChooserDialogParams,
            ct).ConfigureAwait(false);

        // Apply any init scripts from the context
        foreach (var script in _context.InitScripts)
        {
            await _session.SendAsync(
                "Page.addScriptToEvaluateOnNewDocument",
                new PageAddScriptToEvaluateOnNewDocumentParams(script),
                CdpJsonContext.Default.PageAddScriptToEvaluateOnNewDocumentParams,
                CdpJsonContext.Default.PageAddScriptToEvaluateOnNewDocumentResult,
                ct).ConfigureAwait(false);
        }

        // Apply any bindings from the context
        foreach (var (name, callback) in _context.Bindings)
        {
            _bindings[name] = callback;
            await _session.SendAsync(
                "Runtime.addBinding",
                new RuntimeAddBindingParams(name),
                CdpJsonContext.Default.RuntimeAddBindingParams,
                CdpJsonContext.Default.RuntimeAddBindingResult,
                ct).ConfigureAwait(false);
        }

        // Initialize network monitoring and interception
        await InitializeNetworkAsync(ct).ConfigureAwait(false);

        // In BiDi, domain enables are no-ops and don't fire frameNavigated events,
        // so _mainFrameId may still be null. The browsing context ID (targetId) serves
        // as the frame identifier in BiDi.
        if (_mainFrameId is null && _session is BiDiSession)
            _mainFrameId = _targetId;
    }

    /// <summary>
    /// Returns the main frame if available, or a lightweight synthetic frame
    /// for selector strategy dispatch. The synthetic frame is not stored in
    /// the page's frame collection so it doesn't affect frame enumeration.
    /// </summary>
    internal IFrame GetFrameForSelectors() =>
        _mainFrameId is not null && _frames.TryGetValue(_mainFrameId, out var frame)
            ? frame
            : new Frame(this, "__selector__", parentFrameId: null);

    internal bool TryGetFrame(string frameId, out Frame? frame) =>
        _frames.TryGetValue(frameId, out frame);

    internal IReadOnlyList<IFrame> GetChildFrames(string parentFrameId) =>
        _frames.Values.Where(f => f.ParentFrameId == parentFrameId).ToList<IFrame>();

    internal int? GetExecutionContextId(string frameId) =>
        _frameIdToExecutionContext.TryGetValue(frameId, out var id) ? id : null;

    internal async Task<T> EvaluateInFrameAsync<T>(string frameId, string expression, object? arg = null)
    {
        var contextId = GetExecutionContextId(frameId);

        var result = await _session.SendAsync(
            "Runtime.evaluate",
            new RuntimeEvaluateParams(
                Expression: WrapExpression(expression, arg),
                ReturnByValue: true,
                AwaitPromise: true,
                ContextId: contextId),
            CdpJsonContext.Default.RuntimeEvaluateParams,
            CdpJsonContext.Default.RuntimeEvaluateResult,
            CancellationToken.None).ConfigureAwait(false);

        if (result.ExceptionDetails is not null)
            throw new InvalidOperationException(
                $"Evaluation failed: {result.ExceptionDetails.Text}");

        return DeserializeRemoteObject<T>(result.Result);
    }

    internal async Task<T> WaitForFunctionInFrameAsync<T>(
        string frameId, string expression, object? arg, double? timeout)
    {
        var deadline = TimeSpan.FromMilliseconds(timeout ?? 30_000);
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(_pageCts.Token);
        cts.CancelAfter(deadline);

        while (!cts.Token.IsCancellationRequested)
        {
            try
            {
                var result = await EvaluateInFrameAsync<JsonElement>(frameId, expression, arg).ConfigureAwait(false);

                if (IsTruthy(result))
                    return result.Deserialize<T>()!;
            }
            catch (InvalidOperationException)
            {
                // Evaluation failed, will retry
            }

            try
            {
                await Task.Delay(100, cts.Token).ConfigureAwait(false);
            }
            catch (OperationCanceledException)
            {
                break;
            }
        }

        _pageCts.Token.ThrowIfCancellationRequested();
        throw new TimeoutException(
            $"WaitForFunction timed out after {deadline.TotalMilliseconds}ms.");
    }

    internal async Task AddInitScriptInternalAsync(string script)
    {
        _initScripts.Add(script);
        await _session.SendAsync(
            "Page.addScriptToEvaluateOnNewDocument",
            new PageAddScriptToEvaluateOnNewDocumentParams(script),
            CdpJsonContext.Default.PageAddScriptToEvaluateOnNewDocumentParams,
            CdpJsonContext.Default.PageAddScriptToEvaluateOnNewDocumentResult,
            CancellationToken.None).ConfigureAwait(false);
    }

    internal async Task ExposeBindingInternalAsync(string name, Func<object?[], Task<object?>> callback)
    {
        _bindings[name] = callback;
        await _session.SendAsync(
            "Runtime.addBinding",
            new RuntimeAddBindingParams(name),
            CdpJsonContext.Default.RuntimeAddBindingParams,
            CdpJsonContext.Default.RuntimeAddBindingResult,
            CancellationToken.None).ConfigureAwait(false);
    }

    public async ValueTask DisposeAsync()
    {
        if (_isClosed)
            return;

        _isClosed = true;

        if (_videoRecorder is not null)
        {
            try { await _videoRecorder.StopAndFinalizeAsync().ConfigureAwait(false); }
            catch { /* best-effort */ }
        }

        foreach (var dir in _uploadTempDirs)
        {
            try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ }
        }

        Close?.Invoke(this, EventArgs.Empty);
        _pageCts.Cancel();
        // Do not dispose _pageCts here; fire-and-forget handlers triggered by Close
        // may still reference the token (e.g. ScreencastService.StopAsync).
        // The CTS will be collected once all references are released.
    }

    private static readonly Regex FunctionKeywordRegex = new(
        @"^(async\s+)?function\b", RegexOptions.Compiled);

    private static readonly Regex IdentifierArrowRegex = new(
        @"^(async\s+)?[\p{L}_$][\p{L}\p{N}_$]*\s*=>", RegexOptions.Compiled);

    internal static bool LooksLikeFunctionExpression(string expression)
    {
        var trimmed = expression.TrimStart();

        if (FunctionKeywordRegex.IsMatch(trimmed))
            return true;

        if (IdentifierArrowRegex.IsMatch(trimmed))
            return true;

        return IsParenthesizedArrow(trimmed);
    }

    // Detects "(...) => ..." or "async (...) => ..." where the paren group
    // closes at the top level and is followed by "=>". An already-invoked
    // IIFE like "(() => 1)()" is intentionally rejected because the trailing
    // "(" indicates a call, not a function literal.
    private static bool IsParenthesizedArrow(string s)
    {
        int i = 0;
        if (s.Length >= 5 && s.StartsWith("async", StringComparison.Ordinal)
            && i + 5 < s.Length && char.IsWhiteSpace(s[5]))
        {
            i = 5;
            while (i < s.Length && char.IsWhiteSpace(s[i])) i++;
        }

        if (i >= s.Length || s[i] != '(') return false;

        int depth = 0;
        for (; i < s.Length; i++)
        {
            if (s[i] == '(') depth++;
            else if (s[i] == ')')
            {
                depth--;
                if (depth == 0) { i++; break; }
            }
        }
        if (depth != 0) return false;

        while (i < s.Length && char.IsWhiteSpace(s[i])) i++;

        return i + 1 < s.Length && s[i] == '=' && s[i + 1] == '>';
    }

    private static string WrapExpression(string expression, object? arg)
    {
        if (LooksLikeFunctionExpression(expression))
        {
            var serialized = arg is null ? "undefined" : JsonSerializer.Serialize(arg);
            return $"(({expression})({serialized}))";
        }

        if (arg is not null)
            throw new InvalidOperationException(
                "Cannot pass an argument to a non-function expression. " +
                "Use an arrow function or function literal.");

        return expression;
    }

    private static T DeserializeRemoteObject<T>(RuntimeRemoteObject remoteObject)
    {
        if (remoteObject.Value is JsonElement element)
        {
            if (element.ValueKind == JsonValueKind.Null)
                return default!;
            return element.Deserialize<T>()
                   ?? throw new InvalidOperationException("Deserialization returned null.");
        }

        if (remoteObject.Type == "undefined" || remoteObject.Subtype == "null")
            return default!;

        throw new InvalidOperationException(
            $"Cannot deserialize remote object of type '{remoteObject.Type}' to {typeof(T).Name}.");
    }

    private static bool IsTruthy(JsonElement element) =>
        element.ValueKind switch
        {
            JsonValueKind.True => true,
            JsonValueKind.Number => element.GetDouble() != 0,
            JsonValueKind.String => element.GetString()?.Length > 0,
            JsonValueKind.Object or JsonValueKind.Array => true,
            _ => false
        };
}
