//
//  ViewController.swift
//  ZQVRViewer_Demo_Swift
//
//  Created by wangzedong on 2026/4/23.
//

import UIKit
import WebKit
import AVFoundation
import AudioToolbox
import ZQVRViewer_TRTC

final class ViewController: UIViewController, UIGestureRecognizerDelegate {

    private let formScrollView: UIScrollView = {
        let s = UIScrollView()
        s.translatesAutoresizingMaskIntoConstraints = false
        s.alwaysBounceVertical = true
        s.keyboardDismissMode = .onDrag
        return s
    }()

    private let formStack: UIStackView = {
        let v = UIStackView()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.axis = .vertical
        v.spacing = 10
        v.alignment = .fill
        return v
    }()

    private let baseURLTextView: UITextView = {
        let tv = UITextView()
        tv.translatesAutoresizingMaskIntoConstraints = false
        tv.font = .systemFont(ofSize: 15)
        tv.text = "http://123.56.161.139:8080/play2/?m=b5fabc8e_Xmz4_b6f9&useVrTakeSee=1&userId=custom_01&callerUserId=custom_01&packageId=1001&imPlatform=app&takeSeeTestMode=1&house_id=1&vconsole=1&takeSeeTestMode=1"
        tv.textContainerInset = UIEdgeInsets(top: 8, left: 6, bottom: 8, right: 6)
        tv.textContainer.lineFragmentPadding = 0
        tv.isScrollEnabled = true
        tv.keyboardType = .URL
        tv.autocapitalizationType = .none
        tv.autocorrectionType = .no
        tv.dataDetectorTypes = []
        if #available(iOS 11.0, *) {
            tv.smartDashesType = .no
            tv.smartQuotesType = .no
            tv.smartInsertDeleteType = .no
        }
        if #available(iOS 13.0, *) {
            tv.backgroundColor = .secondarySystemBackground
            tv.layer.borderColor = UIColor.separator.cgColor
        } else {
            tv.backgroundColor = UIColor(white: 0.96, alpha: 1)
            tv.layer.borderColor = UIColor(white: 0.78, alpha: 1).cgColor
        }
        tv.layer.borderWidth = 1.0 / UIScreen.main.scale
        tv.layer.cornerRadius = 8
        if #available(iOS 13.0, *) {
            tv.layer.cornerCurve = .continuous
        }
        return tv
    }()

    private let scanTourURLQRButton: UIButton = {
        let b = UIButton(type: .system)
        b.translatesAutoresizingMaskIntoConstraints = false
        b.setTitle("扫码", for: .normal)
        b.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
        if #available(iOS 13.0, *) {
            b.setImage(UIImage(systemName: "qrcode.viewfinder"), for: .normal)
            b.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4)
            b.titleEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: -4)
        }
        b.contentEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
        b.accessibilityLabel = "扫描带看地址二维码"
        return b
    }()

    private let enterTourButton: UIButton = {
        let b = UIButton(type: .system)
        b.translatesAutoresizingMaskIntoConstraints = false
        b.setTitle("进入带看", for: .normal)
        b.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold)
        return b
    }()

    private let endTourButton: UIButton = {
        let b = UIButton(type: .system)
        b.translatesAutoresizingMaskIntoConstraints = false
        b.setTitle("结束带看", for: .normal)
        b.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
        b.contentEdgeInsets = UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 12)
        b.backgroundColor = UIColor.black.withAlphaComponent(0.55)
        b.setTitleColor(.white, for: .normal)
        b.layer.cornerRadius = 8
        b.isHidden = true
        return b
    }()

    private let captureVolumeLabel: UILabel = {
        let l = UILabel()
        l.translatesAutoresizingMaskIntoConstraints = false
        l.font = .systemFont(ofSize: 13)
        l.text = "采集音量 100"
        return l
    }()

    private let captureVolumeSlider: UISlider = {
        let s = UISlider()
        s.translatesAutoresizingMaskIntoConstraints = false
        s.minimumValue = 0
        s.maximumValue = 100
        s.value = 100
        return s
    }()

    private let playoutVolumeLabel: UILabel = {
        let l = UILabel()
        l.translatesAutoresizingMaskIntoConstraints = false
        l.font = .systemFont(ofSize: 13)
        l.text = "播放音量 100"
        return l
    }()

    private let playoutVolumeSlider: UISlider = {
        let s = UISlider()
        s.translatesAutoresizingMaskIntoConstraints = false
        s.minimumValue = 0
        s.maximumValue = 100
        s.value = 100
        return s
    }()

    private lazy var volumeBarButtonItem: UIBarButtonItem = {
        UIBarButtonItem(title: "音量", style: .plain, target: self, action: #selector(volumeToggleTapped))
    }()

    private lazy var logBarButtonItem: UIBarButtonItem = {
        UIBarButtonItem(title: "日志", style: .plain, target: self, action: #selector(logToggleTapped))
    }()

    private let volumeOverlayBackdrop: UIView = {
        let v = UIView()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.backgroundColor = UIColor.black.withAlphaComponent(0.38)
        v.isHidden = true
        v.alpha = 0
        v.isUserInteractionEnabled = true
        return v
    }()

    private let volumeOverlayPanel: UIView = {
        let v = UIView()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.backgroundColor = UIColor(white: 0.98, alpha: 1)
        v.layer.cornerRadius = 10
        if #available(iOS 13.0, *) {
            v.layer.cornerCurve = .continuous
        }
        v.layer.shadowColor = UIColor.black.cgColor
        v.layer.shadowOpacity = 0.18
        v.layer.shadowOffset = CGSize(width: 0, height: 4)
        v.layer.shadowRadius = 10
        v.isHidden = true
        v.alpha = 0
        return v
    }()

    private let logOverlayBackdrop: UIView = {
        let v = UIView()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.backgroundColor = UIColor.black.withAlphaComponent(0.38)
        v.isHidden = true
        v.alpha = 0
        v.isUserInteractionEnabled = true
        return v
    }()

    private let logOverlayPanel: UIView = {
        let v = UIView()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.backgroundColor = UIColor(white: 0.98, alpha: 1)
        v.layer.cornerRadius = 10
        if #available(iOS 13.0, *) {
            v.layer.cornerCurve = .continuous
        }
        v.layer.shadowColor = UIColor.black.cgColor
        v.layer.shadowOpacity = 0.18
        v.layer.shadowOffset = CGSize(width: 0, height: 4)
        v.layer.shadowRadius = 10
        v.isHidden = true
        v.alpha = 0
        return v
    }()

    private let logTextView: UITextView = {
        let tv = UITextView()
        tv.translatesAutoresizingMaskIntoConstraints = false
        tv.isEditable = false
        tv.isSelectable = true
        if #available(iOS 13.0, *) {
            tv.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular)
        } else {
            tv.font = .systemFont(ofSize: 11)
        }
        tv.backgroundColor = .clear
        tv.textContainerInset = UIEdgeInsets(top: 6, left: 4, bottom: 6, right: 4)
        return tv
    }()

    private var volumeOverlayVisible = false
    private var logOverlayVisible = false

    private var webView: WKWebView?

    private let webViewContainer: UIView = {
        let v = UIView()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.backgroundColor = UIColor(white: 0.95, alpha: 1)
        v.isHidden = true
        return v
    }()

    private let timeFormatter: DateFormatter = {
        let f = DateFormatter()
        f.dateFormat = "HH:mm:ss.SSS"
        return f
    }()

    private enum TourConfigStore {
        private static let p = "ZQVRViewerDemo.TourConfig."
        static let baseURL = p + "baseURL"
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        title = "ZQVRViewer Demo"
        navigationItem.leftBarButtonItem = volumeBarButtonItem
        navigationItem.rightBarButtonItem = logBarButtonItem

        buildFormStack()
        applySavedTourConfiguration()
        layoutChrome()

        enterTourButton.addTarget(self, action: #selector(enterTourTapped), for: .touchUpInside)
        scanTourURLQRButton.addTarget(self, action: #selector(scanTourURLQRTapped), for: .touchUpInside)
        endTourButton.addTarget(self, action: #selector(endTourTapped), for: .touchUpInside)

        captureVolumeSlider.addTarget(self, action: #selector(captureVolumeChanged), for: .valueChanged)
        playoutVolumeSlider.addTarget(self, action: #selector(playoutVolumeChanged), for: .valueChanged)

        let volumeBackdropTap = UITapGestureRecognizer(target: self, action: #selector(volumeBackdropTapped))
        volumeOverlayBackdrop.addGestureRecognizer(volumeBackdropTap)
        let backdropTap = UITapGestureRecognizer(target: self, action: #selector(logBackdropTapped))
        logOverlayBackdrop.addGestureRecognizer(backdropTap)

        let dismissKeyboardTap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboardFromNonInputTap))
        dismissKeyboardTap.cancelsTouchesInView = false
        dismissKeyboardTap.delegate = self
        view.addGestureRecognizer(dismissKeyboardTap)

        appendLog("就绪：粘贴完整带看 URL（参数自行拼在地址中）；点「进入带看」会缓存 URL 供下次启动恢复。")
    }

    private func applySavedTourConfiguration() {
        let u = UserDefaults.standard
        let base = u.string(forKey: TourConfigStore.baseURL)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        if !base.isEmpty { baseURLTextView.text = base }
    }

    private func persistTourConfiguration() {
        UserDefaults.standard.set(normalizedBaseURLInput(), forKey: TourConfigStore.baseURL)
    }

    private func buildFormStack() {
        formStack.addArrangedSubview(pageAddressSection())
        formStack.addArrangedSubview(enterTourButton)
    }

    private func pageAddressSection() -> UIStackView {
        let title = UILabel()
        title.translatesAutoresizingMaskIntoConstraints = false
        title.font = .systemFont(ofSize: 13)
        title.text = "完整带看 URL（业务参数请在地址中自行拼接）"
        title.numberOfLines = 0

        let urlRow = UIStackView(arrangedSubviews: [baseURLTextView, scanTourURLQRButton])
        urlRow.translatesAutoresizingMaskIntoConstraints = false
        urlRow.axis = .horizontal
        urlRow.spacing = 8
        urlRow.alignment = .top
        baseURLTextView.setContentHuggingPriority(.defaultLow, for: .horizontal)
        scanTourURLQRButton.setContentHuggingPriority(.required, for: .horizontal)
        scanTourURLQRButton.setContentCompressionResistancePriority(.required, for: .horizontal)

        let stack = UIStackView(arrangedSubviews: [title, urlRow])
        stack.translatesAutoresizingMaskIntoConstraints = false
        stack.axis = .vertical
        stack.spacing = 6
        stack.alignment = .fill
        baseURLTextView.heightAnchor.constraint(equalToConstant: 200).isActive = true
        return stack
    }

    private func makeViewerWebView() -> WKWebView {
        let config = WKWebViewConfiguration()
        config.preferences.javaScriptEnabled = true
        config.preferences.javaScriptCanOpenWindowsAutomatically = true
        let wv = WKWebView(frame: .zero, configuration: config)
        wv.translatesAutoresizingMaskIntoConstraints = false
        return wv
    }

    private func pinWebViewToContainer(_ wv: WKWebView) {
        webViewContainer.addSubview(wv)
        NSLayoutConstraint.activate([
            wv.topAnchor.constraint(equalTo: webViewContainer.topAnchor),
            wv.leadingAnchor.constraint(equalTo: webViewContainer.leadingAnchor),
            wv.trailingAnchor.constraint(equalTo: webViewContainer.trailingAnchor),
            wv.bottomAnchor.constraint(equalTo: webViewContainer.bottomAnchor)
        ])
    }

    private func layoutChrome() {
        formScrollView.addSubview(formStack)

        let capRow = UIStackView(arrangedSubviews: [captureVolumeLabel, captureVolumeSlider])
        capRow.translatesAutoresizingMaskIntoConstraints = false
        capRow.axis = .vertical
        capRow.spacing = 4

        let playRow = UIStackView(arrangedSubviews: [playoutVolumeLabel, playoutVolumeSlider])
        playRow.translatesAutoresizingMaskIntoConstraints = false
        playRow.axis = .vertical
        playRow.spacing = 4

        let volumeContentStack = UIStackView(arrangedSubviews: [capRow, playRow])
        volumeContentStack.translatesAutoresizingMaskIntoConstraints = false
        volumeContentStack.axis = .vertical
        volumeContentStack.spacing = 12

        webViewContainer.addSubview(endTourButton)

        view.addSubview(formScrollView)
        view.addSubview(webViewContainer)
        view.addSubview(volumeOverlayBackdrop)
        volumeOverlayPanel.addSubview(volumeContentStack)
        view.addSubview(volumeOverlayPanel)
        view.addSubview(logOverlayBackdrop)
        logOverlayPanel.addSubview(logTextView)
        view.addSubview(logOverlayPanel)

        let guide = view.safeAreaLayoutGuide

        NSLayoutConstraint.activate([
            formScrollView.topAnchor.constraint(equalTo: guide.topAnchor),
            formScrollView.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
            formScrollView.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
            formScrollView.bottomAnchor.constraint(equalTo: guide.bottomAnchor),

            formStack.topAnchor.constraint(equalTo: formScrollView.contentLayoutGuide.topAnchor, constant: 12),
            formStack.leadingAnchor.constraint(equalTo: formScrollView.frameLayoutGuide.leadingAnchor, constant: 12),
            formStack.trailingAnchor.constraint(equalTo: formScrollView.frameLayoutGuide.trailingAnchor, constant: -12),
            formStack.bottomAnchor.constraint(equalTo: formScrollView.contentLayoutGuide.bottomAnchor, constant: -20),

            webViewContainer.topAnchor.constraint(equalTo: guide.topAnchor),
            webViewContainer.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
            webViewContainer.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
            webViewContainer.bottomAnchor.constraint(equalTo: guide.bottomAnchor),

            endTourButton.topAnchor.constraint(equalTo: webViewContainer.safeAreaLayoutGuide.topAnchor, constant: 8),
            endTourButton.leadingAnchor.constraint(equalTo: webViewContainer.safeAreaLayoutGuide.leadingAnchor, constant: 12),

            volumeOverlayBackdrop.topAnchor.constraint(equalTo: guide.topAnchor),
            volumeOverlayBackdrop.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
            volumeOverlayBackdrop.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
            volumeOverlayBackdrop.bottomAnchor.constraint(equalTo: guide.bottomAnchor),

            volumeOverlayPanel.topAnchor.constraint(equalTo: guide.topAnchor, constant: 12),
            volumeOverlayPanel.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: 12),
            volumeOverlayPanel.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -12),

            volumeContentStack.topAnchor.constraint(equalTo: volumeOverlayPanel.topAnchor, constant: 12),
            volumeContentStack.leadingAnchor.constraint(equalTo: volumeOverlayPanel.leadingAnchor, constant: 12),
            volumeContentStack.trailingAnchor.constraint(equalTo: volumeOverlayPanel.trailingAnchor, constant: -12),
            volumeContentStack.bottomAnchor.constraint(equalTo: volumeOverlayPanel.bottomAnchor, constant: -12),

            logOverlayBackdrop.topAnchor.constraint(equalTo: guide.topAnchor),
            logOverlayBackdrop.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
            logOverlayBackdrop.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
            logOverlayBackdrop.bottomAnchor.constraint(equalTo: guide.bottomAnchor),

            logOverlayPanel.topAnchor.constraint(equalTo: guide.topAnchor, constant: 12),
            logOverlayPanel.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: 12),
            logOverlayPanel.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -12),
            logOverlayPanel.heightAnchor.constraint(equalTo: guide.heightAnchor, multiplier: 0.36),

            logTextView.topAnchor.constraint(equalTo: logOverlayPanel.topAnchor, constant: 8),
            logTextView.leadingAnchor.constraint(equalTo: logOverlayPanel.leadingAnchor, constant: 8),
            logTextView.trailingAnchor.constraint(equalTo: logOverlayPanel.trailingAnchor, constant: -8),
            logTextView.bottomAnchor.constraint(equalTo: logOverlayPanel.bottomAnchor, constant: -8)
        ])
    }

    /// 页面地址：去掉换行并 trim，便于解析为 URL。
    private func normalizedBaseURLInput() -> String {
        (baseURLTextView.text ?? "")
            .components(separatedBy: .newlines)
            .joined()
            .trimmingCharacters(in: .whitespacesAndNewlines)
    }

    /// 使用输入框中的完整 URL（不在客户端追加任何查询参数）。
    private func builtTourURL() -> URL? {
        let base = normalizedBaseURLInput()
        if base.isEmpty { return nil }
        guard let c = URLComponents(string: base) else { return nil }
        return c.url
    }

    @objc private func scanTourURLQRTapped() {
        view.endEditing(true)
        switch AVCaptureDevice.authorizationStatus(for: .video) {
        case .authorized:
            presentTourURLQRScannerIfPossible()
        case .notDetermined:
            AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
                DispatchQueue.main.async {
                    guard let self = self else { return }
                    if granted {
                        self.presentTourURLQRScannerIfPossible()
                    } else {
                        self.appendLog("扫码：用户未授权相机")
                    }
                }
            }
        case .denied, .restricted:
            let alert = UIAlertController(
                title: "无法使用相机",
                message: "请在「设置 › 隐私与安全性 › 相机」中允许本应用访问相机后再试。",
                preferredStyle: .alert
            )
            alert.addAction(UIAlertAction(title: "好的", style: .default))
            present(alert, animated: true)
        @unknown default:
            break
        }
    }

    private func presentTourURLQRScannerIfPossible() {
        guard AVCaptureDevice.default(for: .video) != nil else {
            let alert = UIAlertController(
                title: "无法扫码",
                message: "当前设备没有可用相机（例如模拟器）。请使用真机扫码。",
                preferredStyle: .alert
            )
            alert.addAction(UIAlertAction(title: "好的", style: .default))
            present(alert, animated: true)
            return
        }
        let scanner = TourURLQRScannerViewController()
        scanner.delegate = self
        scanner.modalPresentationStyle = .fullScreen
        present(scanner, animated: true)
    }

    @objc private func enterTourTapped() {
        view.endEditing(true)
        if webView != nil {
            appendLog("已在带看中，请先点「结束带看」。")
            return
        }
        guard let url = builtTourURL() else {
            appendLog("请输入完整带看 URL。")
            return
        }
        persistTourConfiguration()
        applyTourRoleNavigationTitle(for: url)
        let wv = makeViewerWebView()
        pinWebViewToContainer(wv)
        webView = wv
        webViewContainer.bringSubviewToFront(endTourButton)

        ZQVRViewerManager.shared.viewerStateDelegate = self
        ZQVRViewerManager.shared.openViewer(wv)
        appendLog("已 openViewer 并完成 SDK 绑定。")

        wv.load(URLRequest(url: url))
        appendLog("加载远程：\(url.absoluteString)")

        webViewContainer.isHidden = false
        endTourButton.isHidden = false
    }

    @objc private func endTourTapped() {
        ZQVRViewerManager.shared.destroyViewer()
        webView?.removeFromSuperview()
        webView = nil
        endTourButton.isHidden = true
        webViewContainer.isHidden = true
        title = "ZQVRViewer Demo"
        appendLog("已结束带看，WebView 已销毁。")
    }

    private func applyTourRoleNavigationTitle(for url: URL) {
        guard let comp = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
            title = "带看中"
            return
        }
        let items = comp.queryItems ?? []
        func value(_ name: String) -> String {
            items.first { $0.name == name }?.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        }
        let uid = value("userId")
        let caller = value("callerUserId")
        if uid.isEmpty, caller.isEmpty {
            title = "带看中"
        } else if uid == caller {
            title = "拨打端"
        } else {
            title = "接听端"
        }
    }

    @objc private func volumeToggleTapped() {
        if volumeOverlayVisible {
            hideVolumeOverlay(animated: true)
            return
        }
        hideLogOverlayIfNeeded(animated: true)
        volumeOverlayVisible = true
        volumeBarButtonItem.title = "收起"
        volumeOverlayBackdrop.isHidden = false
        volumeOverlayPanel.isHidden = false
        UIView.animate(withDuration: 0.22) {
            self.volumeOverlayBackdrop.alpha = 1
            self.volumeOverlayPanel.alpha = 1
        }
    }

    @objc private func volumeBackdropTapped() {
        guard volumeOverlayVisible else { return }
        hideVolumeOverlay(animated: true)
    }

    private func hideVolumeOverlay(animated: Bool) {
        guard volumeOverlayVisible else {
            volumeBarButtonItem.title = "音量"
            return
        }
        volumeOverlayVisible = false
        volumeBarButtonItem.title = "音量"
        let animations = {
            self.volumeOverlayBackdrop.alpha = 0
            self.volumeOverlayPanel.alpha = 0
        }
        let completion: (Bool) -> Void = { _ in
            self.volumeOverlayBackdrop.isHidden = true
            self.volumeOverlayPanel.isHidden = true
        }
        if animated {
            UIView.animate(withDuration: 0.22, animations: animations, completion: completion)
        } else {
            animations()
            completion(true)
        }
    }

    private func hideLogOverlayIfNeeded(animated: Bool) {
        guard logOverlayVisible else { return }
        logOverlayVisible = false
        logBarButtonItem.title = "日志"
        let animations = {
            self.logOverlayBackdrop.alpha = 0
            self.logOverlayPanel.alpha = 0
        }
        let completion: (Bool) -> Void = { _ in
            self.logOverlayBackdrop.isHidden = true
            self.logOverlayPanel.isHidden = true
        }
        if animated {
            UIView.animate(withDuration: 0.22, animations: animations, completion: completion)
        } else {
            animations()
            completion(true)
        }
    }

    @objc private func logToggleTapped() {
        if logOverlayVisible {
            hideLogOverlayIfNeeded(animated: true)
            return
        }
        hideVolumeOverlay(animated: true)
        logOverlayVisible = true
        logOverlayBackdrop.isHidden = false
        logOverlayPanel.isHidden = false
        logBarButtonItem.title = "收起"
        UIView.animate(withDuration: 0.22) {
            self.logOverlayBackdrop.alpha = 1
            self.logOverlayPanel.alpha = 1
        }
    }

    @objc private func logBackdropTapped() {
        hideLogOverlayIfNeeded(animated: true)
    }

    @objc private func captureVolumeChanged() {
        let v = Int(round(captureVolumeSlider.value))
        captureVolumeLabel.text = "采集音量 \(v)"
        ZQVRViewerManager.shared.setAudioCaptureVolume(v)
    }

    @objc private func playoutVolumeChanged() {
        let v = Int(round(playoutVolumeSlider.value))
        playoutVolumeLabel.text = "播放音量 \(v)"
        ZQVRViewerManager.shared.setAudioPlayoutVolume(v)
    }

    private func appendLog(_ message: String) {
        let line = "[\(timeFormatter.string(from: Date()))] \(message)"
        print("[ZQVRViewerDemo]", line)
        let appendBlock = { [weak self] in
            guard let self = self else { return }
            self.logTextView.text = (self.logTextView.text ?? "") + line + "\n"
            let end = NSRange(location: self.logTextView.text.count, length: 0)
            self.logTextView.scrollRangeToVisible(end)
        }
        if Thread.isMainThread {
            appendBlock()
        } else {
            DispatchQueue.main.async(execute: appendBlock)
        }
    }

    @objc private func dismissKeyboardFromNonInputTap() {
        view.endEditing(true)
    }

    private static func viewHierarchyContainsTextInput(startingFrom view: UIView?) -> Bool {
        var v: UIView? = view
        while let cur = v {
            if cur is UITextField || cur is UITextView { return true }
            v = cur.superview
        }
        return false
    }
}

