import UIKit
import WebKit

final class DreamioWebViewController: UIViewController {
    private enum Constants {
        static let stremioWebURL = URL(string: "https://web.stremio.com/")!
        static let diagnosticsMessageHandler = "dreamioDiagnostics"
        static let streamCandidateMessageHandler = "dreamioStreamCandidate"
        static let subtitleCandidateMessageHandler = "dreamioSubtitleCandidate"
    }

    private lazy var webView: WKWebView = {
        let configuration = WKWebViewConfiguration()
        configuration.defaultWebpagePreferences.allowsContentJavaScript = true
        configuration.allowsInlineMediaPlayback = true
        configuration.mediaTypesRequiringUserActionForPlayback = []
        configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
        configuration.userContentController.add(
            WeakScriptMessageHandler(delegate: self),
            name: Constants.streamCandidateMessageHandler
        )
        configuration.userContentController.add(
            WeakScriptMessageHandler(delegate: self),
            name: Constants.subtitleCandidateMessageHandler
        )
        configuration.userContentController.addUserScript(Self.streamCandidateScript)
#if DEBUG
        configuration.userContentController.add(
            WeakScriptMessageHandler(delegate: self),
            name: Constants.diagnosticsMessageHandler
        )
        configuration.userContentController.addUserScript(Self.playbackDiagnosticsScript)
#endif

        let webView = WKWebView(frame: .zero, configuration: configuration)
        webView.translatesAutoresizingMaskIntoConstraints = false
        webView.allowsBackForwardNavigationGestures = true
        webView.customUserAgent = "Dreamio/0.1 WKWebView"
        webView.navigationDelegate = self
        webView.uiDelegate = self
        webView.scrollView.contentInsetAdjustmentBehavior = .never
#if DEBUG
        if #available(iOS 16.4, *) {
            webView.isInspectable = true
        }
#endif
        return webView
    }()

    private let progressView: UIProgressView = {
        let view = UIProgressView(progressViewStyle: .bar)
        view.translatesAutoresizingMaskIntoConstraints = false
        view.tintColor = UIColor(red: 0.55, green: 0.35, blue: 0.95, alpha: 1.0)
        return view
    }()

    private var progressObservation: NSKeyValueObservation?
    private var userAgent: String?
    private var lastNativePlaybackURL: URL?
    private var pendingSubtitleCandidatesByStreamKey: [URL: [SubtitleCandidate]] = [:]
    private var currentNativePlaybackKey: URL?
    private weak var currentNativePlayer: NativePlayerViewController?
    private let streamResolver: StreamResolving = StremioStreamResolver()

    private static let streamCandidateScript = WKUserScript(
        source: #"""
        (() => {
          if (window.__dreamioStreamBridgeInstalled) {
            return;
          }
          window.__dreamioStreamBridgeInstalled = true;

          const nativePatterns = [
            /\/\/addon\.debridio\.com\/play\//i,
            /\/\/torrentio\.strem\.fun\/resolve\//i,
            /\/\/download\.real-debrid\.com\//i,
            /\.(mkv|avi|webm)(?:[?#]|$)/i
          ];
          const compatiblePatterns = [
            /\.m3u8(?:[?#]|$)/i,
            /\.mp4(?:[?#]|$)/i
          ];
          const subtitleCandidates = [];
          const postedSubtitleURLs = new Set();
          const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
          const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i;
          const subtitleExtensions = new Set(["srt", "vtt", "ass", "ssa", "sub"]);
          const nonSubtitleExtensions = new Set([
            "aac", "avi", "bmp", "css", "gif", "heic", "ico", "jpeg", "jpg", "js", "json",
            "m4a", "m4v", "mkv", "mov", "mp3", "mp4", "mpeg", "mpg", "png", "svg", "ts", "webm", "webp"
          ]);
          const subtitleObjectKeys = [
            "attributes",
            "files",
            "file_id",
            "url",
            "download",
            "link",
            "file",
            "file_name",
            "filename",
            "language",
            "lang"
          ];

          const looksNative = (url) => {
            if (!url || typeof url !== "string") {
              return false;
            }
            const directMatch = nativePatterns.some((pattern) => pattern.test(url));
            const compatibleMatch = compatiblePatterns.some((pattern) => pattern.test(url));
            return directMatch || (!compatibleMatch && /\.(mkv|avi|webm)(?:[?#]|$)/i.test(url));
          };

          const absoluteURL = (url) => {
            try {
              return new URL(url, window.location.href).href;
            } catch (_) {
              return "";
            }
          };

          const isOpenSubtitlesManifestID = (url) => {
            try {
              const parsed = new URL(url, window.location.href);
              return /opensubtitles/i.test(parsed.hostname)
                && /\/manifest\.json(?:_\d+)?$/i.test(parsed.pathname);
            } catch (_) {
              return false;
            }
          };

          const isDirectSubtitleFileURL = (url) => {
            try {
              const parsed = new URL(url, window.location.href);
              const extension = parsed.pathname.split(".").pop().toLowerCase();
              return subtitleExtensions.has(extension)
                || Array.from(subtitleExtensions).some((ext) => parsed.href.toLowerCase().includes(`.${ext}?`) || parsed.href.toLowerCase().includes(`.${ext}&`));
            } catch (_) {
              return false;
            }
          };

          const isProbablyNonSubtitleAssetURL = (url) => {
            try {
              const extension = new URL(url, window.location.href).pathname.split(".").pop().toLowerCase();
              return nonSubtitleExtensions.has(extension);
            } catch (_) {
              return false;
            }
          };

          const isOpenSubtitlesDownloadURL = (url) => {
            try {
              const parsed = new URL(url, window.location.href);
              const host = parsed.hostname.toLowerCase();
              const path = parsed.pathname.toLowerCase();
              if (!host.includes("opensubtitles")) {
                return false;
              }
              if (/\/manifest\.json(?:_\d+)?$/i.test(path)) {
                return false;
              }
              return /\/api\/v1\/download(?:\/|$)/i.test(path)
                || /\/download(?:\/|$)/i.test(path)
                || /\/subtitles?(?:\/|$)/i.test(path);
            } catch (_) {
              return false;
            }
          };

          const isStremioSubtitleDownloadURL = (url) => {
            try {
              const parsed = new URL(url, window.location.href);
              const host = parsed.hostname.toLowerCase();
              const path = parsed.pathname.toLowerCase();
              return host === "strem.io" || host.endsWith(".strem.io")
                ? /\/[a-z]{2,3}\/download(?:\/|$)/i.test(path) || /\/download(?:\/|$)/i.test(path)
                : false;
            } catch (_) {
              return false;
            }
          };

          const isSubtitleURL = (url) => {
            if (!url || isOpenSubtitlesManifestID(url)) {
              return false;
            }
            return !isProbablyNonSubtitleAssetURL(url)
              && (isDirectSubtitleFileURL(url) || isOpenSubtitlesDownloadURL(url) || isStremioSubtitleDownloadURL(url));
          };

          const findResolverURL = () => {
            const links = Array.from(document.querySelectorAll("a[href], [data-href], [data-url]"));
            const match = links
              .map((node) => node.getAttribute("href") || node.getAttribute("data-href") || node.getAttribute("data-url"))
              .map(absoluteURL)
              .find((url) => nativePatterns.some((pattern) => pattern.test(url)));
            return match || "";
          };

          const postCandidate = (rawURL, element) => {
            const url = absoluteURL(rawURL);
            if (!looksNative(url)) {
              return;
            }
            stopNativeHandledMedia(element);
            try {
              window.webkit.messageHandlers.dreamioStreamCandidate.postMessage({
                url,
                resolverUrl: findResolverURL(),
                pageUrl: window.location.href,
                tagName: element && element.tagName ? element.tagName : "",
                currentSrc: element && element.currentSrc ? element.currentSrc : "",
                subtitles: subtitleCandidates.slice(-20)
              });
            } catch (_) {}
          };

          const postSubtitleCandidates = (candidates, debug = {}) => {
            const discoveredCount = candidates.length;
            const fresh = candidates.filter((candidate) => {
              const key = candidate && (candidate.url || candidate.link || candidate.download || candidate.file || candidate.file_id);
              if (!key) {
                return false;
              }
              if (postedSubtitleURLs.has(String(key))) {
                return false;
              }
              postedSubtitleURLs.add(String(key));
              return true;
            });
            if (fresh.length === 0) {
              try {
                window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({
                  pageUrl: window.location.href,
                  subtitles: [],
                  debug: {
                    discovered: discoveredCount,
                    deduped: 0,
                    forwarded: 0,
                    ...debug
                  }
                });
              } catch (_) {}
              return;
            }
            try {
              window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({
                pageUrl: window.location.href,
                subtitles: fresh,
                debug: {
                  discovered: discoveredCount,
                  deduped: fresh.length,
                  forwarded: fresh.length,
                  ...debug
                }
              });
            } catch (_) {}
          };

          const addSubtitleCandidate = (entry) => {
            const rawURL = typeof entry === "string"
              ? entry
              : entry && (
                entry.url ||
                entry.href ||
                entry.src ||
                entry.link ||
                entry.file ||
                entry.download ||
                entry.externalUrl ||
                entry.externalURL ||
                entry.fileUrl ||
                entry.fileURL
              );
            let url = absoluteURL(rawURL);
            if ((!url || isOpenSubtitlesManifestID(url)) && entry && entry.file_id) {
              url = `https://api.opensubtitles.com/api/v1/download/${encodeURIComponent(String(entry.file_id))}`;
            }
            if (!isSubtitleURL(url)) {
              return;
            }
            if (subtitleCandidates.some((candidate) => candidate.url === url)) {
              return;
            }
            const candidate = {
              url,
              label: entry && (entry.label || entry.name || entry.title || entry.lang || entry.language) || "External Subtitle",
              language: entry && (entry.lang || entry.language) || ""
            };
            subtitleCandidates.push(candidate);
            postSubtitleCandidates([candidate], {
              discovered: 1,
              totalKnown: subtitleCandidates.length
            });
          };

          const inspectTrack = (track) => {
            if (!track) {
              return;
            }
            if (track instanceof HTMLTrackElement) {
              addSubtitleCandidate({
                url: track.src || track.getAttribute("src") || "",
                label: track.label || track.srclang || "External Subtitle",
                language: track.srclang || ""
              });
              return;
            }
            const source = track.src || track.url || "";
            if (source) {
              addSubtitleCandidate({
                url: source,
                label: track.label || track.language || track.kind || "External Subtitle",
                language: track.language || ""
              });
            }
          };

          const inspectTextTracks = (media) => {
            try {
              Array.from(media.textTracks || []).forEach(inspectTrack);
            } catch (_) {}
            try {
              media.querySelectorAll("track").forEach(inspectTrack);
            } catch (_) {}
          };

          const postSubtitleInspection = (source, url, beforeCount, afterCount, payloadLength) => {
            if (afterCount > beforeCount) {
              return;
            }
            postSubtitleCandidates([], {
              source,
              inspected: true,
              url: url || "",
              payloadLength: payloadLength || 0,
              totalKnown: subtitleCandidates.length
            });
          };

          const inspectSubtitlePayload = (payload) => {
            if (!payload) {
              return;
            }
            if (typeof payload === "string") {
              const matches = payload.match(subtitleURLPattern) || [];
              subtitleURLPattern.lastIndex = 0;
              matches.forEach(addSubtitleCandidate);
              try {
                inspectSubtitlePayload(JSON.parse(payload));
              } catch (_) {}
              return;
            }
            if (Array.isArray(payload)) {
              payload.forEach(inspectSubtitlePayload);
              return;
            }
            if (typeof payload === "object") {
              addSubtitleCandidate(payload);
              const likelySubtitlePayload = subtitleObjectKeys.some((key) => Object.prototype.hasOwnProperty.call(payload, key));
              if (likelySubtitlePayload) {
                postSubtitleCandidates([payload], {
                  source: "payload-object",
                  totalKnown: subtitleCandidates.length
                });
              }
              Object.values(payload).forEach(inspectSubtitlePayload);
            }
          };

          const inspectSubtitleText = (source, url, text) => {
            const beforeCount = subtitleCandidates.length;
            inspectSubtitlePayload(text);
            postSubtitleInspection(source, url, beforeCount, subtitleCandidates.length, text ? text.length : 0);
          };

          const inspectMessagePayload = (source, payload) => {
            const beforeCount = subtitleCandidates.length;
            inspectSubtitlePayload(payload);
            postSubtitleInspection(source, "", beforeCount, subtitleCandidates.length, 0);
          };

          const originalFetch = window.fetch;
          if (originalFetch) {
            window.fetch = async (...args) => {
              const response = await originalFetch(...args);
              try {
                const contentType = response.headers && response.headers.get("content-type") || "";
                const url = response.url || "";
                subtitleURLPattern.lastIndex = 0;
                const shouldInspect = !contentType
                  || /json|text|javascript|xml|subtitle|vtt|srt/i.test(contentType)
                  || subtitleURLPattern.test(url)
                  || subtitleSignalPattern.test(url);
                if (shouldInspect) {
                  subtitleURLPattern.lastIndex = 0;
                  response.clone().text().then((text) => {
                    inspectSubtitleText("fetch", url, text);
                  }).catch(() => {});
                }
              } catch (_) {}
              return response;
            };
          }

          const originalXHRSend = XMLHttpRequest.prototype.send;
          XMLHttpRequest.prototype.send = function(...args) {
            try {
              this.addEventListener("load", () => {
                try {
                  const responseType = this.responseType || "";
                  if (responseType && responseType !== "text") {
                    return;
                  }
                  const url = this.responseURL || "";
                  const text = this.responseText || "";
                  if (subtitleSignalPattern.test(url) || subtitleSignalPattern.test(text)) {
                    inspectSubtitleText("xhr", url, text);
                  } else {
                    inspectSubtitlePayload(text);
                  }
                } catch (_) {}
              });
            } catch (_) {}
            return originalXHRSend.apply(this, args);
          };

          const originalWindowPostMessage = window.postMessage;
          if (originalWindowPostMessage) {
            window.postMessage = function(message, targetOrigin, transfer) {
              try { inspectMessagePayload("window.postMessage", message); } catch (_) {}
              return originalWindowPostMessage.apply(this, arguments);
            };
          }
          window.addEventListener("message", (event) => {
            try { inspectMessagePayload("window.message", event.data); } catch (_) {}
          }, true);

          const OriginalWorker = window.Worker;
          if (OriginalWorker) {
            window.Worker = function(...args) {
              const worker = new OriginalWorker(...args);
              try {
                const originalWorkerPostMessage = worker.postMessage;
                worker.postMessage = function(message, transfer) {
                  try { inspectMessagePayload("worker.postMessage", message); } catch (_) {}
                  return originalWorkerPostMessage.apply(this, arguments);
                };
                worker.addEventListener("message", (event) => {
                  try { inspectMessagePayload("worker.message", event.data); } catch (_) {}
                }, true);
              } catch (_) {}
              return worker;
            };
            try {
              window.Worker.prototype = OriginalWorker.prototype;
            } catch (_) {}
          }

          if (window.MessagePort && window.MessagePort.prototype) {
            const originalPortPostMessage = window.MessagePort.prototype.postMessage;
            if (originalPortPostMessage) {
              window.MessagePort.prototype.postMessage = function(message, transfer) {
                try { inspectMessagePayload("message-port.postMessage", message); } catch (_) {}
                return originalPortPostMessage.apply(this, arguments);
              };
            }
            const originalPortAddEventListener = window.MessagePort.prototype.addEventListener;
            if (originalPortAddEventListener) {
              window.MessagePort.prototype.addEventListener = function(type, listener, options) {
                if (type === "message" && typeof listener === "function") {
                  const wrapped = function(event) {
                    try { inspectMessagePayload("message-port.message", event && event.data); } catch (_) {}
                    return listener.apply(this, arguments);
                  };
                  return originalPortAddEventListener.call(this, type, wrapped, options);
                }
                return originalPortAddEventListener.apply(this, arguments);
              };
            }
          }

          const OriginalBroadcastChannel = window.BroadcastChannel;
          if (OriginalBroadcastChannel) {
            window.BroadcastChannel = function(...args) {
              const channel = new OriginalBroadcastChannel(...args);
              try {
                const originalBroadcastPostMessage = channel.postMessage;
                channel.postMessage = function(message) {
                  try { inspectMessagePayload("broadcast-channel.postMessage", message); } catch (_) {}
                  return originalBroadcastPostMessage.apply(this, arguments);
                };
                channel.addEventListener("message", (event) => {
                  try { inspectMessagePayload("broadcast-channel.message", event.data); } catch (_) {}
                }, true);
              } catch (_) {}
              return channel;
            };
            try {
              window.BroadcastChannel.prototype = OriginalBroadcastChannel.prototype;
            } catch (_) {}
          }

          const stopNativeHandledMedia = (element) => {
            const media = element instanceof HTMLVideoElement
              ? element
              : element && element.parentElement instanceof HTMLVideoElement
                ? element.parentElement
                : null;
            if (!media) {
              return;
            }
            try { media.pause(); } catch (_) {}
            try { media.removeAttribute("src"); } catch (_) {}
            try {
              media.querySelectorAll("source").forEach((source) => source.removeAttribute("src"));
            } catch (_) {}
            try { media.load(); } catch (_) {}
          };

          const inspectMedia = (node) => {
            if (!node) {
              return;
            }
            if (node instanceof HTMLTrackElement) {
              inspectTrack(node);
            }
            if (node instanceof HTMLVideoElement || node instanceof HTMLSourceElement) {
              postCandidate(node.currentSrc || node.src || node.getAttribute("src"), node);
            }
            if (node.querySelectorAll) {
              node.querySelectorAll("video, source, track").forEach(inspectMedia);
            }
            if (node instanceof HTMLVideoElement) {
              inspectTextTracks(node);
            }
          };

          const srcDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, "src");
          if (srcDescriptor && srcDescriptor.set) {
            Object.defineProperty(HTMLMediaElement.prototype, "src", {
              get: srcDescriptor.get,
              set(value) {
                postCandidate(value, this);
                return srcDescriptor.set.call(this, value);
              }
            });
          }

          const sourceSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLSourceElement.prototype, "src");
          if (sourceSrcDescriptor && sourceSrcDescriptor.set) {
            Object.defineProperty(HTMLSourceElement.prototype, "src", {
              get: sourceSrcDescriptor.get,
              set(value) {
                postCandidate(value, this);
                return sourceSrcDescriptor.set.call(this, value);
              }
            });
          }

          const trackSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLTrackElement.prototype, "src");
          if (trackSrcDescriptor && trackSrcDescriptor.set) {
            Object.defineProperty(HTMLTrackElement.prototype, "src", {
              get: trackSrcDescriptor.get,
              set(value) {
                addSubtitleCandidate({
                  url: value,
                  label: this.label || this.srclang || "External Subtitle",
                  language: this.srclang || ""
                });
                return trackSrcDescriptor.set.call(this, value);
              }
            });
          }

          const originalSetAttribute = Element.prototype.setAttribute;
          Element.prototype.setAttribute = function(name, value) {
            if (String(name).toLowerCase() === "src") {
              if (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement) {
                postCandidate(value, this);
              }
              if (this instanceof HTMLTrackElement) {
                addSubtitleCandidate({
                  url: value,
                  label: this.label || this.srclang || "External Subtitle",
                  language: this.srclang || ""
                });
              }
            }
            return originalSetAttribute.call(this, name, value);
          };

          const originalLoad = HTMLMediaElement.prototype.load;
          HTMLMediaElement.prototype.load = function() {
            inspectMedia(this);
            this.querySelectorAll("source").forEach(inspectMedia);
            inspectTextTracks(this);
            return originalLoad.call(this);
          };

          document.addEventListener("addtrack", (event) => {
            inspectTrack(event.track || event.target);
          }, true);
          document.addEventListener("loadedmetadata", (event) => inspectMedia(event.target), true);
          document.addEventListener("error", (event) => inspectMedia(event.target), true);
          new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
              if (mutation.type === "attributes" && mutation.attributeName === "src") {
                inspectMedia(mutation.target);
              }
              mutation.addedNodes.forEach(inspectMedia);
            });
          }).observe(document.documentElement, {
            childList: true,
            subtree: true,
            attributes: true,
            attributeFilter: ["src", "label", "srclang"]
          });

          inspectMedia(document);
        })();
        """#,
        injectionTime: .atDocumentStart,
        forMainFrameOnly: false
    )


#if DEBUG
    private static let playbackDiagnosticsScript = WKUserScript(
        source: """
        (() => {
          if (window.__dreamioPlaybackDiagnosticsInstalled) {
            return;
          }
          window.__dreamioPlaybackDiagnosticsInstalled = true;

          const post = (type, payload = {}) => {
            try {
              window.webkit.messageHandlers.dreamioDiagnostics.postMessage({
                type,
                payload,
                href: window.location.href
              });
            } catch (_) {}
          };

          const describeValue = (value) => {
            if (value instanceof Error) {
              return {
                name: value.name,
                message: value.message,
                stack: value.stack
              };
            }
            if (typeof value === "string") {
              return value;
            }
            try {
              return JSON.stringify(value);
            } catch (_) {
              return String(value);
            }
          };

          ["error", "warn"].forEach((level) => {
            const original = console[level];
            console[level] = (...args) => {
              post(`console.${level}`, { args: args.map(describeValue) });
              original.apply(console, args);
            };
          });

          window.addEventListener("unhandledrejection", (event) => {
            post("unhandledrejection", {
              reason: describeValue(event.reason)
            });
          });

          const videoState = (video) => ({
            currentSrc: video.currentSrc || video.src || "",
            networkState: video.networkState,
            readyState: video.readyState,
            errorCode: video.error ? video.error.code : null,
            errorMessage: video.error ? video.error.message : null
          });

          const attachVideoDiagnostics = (video) => {
            if (!video || video.__dreamioDiagnosticsAttached) {
              return;
            }
            video.__dreamioDiagnosticsAttached = true;
            video.addEventListener("error", () => {
              post("video.error", videoState(video));
            }, true);
          };

          document.querySelectorAll("video").forEach(attachVideoDiagnostics);

          new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
              mutation.addedNodes.forEach((node) => {
                if (node instanceof HTMLVideoElement) {
                  attachVideoDiagnostics(node);
                }
                if (node.querySelectorAll) {
                  node.querySelectorAll("video").forEach(attachVideoDiagnostics);
                }
              });
            });
          }).observe(document.documentElement, { childList: true, subtree: true });
        })();
        """,
        injectionTime: .atDocumentStart,
        forMainFrameOnly: false
    )
#endif

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .systemBackground
        view.addSubview(webView)
        view.addSubview(progressView)

        NSLayoutConstraint.activate([
            webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            webView.topAnchor.constraint(equalTo: view.topAnchor),
            webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            progressView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            progressView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            progressView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
        ])

        progressObservation = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in
            self?.updateProgress(webView.estimatedProgress)
        }

        loadDreamio()
        webView.evaluateJavaScript("navigator.userAgent") { [weak self] result, _ in
            self?.userAgent = result as? String
        }
    }

    private func loadDreamio() {
        let request = URLRequest(url: Constants.stremioWebURL)
        webView.load(request)
    }

    private func updateProgress(_ progress: Double) {
        progressView.isHidden = progress >= 1.0
        progressView.setProgress(Float(progress), animated: true)
    }

    private func showLoadFailure(_ error: Error) {
        let alert = UIAlertController(
            title: "Could not load Dreamio",
            message: error.localizedDescription,
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "Retry", style: .default) { [weak self] _ in
            self?.loadDreamio()
        })
        present(alert, animated: true)
    }

    private func handleStreamCandidate(_ candidate: StreamCandidate) {
        guard let request = StreamClassifier.playbackRequest(from: candidate, userAgent: userAgent) else {
            return
        }

        let duplicateKey = request.resolverURL ?? request.playbackURL
        if lastNativePlaybackURL == duplicateKey {
            mergeSubtitleCandidates(candidate.subtitleCandidates, for: duplicateKey)
            return
        }
        lastNativePlaybackURL = duplicateKey
        currentNativePlaybackKey = duplicateKey
        mergeSubtitleCandidates(request.subtitleCandidates, for: duplicateKey)
        let mergedSubtitleCandidates = subtitleCandidates(for: duplicateKey)

#if DEBUG
        let classification = request.classification
        print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(mergedSubtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")
#endif

        let playbackRequest = NativePlaybackRequest(
            playbackURL: request.playbackURL,
            observedURL: request.observedURL,
            resolverURL: request.resolverURL,
            pageURL: request.pageURL,
            userAgent: request.userAgent,
            referer: request.referer,
            headers: request.headers,
            classification: request.classification,
            subtitleCandidates: mergedSubtitleCandidates
        )

        Task { [weak self] in
            await self?.resolveAndPresentNativePlayback(playbackRequest, streamKey: duplicateKey)
        }
    }

    private func handleSubtitleCandidates(_ candidates: [SubtitleCandidate]) {
        guard !candidates.isEmpty else {
            return
        }

        let streamKey = currentNativePlaybackKey ?? lastNativePlaybackURL
        if let streamKey {
            mergeSubtitleCandidates(candidates, for: streamKey)
        }

#if DEBUG
        print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) streamKey=\(streamKey.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none") candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")
#endif
        guard let currentNativePlayer else {
#if DEBUG
            print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player buffered=\(streamKey != nil)")
#endif
            return
        }

        let forwarded = currentNativePlayer.addSubtitleCandidates(candidates)
#if DEBUG
        print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded) reason=active-native-player")
#endif
    }

    @MainActor
    private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest, streamKey: URL) async {
        guard VLCNativePlaybackBackend.isAvailable else {
            lastNativePlaybackURL = nil
            currentNativePlaybackKey = nil
            showNativePlaybackUnavailableAlert()
            return
        }

        do {
            let resolved = try await streamResolver.resolve(request: request)
#if DEBUG
            print("[DreamioStreamResolver] source=\(resolved.source) playback=\(URLRedactor.redactedURLString(resolved.playbackURL.absoluteString))")
#endif
            let resolvedRequest = NativePlaybackRequest(
                playbackURL: resolved.playbackURL,
                observedURL: request.observedURL,
                resolverURL: request.resolverURL,
                pageURL: request.pageURL,
                userAgent: request.userAgent,
                referer: request.referer,
                headers: resolved.headers,
                classification: request.classification,
                subtitleCandidates: subtitleCandidates(for: streamKey)
            )
            let player = NativePlayerViewController(request: resolvedRequest)
            player.onDismiss = { [weak self] in
                self?.lastNativePlaybackURL = nil
                self?.currentNativePlaybackKey = nil
                self?.currentNativePlayer = nil
                self?.pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)
                self?.cleanUpStremioPlayerAfterNativeDismiss()
            }
            present(player, animated: true) { [weak self, weak player] in
                guard let self, let player else {
                    return
                }
                self.currentNativePlayer = player
                let lateBufferedCandidates = self.subtitleCandidates(for: streamKey)
                let forwarded = player.addSubtitleCandidates(lateBufferedCandidates)
#if DEBUG
                print("[DreamioSubtitles] presented buffered=\(lateBufferedCandidates.count) forwarded=\(forwarded) streamKey=\(URLRedactor.redactedURLString(streamKey.absoluteString))")
#endif
            }
        } catch {
#if DEBUG
            print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")
#endif
            lastNativePlaybackURL = nil
            currentNativePlaybackKey = nil
            pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)
            showNativePlaybackResolutionFailure(error)
        }
    }

    private func mergeSubtitleCandidates(_ candidates: [SubtitleCandidate], for streamKey: URL) {
        guard !candidates.isEmpty else {
            return
        }

        let existing = pendingSubtitleCandidatesByStreamKey[streamKey] ?? []
        pendingSubtitleCandidatesByStreamKey[streamKey] = Self.mergedSubtitleCandidates(existing + candidates)
    }

    private func subtitleCandidates(for streamKey: URL) -> [SubtitleCandidate] {
        pendingSubtitleCandidatesByStreamKey[streamKey] ?? []
    }

    private static func mergedSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> [SubtitleCandidate] {
        var orderedKeys: [String] = []
        var bestByURL: [String: SubtitleCandidate] = [:]
        candidates.forEach { candidate in
            let key = candidate.url.absoluteString
            if bestByURL[key] == nil {
                orderedKeys.append(key)
                bestByURL[key] = candidate
            } else if let current = bestByURL[key],
                      subtitleCandidateScore(candidate) > subtitleCandidateScore(current) {
                bestByURL[key] = candidate
            }
        }
        return orderedKeys.compactMap { bestByURL[$0] }
    }

    private static func subtitleCandidateScore(_ candidate: SubtitleCandidate) -> Int {
        let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != candidate.url.deletingPathExtension().lastPathComponent
        return (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0)
    }

    private func showNativePlaybackResolutionFailure(_ error: Error) {
        let alert = UIAlertController(
            title: "Could not open stream",
            message: error.localizedDescription,
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "Close", style: .cancel))
        present(alert, animated: true)
    }

    private func showNativePlaybackUnavailableAlert() {
        let alert = UIAlertController(
            title: "Native playback needs CocoaPods",
            message: "This build was opened from Dreamio.xcodeproj or built before MobileVLCKit was installed. Run pod install, open Dreamio.xcworkspace, then build again to play MKV, AVI, and WebM streams.",
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "Close", style: .cancel))
        present(alert, animated: true)
    }

    private func cleanUpStremioPlayerAfterNativeDismiss() {
        let script = #"""
        (() => {
          const stopMedia = () => {
            document.querySelectorAll("video, audio").forEach((media) => {
              try { media.pause(); } catch (_) {}
              try { media.removeAttribute("src"); } catch (_) {}
              try { media.querySelectorAll("source").forEach((source) => source.removeAttribute("src")); } catch (_) {}
              try { media.load(); } catch (_) {}
            });
          };
          const clickVisible = (selectors) => {
            for (const selector of selectors) {
              const nodes = Array.from(document.querySelectorAll(selector));
              const match = nodes.find((node) => {
                const style = window.getComputedStyle(node);
                const rect = node.getBoundingClientRect();
                return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
              });
              if (match) {
                try { match.click(); return true; } catch (_) {}
              }
            }
            return false;
          };
          stopMedia();
          const clicked = clickVisible([
            "[aria-label*='Close' i]",
            "[aria-label*='Back' i]",
            "[title*='Close' i]",
            "[title*='Back' i]",
            "button[class*='close' i]",
            "button[class*='back' i]",
            "[class*='close' i]",
            "[class*='back' i]",
            ".player button",
            "[role='button']"
          ]);
          const locationLooksPlayer = /\/(player|stream)\b/i.test(window.location.pathname || "") || /player|stream/i.test(window.location.hash || "");
          const visibleBusyPlayer = Boolean(document.querySelector("video, .player, [class*='player' i], [class*='buffer' i]"));
          const stillPlayer = locationLooksPlayer || (visibleBusyPlayer && /buffer|prepar|stream/i.test(document.body.innerText || ""));
          return { clicked, stillPlayer, href: window.location.href };
        })();
        """#

        webView.evaluateJavaScript(script) { [weak self] result, error in
            guard let self else {
                return
            }
#if DEBUG
            if let error {
                print("[DreamioCloseFlow] cleanup error=\(URLRedactor.redactedURLString(error.localizedDescription))")
            } else {
                print("[DreamioCloseFlow] cleanup result=\(String(describing: result))")
            }
#endif
            guard error == nil else {
                self.loadDreamio()
                return
            }
            if self.webView.canGoBack {
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
                    let stillPlayerScript = #"""
                    (() => {
                      const locationLooksPlayer = /\/(player|stream)\b/i.test(window.location.pathname || "") || /player|stream/i.test(window.location.hash || "");
                      const visibleBusyPlayer = Boolean(document.querySelector("video, .player, [class*='player' i], [class*='buffer' i]"));
                      return locationLooksPlayer || (visibleBusyPlayer && /buffer|prepar|stream/i.test(document.body.innerText || ""));
                    })()
                    """#
                    self.webView.evaluateJavaScript(stillPlayerScript) { result, _ in
                        if (result as? Bool) == true {
                            self.webView.goBack()
                        }
                    }
                }
            }
        }
    }

#if DEBUG
    private func logDiagnostic(type: String, payload: Any, pageURL: String?) {
        let redactedPageURL = pageURL.map(redactedURLString) ?? "unknown"
        let redactedPayload = redactDiagnosticValue(payload)
        print("[DreamioDiagnostics] \(type) page=\(redactedPageURL) payload=\(redactedPayload)")
    }

    private func redactDiagnosticValue(_ value: Any) -> Any {
        switch value {
        case let string as String:
            return redactedURLString(string)
        case let array as [Any]:
            return array.map(redactDiagnosticValue)
        case let dictionary as [String: Any]:
            return dictionary.reduce(into: [String: Any]()) { result, entry in
                result[entry.key] = redactDiagnosticValue(entry.value)
            }
        default:
            return value
        }
    }

    private func redactedURLString(_ value: String) -> String {
        URLRedactor.redactedURLString(value)
    }

    private func logSubtitleBridgeMessage(_ body: Any, parsedCandidates: [SubtitleCandidate]) {
        let dictionary = body as? [String: Any]
        let debug = dictionary?["debug"] as? [String: Any]
        let discovered = debug?["discovered"] as? Int ?? parsedCandidates.count
        let deduped = debug?["deduped"] as? Int ?? parsedCandidates.count
        let posted = debug?["forwarded"] as? Int ?? parsedCandidates.count
        let source = debug?["source"] as? String ?? "bridge"
        let inspected = debug?["inspected"] as? Bool ?? false
        let inspectedURL = (debug?["url"] as? String).map(redactedURLString) ?? "none"
        let payloadLength = debug?["payloadLength"] as? Int ?? 0
        let totalKnown = debug?["totalKnown"] as? Int ?? parsedCandidates.count
        let pageURL = dictionary?["pageUrl"] as? String
        print("[DreamioSubtitles] bridge source=\(source) inspected=\(inspected) discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) totalKnown=\(totalKnown) payloadLength=\(payloadLength) playerActive=\(currentNativePlayer != nil) inspectedURL=\(inspectedURL) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))")
    }
#endif
}

extension DreamioWebViewController: WKNavigationDelegate {
    func webView(
        _ webView: WKWebView,
        decidePolicyFor navigationAction: WKNavigationAction,
        decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
    ) {
        guard let url = navigationAction.request.url else {
            decisionHandler(.cancel)
            return
        }

        if shouldOpenExternally(url: url, navigationType: navigationAction.navigationType) {
            UIApplication.shared.open(url)
            decisionHandler(.cancel)
            return
        }

        decisionHandler(.allow)
    }

    func webView(
        _ webView: WKWebView,
        decidePolicyFor navigationResponse: WKNavigationResponse,
        decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
    ) {
#if DEBUG
        if let response = navigationResponse.response as? HTTPURLResponse {
            let url = response.url?.absoluteString ?? "unknown"
            print("[DreamioNavigation] status=\(response.statusCode) url=\(redactedURLString(url))")
        }
#endif
        decisionHandler(.allow)
    }

    func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
#if DEBUG
        print("[DreamioNavigation] didFail url=\(redactedURLString(webView.url?.absoluteString ?? "unknown")) error=\(redactedURLString(error.localizedDescription))")
#endif
        showLoadFailure(error)
    }

    func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
#if DEBUG
        print("[DreamioNavigation] didFailProvisional url=\(redactedURLString(webView.url?.absoluteString ?? "unknown")) error=\(redactedURLString(error.localizedDescription))")
#endif
        showLoadFailure(error)
    }

    private func shouldOpenExternally(url: URL, navigationType: WKNavigationType) -> Bool {
        guard let scheme = url.scheme?.lowercased() else {
            return false
        }

        if ["http", "https"].contains(scheme) {
            return false
        }

        if ["mailto", "tel", "sms"].contains(scheme) {
            return true
        }

        return navigationType == .linkActivated
    }
}

extension DreamioWebViewController: WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if message.name == Constants.streamCandidateMessageHandler,
           let candidate = StreamCandidate(messageBody: message.body) {
            handleStreamCandidate(candidate)
            return
        }

        if message.name == Constants.subtitleCandidateMessageHandler {
            let candidates = SubtitleCandidateParser.candidates(in: message.body)
#if DEBUG
            logSubtitleBridgeMessage(message.body, parsedCandidates: candidates)
#endif
            handleSubtitleCandidates(candidates)
            return
        }

#if DEBUG
        guard message.name == Constants.diagnosticsMessageHandler,
              let body = message.body as? [String: Any],
              let type = body["type"] as? String
        else {
            return
        }

        logDiagnostic(
            type: type,
            payload: body["payload"] ?? [:],
            pageURL: body["href"] as? String
        )
#endif
    }
}

private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
    weak var delegate: WKScriptMessageHandler?

    init(delegate: WKScriptMessageHandler) {
        self.delegate = delegate
        super.init()
    }

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        delegate?.userContentController(userContentController, didReceive: message)
    }
}


extension DreamioWebViewController: WKUIDelegate {
    func webView(
        _ webView: WKWebView,
        createWebViewWith configuration: WKWebViewConfiguration,
        for navigationAction: WKNavigationAction,
        windowFeatures: WKWindowFeatures
    ) -> WKWebView? {
        if navigationAction.targetFrame == nil {
            webView.load(navigationAction.request)
        }

        return nil
    }
}