// MARK: - UIGestureRecognizerDelegate

extension ViewController {
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
        !Self.viewHierarchyContainsTextInput(startingFrom: touch.view)
    }
}

// MARK: - ZQVRViewerStateDelegate

extension ViewController: ZQVRViewerStateDelegate {
    func onEnterRoom(_ result: Int) {
        appendLog("onEnterRoom: \(result)")
    }

    func onExitRoom(_ reason: Int) {
        appendLog("onExitRoom: reason=\(reason)")
    }

    func onError(_ errCode: Int, errMsg: String, extInfo: [String: Any]?) {
        appendLog("onError: code=\(errCode) msg=\(errMsg)")
    }

    func onRemoteUserEnterRoom(_ userId: String) {
        appendLog("onRemoteUserEnterRoom: \(userId)")
    }

    func onRemoteUserLeaveRoom(_ userId: String, reason: Int) {
        appendLog("onRemoteUserLeaveRoom: \(userId) reason=\(reason)")
    }

    func onFirstAudioFrame(_ userId: String) {
        appendLog("onFirstAudioFrame: \(userId)")
    }

    func onNetworkQuality(_ userId: String, quality: Int) {
        appendLog("onNetworkQuality: \(userId) quality=\(quality)")
    }

    func onExitVR() {
        appendLog("onExitVR")
        endTourTapped()
    }
}

// MARK: - TourURLQRScannerViewControllerDelegate

extension ViewController: TourURLQRScannerViewControllerDelegate {
    fileprivate func tourURLQRScanner(_ controller: TourURLQRScannerViewController, didPickPayload string: String) {
        baseURLTextView.text = string
        persistTourConfiguration()
        appendLog("扫码成功，已写入地址框。")
    }

    fileprivate func tourURLQRScannerDidCancel(_ controller: TourURLQRScannerViewController) {}
}

// MARK: - Tour URL QR Scanner (camera)

private protocol TourURLQRScannerViewControllerDelegate: AnyObject {
    func tourURLQRScanner(_ controller: TourURLQRScannerViewController, didPickPayload string: String)
    func tourURLQRScannerDidCancel(_ controller: TourURLQRScannerViewController)
}

private final class TourURLQRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {

    weak var delegate: TourURLQRScannerViewControllerDelegate?

    private let session = AVCaptureSession()
    private var previewLayer: AVCaptureVideoPreviewLayer?
    private var sessionConfigured = false
    private var didFinish = false

    private lazy var cancelButton: UIButton = {
        let b = UIButton(type: .system)
        b.translatesAutoresizingMaskIntoConstraints = false
        b.setTitle("取消", for: .normal)
        b.setTitleColor(.white, for: .normal)
        b.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium)
        b.backgroundColor = UIColor.black.withAlphaComponent(0.5)
        b.layer.cornerRadius = 10
        b.contentEdgeInsets = UIEdgeInsets(top: 10, left: 18, bottom: 10, right: 18)
        b.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
        return b
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .black
        view.addSubview(cancelButton)
        let guide = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            cancelButton.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: 16),
            cancelButton.topAnchor.constraint(equalTo: guide.topAnchor, constant: 8)
        ])
        configureSession()
    }

    private func configureSession() {
        guard let device = AVCaptureDevice.default(for: .video) else {
            presentSetupFailure("无可用相机")
            return
        }
        do {
            let input = try AVCaptureDeviceInput(device: device)
            session.beginConfiguration()
            guard session.canAddInput(input) else {
                session.commitConfiguration()
                presentSetupFailure("无法接入相机")
                return
            }
            session.addInput(input)
            let output = AVCaptureMetadataOutput()
            guard session.canAddOutput(output) else {
                session.commitConfiguration()
                presentSetupFailure("无法配置扫码输出")
                return
            }
            session.addOutput(output)
            output.setMetadataObjectsDelegate(self, queue: .main)
            output.metadataObjectTypes = [.qr]
            if session.canSetSessionPreset(.high) {
                session.sessionPreset = .high
            }
            session.commitConfiguration()
        } catch {
            presentSetupFailure("打开相机失败：\(error.localizedDescription)")
            return
        }

        let layer = AVCaptureVideoPreviewLayer(session: session)
        layer.videoGravity = .resizeAspectFill
        view.layer.insertSublayer(layer, at: 0)
        previewLayer = layer
        sessionConfigured = true
    }

    private func presentSetupFailure(_ message: String) {
        let alert = UIAlertController(title: "无法扫码", message: message, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "好的", style: .default) { [weak self] _ in
            guard let self = self else { return }
            self.delegate?.tourURLQRScannerDidCancel(self)
            self.dismiss(animated: true)
        })
        present(alert, animated: true)
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        previewLayer?.frame = view.bounds
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        guard sessionConfigured, !didFinish else { return }
        DispatchQueue.global(qos: .userInitiated).async { [weak self] in
            self?.session.startRunning()
        }
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        if session.isRunning {
            DispatchQueue.global(qos: .userInitiated).async { [weak self] in
                self?.session.stopRunning()
            }
        }
    }

    @objc private func cancelTapped() {
        finishCancelled()
    }

    private func finishCancelled() {
        guard !didFinish else { return }
        didFinish = true
        DispatchQueue.global(qos: .userInitiated).async { [weak self] in
            guard let self = self else { return }
            if self.session.isRunning { self.session.stopRunning() }
            DispatchQueue.main.async {
                self.delegate?.tourURLQRScannerDidCancel(self)
                self.dismiss(animated: true)
            }
        }
    }

    private func finishWithPayload(_ string: String) {
        guard !didFinish else { return }
        didFinish = true
        AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
        let payload = string
        DispatchQueue.global(qos: .userInitiated).async { [weak self] in
            guard let self = self else { return }
            if self.session.isRunning { self.session.stopRunning() }
            DispatchQueue.main.async {
                self.delegate?.tourURLQRScanner(self, didPickPayload: payload)
                self.dismiss(animated: true)
            }
        }
    }

    func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
        guard let layer = previewLayer else { return }
        for case let obj as AVMetadataMachineReadableCodeObject in metadataObjects where obj.type == .qr {
            guard let transformed = layer.transformedMetadataObject(for: obj) as? AVMetadataMachineReadableCodeObject,
                  let raw = transformed.stringValue else { continue }
            let value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
            guard !value.isEmpty else { continue }
            finishWithPayload(value)
            return
        }
    }
}
