(() => {
  "use strict";

  const SELECTOR = ".a360-panorama[data-image]";
  const DEG = Math.PI / 180;
  const TAU = Math.PI * 2;

  class A360WebGLPanorama {
    constructor(viewport, options) {
      this.viewport = viewport;
      this.imageUrl = options.imageUrl;
      this.nadirLogoUrl = options.nadirLogoUrl || "";
      this.nadirRotation = Number(options.nadirRotation || 0);
      this.nadirSize = Number(options.nadirSize || 50);
      this.nadirLogo = null;
      this.fov = options.fov * DEG;
      this.minFov = 35 * DEG;
      this.maxFov = 110 * DEG;
      this.yaw = options.yaw * DEG;
      this.pitch = 0;
      this.speed = options.speed * DEG;
      this.hotspots = Array.isArray(options.hotspots) ? options.hotspots : [];
      this.hotspotElements = [];
      this.resumeDelay = options.resumeDelay;
      this.autoRotate = options.autoRotate;
      this.manuallyPaused = !options.autoRotate;
      this.resumeAt = 0;
      this.resumeTimer = 0;
      this.visible = true;
      this.dirty = true;
      this.frame = 0;
      this.lastFrameTime = 0;
      this.velocityYaw = 0;
      this.velocityPitch = 0;
      this.pointers = new Map();
      this.lastPinchDistance = 0;
      this.destroyed = false;
      this.image = null;
      this.canvas = this.viewport.ownerDocument.createElement("canvas");
      this.canvas.className = "a360-panorama__canvas";
      this.canvas.style.width = "100%";
      this.canvas.style.height = "100%";
      this.canvas.setAttribute("aria-hidden", "true");
      this.viewport.appendChild(this.canvas);
      this.hotspotLayer = this.viewport.ownerDocument.createElement("div");
      this.hotspotLayer.className = "a360-panorama__hotspots";
      this.viewport.appendChild(this.hotspotLayer);

      this.createHotspots();
      this.bindHandlers();
    }

    async init() {
      this.createContext();
      this.createProgram();
      this.createGeometry();
      this.createTexture();
      this.observeSize();
      this.observeVisibility();
      this.addControls();
      await Promise.all([this.loadImage(), this.loadNadirLogo()]);
      if (this.destroyed) return;
      this.uploadImage();
      this.resize();
      this.requestFrame();
    }

    destroy() {
      this.destroyed = true;
      cancelAnimationFrame(this.frame);
      this.frame = 0;
      window.clearTimeout(this.resumeTimer);
      this.resizeObserver?.disconnect();
      this.visibilityObserver?.disconnect();
      this.canvas.removeEventListener("pointerdown", this.onPointerDown);
      this.canvas.removeEventListener("pointermove", this.onPointerMove);
      this.canvas.removeEventListener("pointerup", this.onPointerUp);
      this.canvas.removeEventListener("pointercancel", this.onPointerUp);
      this.canvas.removeEventListener("wheel", this.onWheel);
      this.keyboardTarget?.removeEventListener("keydown", this.onKeyDown);
      this.canvas.removeEventListener("webglcontextlost", this.onContextLost);
      this.canvas.removeEventListener("webglcontextrestored", this.onContextRestored);
      this.pointers.clear();
      this.closeHotspotModal();
      if (this.onHotspotModalKeyDown) this.viewport.ownerDocument.removeEventListener("keydown", this.onHotspotModalKeyDown);
      if (this.onHotspotModalClick && this.hotspotModal) this.hotspotModal.removeEventListener("click", this.onHotspotModalClick);
      this.hotspotModal?.remove();
      this.hotspotModal = null;
      this.hotspotLayer?.remove();
      this.hotspotElements = [];
      this.canvas.remove();
    }

    createContext() {
      const attributes = {
        alpha: false,
        antialias: false,
        depth: false,
        stencil: false,
        powerPreference: "high-performance",
        preserveDrawingBuffer: true,
      };
      this.gl = this.canvas.getContext("webgl2", attributes);
      this.webgl2 = Boolean(this.gl);
      if (!this.gl) this.gl = this.canvas.getContext("webgl", attributes);
      if (!this.gl) throw new Error("WebGL is unavailable.");
    }

    createProgram() {
      const vertex = this.webgl2
        ? `#version 300 es
          precision highp float;
          in vec2 a_position;
          void main() { gl_Position = vec4(a_position, 0.0, 1.0); }`
        : `precision highp float;
          attribute vec2 a_position;
          void main() { gl_Position = vec4(a_position, 0.0, 1.0); }`;
      const fragmentBody = `
          precision highp float;
          uniform sampler2D u_panorama;
          uniform vec2 u_resolution;
          uniform float u_yaw;
          uniform float u_pitch;
          uniform float u_fov;
          const float PI = 3.141592653589793;
          void main() {
            vec2 point = (2.0 * gl_FragCoord.xy - u_resolution) / u_resolution.y;
            float distanceToPlane = 1.0 / tan(u_fov * 0.5);
            vec3 ray = normalize(vec3(point.x, point.y, -distanceToPlane));
            float cp = cos(u_pitch);
            float sp = sin(u_pitch);
            ray = vec3(ray.x, cp * ray.y - sp * ray.z, sp * ray.y + cp * ray.z);
            float cy = cos(u_yaw);
            float sy = sin(u_yaw);
            ray = vec3(cy * ray.x + sy * ray.z, ray.y, -sy * ray.x + cy * ray.z);
            vec2 uv = vec2(0.5 + atan(ray.x, -ray.z) / (2.0 * PI), 0.5 + asin(clamp(ray.y, -1.0, 1.0)) / PI);
            COLOR = texture(TEXTURE, uv);
          }`;
      const fragment = this.webgl2
        ? `#version 300 es
          precision highp float;
          out vec4 outputColor;
          ${fragmentBody.replace("COLOR", "outputColor").replace("TEXTURE", "u_panorama")}`
        : fragmentBody.replace("COLOR", "gl_FragColor").replace("TEXTURE", "u_panorama").replace("texture(", "texture2D(");

      const program = this.gl.createProgram();
      this.gl.attachShader(program, this.compileShader(this.gl.VERTEX_SHADER, vertex));
      this.gl.attachShader(program, this.compileShader(this.gl.FRAGMENT_SHADER, fragment));
      this.gl.linkProgram(program);
      if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) {
        throw new Error(this.gl.getProgramInfoLog(program) || "WebGL program could not be linked.");
      }
      this.program = program;
      this.positionLocation = this.gl.getAttribLocation(program, "a_position");
      this.uniforms = {
        panorama: this.gl.getUniformLocation(program, "u_panorama"),
        resolution: this.gl.getUniformLocation(program, "u_resolution"),
        yaw: this.gl.getUniformLocation(program, "u_yaw"),
        pitch: this.gl.getUniformLocation(program, "u_pitch"),
        fov: this.gl.getUniformLocation(program, "u_fov"),
      };
    }

    compileShader(type, source) {
      const shader = this.gl.createShader(type);
      this.gl.shaderSource(shader, source);
      this.gl.compileShader(shader);
      if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
        throw new Error(this.gl.getShaderInfoLog(shader) || "WebGL shader could not be compiled.");
      }
      return shader;
    }

    createGeometry() {
      const vertices = new Float32Array([-1, -1, 3, -1, -1, 3]);
      this.buffer = this.gl.createBuffer();
      this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffer);
      this.gl.bufferData(this.gl.ARRAY_BUFFER, vertices, this.gl.STATIC_DRAW);
    }

    createTexture() {
      this.texture = this.gl.createTexture();
      this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
      this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
      this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
      this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
      this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
    }

    loadImage() {
      return new Promise((resolve, reject) => {
        const image = new Image();
        const sourceUrl = new URL(this.imageUrl, window.location.href);
        if (sourceUrl.origin !== window.location.origin) image.crossOrigin = "anonymous";
        image.decoding = "async";
        image.onload = () => {
          this.image = image;
          resolve();
        };
        image.onerror = () => reject(new Error("Panorama image could not be loaded."));
        image.src = sourceUrl.href;
      });
    }

    setImage(url) {
      if (!url || url === this.imageUrl || this.destroyed) return Promise.resolve();
      const loadToken = (this.imageLoadToken || 0) + 1;
      this.imageLoadToken = loadToken;
      return new Promise((resolve, reject) => {
        const image = new Image();
        const sourceUrl = new URL(url, window.location.href);
        if (sourceUrl.origin !== window.location.origin) image.crossOrigin = "anonymous";
        image.decoding = "async";
        image.onload = () => {
          if (this.destroyed || this.imageLoadToken !== loadToken) return resolve();
          this.imageUrl = url;
          this.image = image;
          this.uploadImage();
          this.requestFrame();
          resolve();
        };
        image.onerror = () => reject(new Error("Panorama image could not be loaded."));
        image.src = sourceUrl.href;
      });
    }

    loadNadirLogo() {
      if (!this.nadirLogoUrl) return Promise.resolve();
      return new Promise((resolve) => {
        const logo = new Image();
        const sourceUrl = new URL(this.nadirLogoUrl, window.location.href);
        if (sourceUrl.origin !== window.location.origin) logo.crossOrigin = "anonymous";
        logo.decoding = "async";
        logo.onload = () => { this.nadirLogo = logo; resolve(); };
        logo.onerror = () => { console.warn("A360 Panorama: nadir logo could not be loaded."); resolve(); };
        logo.src = sourceUrl.href;
      });
    }

    setNadirLogo(url, rotation, size) {
      this.nadirRotation = Number(rotation || 0);
      this.nadirSize = Number(size || 50);
      const nextUrl = url || "";
      if (!this.image || !this.texture) {
        this.nadirLogoUrl = nextUrl;
        return;
      }
      if (nextUrl === this.nadirLogoUrl && (this.nadirLogo || !nextUrl)) {
        this.uploadImage();
        this.requestFrame();
        return;
      }

      this.nadirLogoUrl = nextUrl;
      const loadToken = (this.nadirLoadToken || 0) + 1;
      this.nadirLoadToken = loadToken;
      if (!nextUrl) {
        this.nadirLogo = null;
        this.uploadImage();
        this.requestFrame();
        return;
      }

      const logo = new Image();
      const sourceUrl = new URL(nextUrl, window.location.href);
      if (sourceUrl.origin !== window.location.origin) logo.crossOrigin = "anonymous";
      logo.decoding = "async";
      logo.onload = () => {
        if (this.destroyed || this.nadirLoadToken !== loadToken) return;
        this.nadirLogo = logo;
        this.uploadImage();
        this.requestFrame();
      };
      logo.onerror = () => console.warn("A360 Panorama: nadir logo could not be loaded.");
      logo.src = sourceUrl.href;
    }

    projectNadirLogo(source) {
      if (!this.nadirLogo) return source;
      const canvas = document.createElement("canvas");
      canvas.width = source.width;
      canvas.height = source.height;
      const context = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
      if (!context) return source;
      context.drawImage(source, 0, 0, canvas.width, canvas.height);

      const logo = this.nadirLogo;
      const logoCanvas = document.createElement("canvas");
      const resolution = Math.min(2048, Math.max(512, logo.naturalWidth, logo.naturalHeight));
      logoCanvas.width = resolution;
      logoCanvas.height = resolution;
      const logoContext = logoCanvas.getContext("2d", { willReadFrequently: true });
      if (!logoContext) return source;
      const aspect = logo.naturalWidth / Math.max(1, logo.naturalHeight);
      const drawWidth = aspect >= 1 ? resolution : resolution * aspect;
      const drawHeight = aspect >= 1 ? resolution / aspect : resolution;
      logoContext.drawImage(logo, (resolution - drawWidth) / 2, (resolution - drawHeight) / 2, drawWidth, drawHeight);
      const logoPixels = logoContext.getImageData(0, 0, resolution, resolution).data;
      const capAngle = 0.12 + Math.max(10, Math.min(100, this.nadirSize)) / 100 * 0.43;
      const rotation = this.nadirRotation * DEG + Math.PI;
      const capRows = Math.ceil(canvas.height * capAngle / Math.PI);
      const target = context.getImageData(0, canvas.height - capRows, canvas.width, capRows);

      for (let row = 0; row < capRows; row++) {
        const y = canvas.height - capRows + row;
        const angle = Math.PI * (1 - y / Math.max(1, canvas.height - 1));
        const radius = Math.tan(angle) / Math.tan(capAngle) * 0.5;
        if (radius > 0.5) continue;
        for (let x = 0; x < canvas.width; x++) {
          const longitude = (x / canvas.width - 0.5) * TAU;
          const logoX = 0.5 - Math.sin(longitude - rotation) * radius;
          const logoY = 0.5 + Math.cos(longitude - rotation) * radius;
          if (logoX < 0 || logoX > 1 || logoY < 0 || logoY > 1) continue;
          const sourceX = Math.max(0, Math.min(resolution - 1, Math.round(logoX * (resolution - 1))));
          const sourceY = Math.max(0, Math.min(resolution - 1, Math.round(logoY * (resolution - 1))));
          const sourceIndex = (sourceY * resolution + sourceX) * 4;
          const alpha = logoPixels[sourceIndex + 3] / 255;
          if (alpha <= 0) continue;
          const targetIndex = (row * canvas.width + x) * 4;
          target.data[targetIndex] = target.data[targetIndex] * (1 - alpha) + logoPixels[sourceIndex] * alpha;
          target.data[targetIndex + 1] = target.data[targetIndex + 1] * (1 - alpha) + logoPixels[sourceIndex + 1] * alpha;
          target.data[targetIndex + 2] = target.data[targetIndex + 2] * (1 - alpha) + logoPixels[sourceIndex + 2] * alpha;
        }
      }
      context.putImageData(target, 0, canvas.height - capRows);
      return canvas;
    }

    uploadImage() {
      const gl = this.gl;
      const maxTexture = gl.getParameter(gl.MAX_TEXTURE_SIZE);
      const memory = Number(navigator.deviceMemory || 8);
      const qualityLimit = memory <= 4 ? 4096 : 8192;
      const limit = Math.min(maxTexture, qualityLimit);
      let source = this.image;

      if (source.width > limit || source.height > limit) {
        const scale = Math.min(limit / source.width, limit / source.height);
        const resized = document.createElement("canvas");
        resized.width = Math.max(1, Math.round(source.width * scale));
        resized.height = Math.max(1, Math.round(source.height * scale));
        const context = resized.getContext("2d", { alpha: false });
        context.drawImage(source, 0, 0, resized.width, resized.height);
        source = resized;
      }

      source = this.projectNadirLogo(source);

      gl.bindTexture(gl.TEXTURE_2D, this.texture);
      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
      this.dirty = true;
    }

    bindHandlers() {
      this.onPointerDown = this.pointerDown.bind(this);
      this.onPointerMove = this.pointerMove.bind(this);
      this.onPointerUp = this.pointerUp.bind(this);
      this.onWheel = this.wheel.bind(this);
      this.onKeyDown = this.keyDown.bind(this);
      this.onContextLost = (event) => {
        event.preventDefault();
        cancelAnimationFrame(this.frame);
        this.frame = 0;
      };
      this.onContextRestored = () => {
        try {
          this.createProgram();
          this.createGeometry();
          this.createTexture();
          this.uploadImage();
          this.requestFrame();
        } catch (error) {
          console.error("A360 Panorama:", error);
        }
      };
    }

    addControls() {
      this.canvas.addEventListener("pointerdown", this.onPointerDown);
      this.canvas.addEventListener("pointermove", this.onPointerMove);
      this.canvas.addEventListener("pointerup", this.onPointerUp);
      this.canvas.addEventListener("pointercancel", this.onPointerUp);
      this.canvas.addEventListener("wheel", this.onWheel, { passive: false });
      this.keyboardTarget = this.viewport.closest(".a360-panorama");
      this.keyboardTarget?.addEventListener("keydown", this.onKeyDown);
      this.canvas.addEventListener("webglcontextlost", this.onContextLost);
      this.canvas.addEventListener("webglcontextrestored", this.onContextRestored);
    }

    observeSize() {
      this.resizeObserver = new ResizeObserver(() => this.resize());
      this.resizeObserver.observe(this.viewport);
    }

    observeVisibility() {
      this.visibilityObserver = new IntersectionObserver((entries) => {
        this.visible = entries[0]?.isIntersecting ?? true;
        if (this.visible) this.requestFrame();
      }, { rootMargin: "150px" });
      this.visibilityObserver.observe(this.viewport);
    }

    resize() {
      const rect = this.viewport.getBoundingClientRect();
      const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
      const width = Math.max(1, Math.round(rect.width * pixelRatio));
      const height = Math.max(1, Math.round(rect.height * pixelRatio));
      if (this.canvas.width !== width || this.canvas.height !== height) {
        this.canvas.width = width;
        this.canvas.height = height;
        this.dirty = true;
        this.requestFrame();
      }
    }

    pointerDown(event) {
      event.preventDefault();
      this.canvas.setPointerCapture(event.pointerId);
      this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY, time: performance.now() });
      this.velocityYaw = 0;
      this.velocityPitch = 0;
      this.interacted();
      if (this.pointers.size === 2) this.lastPinchDistance = this.pinchDistance();
    }

    pointerMove(event) {
      const previous = this.pointers.get(event.pointerId);
      if (!previous) return;
      const now = performance.now();
      const dx = event.clientX - previous.x;
      const dy = event.clientY - previous.y;
      const elapsed = Math.max(8, now - previous.time);
      this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY, time: now });

      if (this.pointers.size === 1) {
        const radiansPerPixel = this.fov / Math.max(240, this.viewport.clientHeight);
        this.yaw += dx * radiansPerPixel;
        this.pitch = this.clampPitch(this.pitch + dy * radiansPerPixel);
        this.velocityYaw = (dx * radiansPerPixel) / elapsed;
        this.velocityPitch = (dy * radiansPerPixel) / elapsed;
      } else if (this.pointers.size === 2) {
        const distance = this.pinchDistance();
        if (this.lastPinchDistance > 0) this.setFov(this.fov * this.lastPinchDistance / distance);
        this.lastPinchDistance = distance;
      }
      this.interacted();
      this.dirty = true;
      this.requestFrame();
    }

    pointerUp(event) {
      this.pointers.delete(event.pointerId);
      if (this.pointers.size < 2) this.lastPinchDistance = 0;
      this.interacted();
      this.requestFrame();
    }

    pinchDistance() {
      const points = Array.from(this.pointers.values());
      return points.length < 2 ? 0 : Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
    }

    wheel(event) {
      event.preventDefault();
      this.setFov(this.fov * Math.exp(event.deltaY * 0.001));
      this.interacted();
      this.requestFrame();
    }

    keyDown(event) {
      const step = 4 * DEG;
      let handled = true;
      if (event.key === "ArrowLeft") this.yaw -= step;
      else if (event.key === "ArrowRight") this.yaw += step;
      else if (event.key === "ArrowUp") this.pitch = this.clampPitch(this.pitch - step);
      else if (event.key === "ArrowDown") this.pitch = this.clampPitch(this.pitch + step);
      else if (event.key === "+" || event.key === "=") this.setFov(this.fov * 0.9);
      else if (event.key === "-" || event.key === "_") this.setFov(this.fov * 1.1);
      else handled = false;
      if (!handled) return;
      event.preventDefault();
      this.interacted();
      this.dirty = true;
      this.requestFrame();
    }

    interacted() {
      this.onInteraction?.();
      window.clearTimeout(this.resumeTimer);
      this.resumeAt = performance.now() + this.resumeDelay;
      if (this.autoRotate && !this.manuallyPaused) {
        this.resumeTimer = window.setTimeout(() => {
          this.resumeTimer = 0;
          this.onAutoRotateResume?.();
          this.requestFrame();
        }, this.resumeDelay + 20);
      }
    }

    setFov(value) {
      this.fov = Math.max(this.minFov, Math.min(this.maxFov, value));
      this.dirty = true;
    }

    clampPitch(value) {
      const limit = Math.PI / 2 - 0.01;
      return Math.max(-limit, Math.min(limit, value));
    }

    setPaused(paused) {
      this.manuallyPaused = paused;
      if (paused) this.onInteraction?.();
      window.clearTimeout(this.resumeTimer);
      this.resumeTimer = 0;
      if (!paused) {
        this.autoRotate = true;
        this.resumeAt = 0;
        this.onAutoRotateResume?.();
      }
      this.requestFrame();
    }

    requestFrame() {
      if (!this.frame && this.visible) this.frame = requestAnimationFrame((time) => this.tick(time));
    }

    tick(time) {
      this.frame = 0;
      const elapsed = this.lastFrameTime ? Math.min(50, time - this.lastFrameTime) : 16;
      this.lastFrameTime = time;
      let moving = false;

      if (!this.pointers.size && (Math.abs(this.velocityYaw) > 0.00001 || Math.abs(this.velocityPitch) > 0.00001)) {
        this.yaw += this.velocityYaw * elapsed;
        this.pitch = this.clampPitch(this.pitch + this.velocityPitch * elapsed);
        const damping = Math.pow(0.92, elapsed / 16);
        this.velocityYaw *= damping;
        this.velocityPitch *= damping;
        this.dirty = true;
        moving = true;
      }

      if (this.autoRotate && !this.manuallyPaused && !this.pointers.size && time >= this.resumeAt) {
        const rotationStep = this.speed * elapsed / 1000;
        this.yaw = (this.yaw - rotationStep) % TAU;

        // Return the view to the natural horizon while autorotation is active.
        // Using the configured rotation speed keeps both movements visually consistent.
        if (Math.abs(this.pitch) <= rotationStep) {
          this.pitch = 0;
        } else {
          this.pitch -= Math.sign(this.pitch) * rotationStep;
        }

        this.dirty = true;
        moving = true;
      }

      if (this.dirty) this.render();
      if (moving) this.requestFrame();
    }
    screenToPanorama(clientX, clientY) {
      const rect = this.viewport.getBoundingClientRect();
      if (!rect.width || !rect.height) return null;

      const localX = clientX - rect.left;
      const localY = clientY - rect.top;
      const pointX = (2 * localX - rect.width) / rect.height;
      const pointY = (rect.height - 2 * localY) / rect.height;
      const distanceToPlane = 1 / Math.tan(this.fov * 0.5);

      let rayX = pointX;
      let rayY = pointY;
      let rayZ = -distanceToPlane;
      const length = Math.hypot(rayX, rayY, rayZ) || 1;
      rayX /= length;
      rayY /= length;
      rayZ /= length;

      const cp = Math.cos(this.pitch);
      const sp = Math.sin(this.pitch);
      const pitchedY = cp * rayY - sp * rayZ;
      const pitchedZ = sp * rayY + cp * rayZ;
      rayY = pitchedY;
      rayZ = pitchedZ;

      const cy = Math.cos(this.yaw);
      const sy = Math.sin(this.yaw);
      const worldX = cy * rayX + sy * rayZ;
      const worldZ = -sy * rayX + cy * rayZ;
      rayX = worldX;
      rayZ = worldZ;

      let yaw = Math.atan2(rayX, -rayZ) / DEG;
      const pitch = Math.asin(Math.max(-1, Math.min(1, rayY))) / DEG;
      yaw = ((yaw + 180) % 360 + 360) % 360 - 180;

      return {
        yaw: Math.round(yaw * 10) / 10,
        pitch: Math.round(pitch * 10) / 10,
      };
    }

    youtubeEmbedUrl(url) {
      if (!url) return "";
      try {
        const parsed = new URL(url, window.location.href);
        let id = "";
        if (parsed.hostname.includes("youtu.be")) id = parsed.pathname.split("/").filter(Boolean)[0] || "";
        else if (parsed.hostname.includes("youtube.com") || parsed.hostname.includes("youtube-nocookie.com")) {
          if (parsed.pathname === "/watch") id = parsed.searchParams.get("v") || "";
          else {
            const parts = parsed.pathname.split("/").filter(Boolean);
            if (["embed", "shorts", "live"].includes(parts[0])) id = parts[1] || "";
          }
        }
        if (!/^[A-Za-z0-9_-]{6,20}$/.test(id)) return "";
        return `https://www.youtube-nocookie.com/embed/${id}?rel=0`;
      } catch (error) { return ""; }
    }

    ensureHotspotModal() {
      if (this.hotspotModal) return this.hotspotModal;
      const root = this.viewport.closest(".a360-panorama-hero") || this.viewport.closest(".a360-panorama") || this.viewport;
      const modal = this.viewport.ownerDocument.createElement("div");
      modal.className = "a360-hotspot-modal";
      modal.hidden = true;
      modal.setAttribute("role", "dialog");
      modal.setAttribute("aria-modal", "true");
      modal.setAttribute("aria-label", "Hotspot information");
      modal.innerHTML = `<div class="a360-hotspot-modal__backdrop" data-a360-hotspot-close></div><div class="a360-hotspot-modal__dialog" role="document"><button class="a360-hotspot-modal__close" type="button" aria-label="Close" data-a360-hotspot-close>×</button><div class="a360-hotspot-modal__body"></div></div>`;
      this.onHotspotModalClick = (event) => { if (event.target.closest("[data-a360-hotspot-close]")) this.closeHotspotModal(); };
      this.onHotspotModalKeyDown = (event) => { if (event.key === "Escape") this.closeHotspotModal(); };
      modal.addEventListener("click", this.onHotspotModalClick);
      this.viewport.ownerDocument.addEventListener("keydown", this.onHotspotModalKeyDown);
      root.appendChild(modal);
      this.hotspotModal = modal;
      return modal;
    }

    openInfoModal(hotspot, trigger) {
      const modal = this.ensureHotspotModal();
      const body = modal.querySelector(".a360-hotspot-modal__body");
      if (!body) return;
      body.replaceChildren();
      this.hotspotModalTrigger = trigger || null;
      const gallery = Array.isArray(hotspot.gallery) ? hotspot.gallery.filter((image) => image && image.url) : [];
      if (gallery.length) {
        const galleryWrap = document.createElement("div"); galleryWrap.className = "a360-hotspot-modal__gallery";
        const main = document.createElement("img"); main.className = "a360-hotspot-modal__gallery-main"; main.src = gallery[0].url; main.alt = gallery[0].alt || hotspot.title || ""; main.loading = "eager"; galleryWrap.appendChild(main);
        if (gallery.length > 1) {
          const thumbs = document.createElement("div"); thumbs.className = "a360-hotspot-modal__thumbs";
          gallery.forEach((image, index) => {
            const button = document.createElement("button"); button.type = "button"; button.className = "a360-hotspot-modal__thumb" + (index === 0 ? " is-active" : ""); button.setAttribute("aria-label", `Show image ${index + 1}`);
            const thumb = document.createElement("img"); thumb.src = image.url; thumb.alt = image.alt || ""; thumb.loading = "lazy"; button.appendChild(thumb);
            button.addEventListener("click", () => { main.src = image.url; main.alt = image.alt || hotspot.title || ""; thumbs.querySelectorAll(".a360-hotspot-modal__thumb").forEach((item) => item.classList.remove("is-active")); button.classList.add("is-active"); });
            thumbs.appendChild(button);
          });
          galleryWrap.appendChild(thumbs);
        }
        body.appendChild(galleryWrap);
      }
      const content = document.createElement("div"); content.className = "a360-hotspot-modal__content";
      if (hotspot.title) { const title = document.createElement("h3"); title.className = "a360-hotspot-modal__title"; title.textContent = hotspot.title; content.appendChild(title); }
      if (hotspot.description) {
        const description = document.createElement("div"); description.className = "a360-hotspot-modal__description";
        hotspot.description.split(/\n{2,}/).forEach((paragraphText) => { const paragraph = document.createElement("p"); paragraph.textContent = paragraphText.trim(); if (paragraph.textContent) description.appendChild(paragraph); });
        content.appendChild(description);
      }
      const youtubeUrl = this.youtubeEmbedUrl(hotspot.youtube);
      if (youtubeUrl) {
        const video = document.createElement("div"); video.className = "a360-hotspot-modal__video";
        const iframe = document.createElement("iframe"); iframe.src = youtubeUrl; iframe.title = hotspot.title ? `${hotspot.title} — YouTube` : "YouTube video"; iframe.loading = "lazy"; iframe.allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"; iframe.referrerPolicy = "strict-origin-when-cross-origin"; iframe.allowFullscreen = true; video.appendChild(iframe); content.appendChild(video);
      }
      body.appendChild(content);
      modal.hidden = false;
      modal.classList.add("is-open");
      this.hotspotShouldResumeRotation = this.autoRotate && !this.manuallyPaused;
      this.setPaused(true);
      window.requestAnimationFrame(() => modal.querySelector(".a360-hotspot-modal__close")?.focus());
    }

    closeHotspotModal(resumeRotation = true) {
      if (!this.hotspotModal || this.hotspotModal.hidden) return;
      this.hotspotModal.classList.remove("is-open");
      this.hotspotModal.hidden = true;
      const iframe = this.hotspotModal.querySelector("iframe"); if (iframe) iframe.src = "about:blank";
      this.hotspotModalTrigger?.focus?.(); this.hotspotModalTrigger = null;
      const shouldResume = this.hotspotShouldResumeRotation;
      this.hotspotShouldResumeRotation = false;
      if (resumeRotation && shouldResume && !this.destroyed) this.setPaused(false);
    }

    createHotspots() {
      if (!this.hotspotLayer) return;

      this.hotspotLayer.innerHTML = "";
      this.hotspotElements = [];

      this.hotspots.forEach((hotspot) => {
        const type = ["info", "product", "link"].includes(hotspot.type) ? hotspot.type : "info";
        const isUrlLink = type === "link" && (hotspot.destinationType || "url") === "url" && hotspot.url;
        const isPanoramaLink = type === "link" && hotspot.destinationType === "panorama" && hotspot.targetImageUrl;
        const element = document.createElement(isUrlLink ? "a" : "button");

        element.className = `a360-panorama__hotspot a360-panorama__hotspot--${type}`;
        element.dataset.hotspotId = String(hotspot.id || "");
        element.dataset.hotspotType = type;
        element.dataset.yaw = String(hotspot.yaw || 0);
        element.dataset.pitch = String(hotspot.pitch || 0);

        if (isUrlLink) {
          element.href = hotspot.url;
          if (hotspot.targetBlank) {
            element.target = "_blank";
            element.rel = "noopener noreferrer";
          }
        } else {
          element.type = "button";
        }

        element.setAttribute(
          "aria-label",
          hotspot.title || "Hotspot"
        );

        element.innerHTML = `
      <span class="a360-panorama__hotspot-dot"></span>
      ${hotspot.title && hotspot.visible !== false
            ? `<span class="a360-panorama__hotspot-label"></span>`
            : ""
          }
    `;

        const label = element.querySelector(
          ".a360-panorama__hotspot-label"
        );

        if (label) {
          label.textContent = hotspot.title;
        }

        if (type === "info") {
          element.addEventListener("click", (event) => {
            if (this.viewport.closest(".is-editor-preview")) return;
            event.preventDefault();
            event.stopPropagation();
            this.openInfoModal(hotspot, element);
          });
        }

        if (isPanoramaLink) {
          element.addEventListener("click", (event) => {
            if (this.viewport.closest(".is-editor-preview")) return;
            event.preventDefault();
            event.stopPropagation();
            this.onPanoramaLink?.(hotspot.targetImageUrl);
          });
        }

        this.hotspotLayer.appendChild(element);

        this.hotspotElements.push({
          element,
          yaw: Number(hotspot.yaw || 0) * DEG,
          pitch: Number(hotspot.pitch || 0) * DEG,
        });
      });
    }

    setHotspots(hotspots) {
      this.closeHotspotModal(false);
      this.hotspots = Array.isArray(hotspots) ? hotspots : [];
      this.createHotspots();
      this.dirty = true;
      this.requestFrame();
    }


    updateHotspots() {
      if (!this.hotspotElements.length) return;

      const width = this.viewport.clientWidth;
      const height = this.viewport.clientHeight;

      if (!width || !height) return;

      const distanceToPlane = 1 / Math.tan(this.fov * 0.5);

      const cy = Math.cos(this.yaw);
      const sy = Math.sin(this.yaw);

      const cp = Math.cos(this.pitch);
      const sp = Math.sin(this.pitch);

      this.hotspotElements.forEach((hotspot) => {
        /*
         * Pozycja hotspotu na sferze.
         *
         * Układ jest zgodny z shaderem A360:
         * x = sin(yaw)
         * y = sin(pitch)
         * z = -cos(yaw)
         */
        const cosPitch = Math.cos(hotspot.pitch);

        const worldX = Math.sin(hotspot.yaw) * cosPitch;
        const worldY = Math.sin(hotspot.pitch);
        const worldZ = -Math.cos(hotspot.yaw) * cosPitch;

        /*
         * Odwrócenie obrotu kamery YAW.
         */
        const x1 = cy * worldX - sy * worldZ;
        const y1 = worldY;
        const z1 = sy * worldX + cy * worldZ;

        /*
         * Odwrócenie obrotu kamery PITCH.
         */
        const cameraX = x1;
        const cameraY = cp * y1 + sp * z1;
        const cameraZ = -sp * y1 + cp * z1;

        /*
         * Hotspot znajduje się za kamerą.
         */
        if (cameraZ >= -0.001) {
          hotspot.element.hidden = true;
          return;
        }

        const pointX =
          (-distanceToPlane * cameraX) / cameraZ;

        const pointY =
          (-distanceToPlane * cameraY) / cameraZ;

        const screenX =
          width / 2 + (pointX * height) / 2;

        const screenY =
          height / 2 - (pointY * height) / 2;

        /*
         * Nie pokazujemy hotspotów daleko poza ekranem.
         */
        if (
          screenX < -100 ||
          screenX > width + 100 ||
          screenY < -100 ||
          screenY > height + 100
        ) {
          hotspot.element.hidden = true;
          return;
        }

        hotspot.element.hidden = false;

        hotspot.element.style.transform =
          `translate3d(${screenX}px, ${screenY}px, 0) translate(-50%, -50%)`;
      });
    }

    render() {
      const gl = this.gl;
      gl.viewport(0, 0, this.canvas.width, this.canvas.height);
      gl.useProgram(this.program);
      gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
      gl.enableVertexAttribArray(this.positionLocation);
      gl.vertexAttribPointer(this.positionLocation, 2, gl.FLOAT, false, 0, 0);
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, this.texture);
      gl.uniform1i(this.uniforms.panorama, 0);
      gl.uniform2f(this.uniforms.resolution, this.canvas.width, this.canvas.height);
      gl.uniform1f(this.uniforms.yaw, this.yaw);
      gl.uniform1f(this.uniforms.pitch, this.pitch);
      gl.uniform1f(this.uniforms.fov, this.fov);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
      this.updateHotspots();
      this.dirty = false;
    }
  }

  class A360PanoramaController {
    constructor(element) {
      this.element = element;
      this.viewport = element.querySelector(".a360-panorama__viewport");
      this.loader = element.querySelector(".a360-panorama__loader");
      this.error = element.querySelector(".a360-panorama__error");
      this.toggle = element.querySelector(".a360-panorama__toggle");
      this.hint = element.querySelector(".a360-panorama__interaction-hint");
      this.pauseLabel = element.dataset.pauseLabel || "Pause panorama rotation";
      this.resumeLabel = element.dataset.resumeLabel || "Resume panorama rotation";
      const autoplay = ["respect", "always", "off"].includes(element.dataset.autoplay) ? element.dataset.autoplay : "always";
      const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches || false;
      this.autoRotate = autoplay === "always" || (autoplay === "respect" && !reducedMotion);
      this.isPaused = !this.autoRotate;
      this.scenes = this.readScenes();
      this.images = this.scenes.map((scene) => scene.url);
      this.imageIndex = 0;
      this.panoramaInterval = this.numberInRange(element.dataset.panoramaInterval, 8, 1, 300) * 1000;
      this.switchTimer = 0;
      this.isSwitching = false;
    }

    readImages() {
      let images = [];
      try {
        images = JSON.parse(this.element.dataset.images || "[]");
      } catch (error) {
        console.warn("A360 Panorama: invalid panorama list.", error);
      }
      images = Array.isArray(images) ? images.filter((url) => typeof url === "string" && url) : [];
      if (!images.length && this.element.dataset.image) images.push(this.element.dataset.image);
      return [...new Set(images)];
    }

    readScenes() {
      let scenes = [];
      try {
        scenes = JSON.parse(this.element.dataset.scenes || "[]");
      } catch (error) {
        console.warn("A360 Panorama: invalid scene list.", error);
      }
      scenes = Array.isArray(scenes) ? scenes.filter((scene) => scene && typeof scene.url === "string" && scene.url).map((scene) => ({
        url: scene.url,
        hotspots: Array.isArray(scene.hotspots) ? scene.hotspots : [],
      })) : [];
      if (!scenes.length) return this.readImages().map((url) => ({ url, hotspots: [] }));
      return scenes;
    }

    async init() {
      if (!this.viewport || !this.element.dataset.image) return this.showError();
      try {
        let hotspots = [];

        try {
          hotspots = JSON.parse(this.element.dataset.hotspots || "[]");
        } catch (error) {
          console.warn("A360 Panorama: invalid hotspots data.", error);
        }
        this.hotspots = Array.isArray(hotspots) ? hotspots : [];
        if (this.scenes[0] && !this.scenes[0].hotspots.length) this.scenes[0].hotspots = this.hotspots;
        this.viewer = new A360WebGLPanorama(this.viewport, {

          imageUrl: this.element.dataset.image,
          speed: this.numberInRange(this.element.dataset.speed, 2, 0.1, 12),
          fov: this.numberInRange(this.element.dataset.fov, 75, 35, 110),
          yaw: this.numberInRange(this.element.dataset.yaw, 0, -180, 180),
          resumeDelay: this.numberInRange(this.element.dataset.resume, 4, 0, 30) * 1000,
          autoRotate: this.autoRotate,
          hotspots: this.hotspots,
          nadirLogoUrl: this.element.dataset.nadirLogo || "",
          nadirRotation: this.numberInRange(this.element.dataset.nadirRotation, 0, -180, 180),
          nadirSize: this.numberInRange(this.element.dataset.nadirSize, 50, 10, 100),
        });
        this.viewer.onInteraction = () => this.pausePanoramaRotation();
        this.viewer.onAutoRotateResume = () => this.restartPanoramaRotation();
        this.viewer.onPanoramaLink = (url) => this.showPanoramaTarget(url);
        await this.viewer.init();
        if (this.element.a360Controller !== this) return;
        this.onToggle = () => this.toggleRotation();
        this.toggle?.addEventListener("click", this.onToggle);
        this.onFirstInteraction = () => this.dismissHint();
        this.element.addEventListener("pointerdown", this.onFirstInteraction, { passive: true });
        this.element.addEventListener("wheel", this.onFirstInteraction, { passive: true });
        this.element.addEventListener("keydown", this.onFirstInteraction);
        this.element.classList.add("is-ready");
        this.markHero("is-panorama-ready");
        if (this.loader) this.loader.hidden = true;
        this.updateToggle();
        this.startPanoramaRotation();
      } catch (error) {
        console.error("A360 Panorama:", error);
        this.showError();
      }
    }

    destroy() {
      window.clearInterval(this.switchTimer);
      this.toggle?.removeEventListener("click", this.onToggle);
      this.element.removeEventListener("pointerdown", this.onFirstInteraction);
      this.element.removeEventListener("wheel", this.onFirstInteraction);
      this.element.removeEventListener("keydown", this.onFirstInteraction);
      this.viewer?.destroy();
      delete this.element.a360Controller;
      delete this.element.dataset.a360Initialized;
    }

    pausePanoramaRotation() {
      window.clearInterval(this.switchTimer);
      this.switchTimer = 0;
    }

    restartPanoramaRotation() {
      this.pausePanoramaRotation();
      if (this.images.length < 2 || this.isPaused) return;
      this.switchTimer = window.setInterval(() => this.showNextPanorama(), this.panoramaInterval);
    }

    startPanoramaRotation() {
      this.restartPanoramaRotation();
    }

    async showNextPanorama() {
      if (this.isSwitching || !this.viewer || this.images.length < 2) return;
      this.isSwitching = true;
      this.pausePanoramaRotation();
      const nextIndex = (this.imageIndex + 1) % this.images.length;
      try {
        await this.crossfadeToScene(this.scenes[nextIndex]);
        if (this.element.a360Controller !== this) return;
        this.imageIndex = nextIndex;
      } catch (error) {
        console.warn("A360 Panorama: could not switch panorama.", error);
      } finally {
        this.isSwitching = false;
        this.restartPanoramaRotation();
      }
    }

    async showPanoramaTarget(url) {
      if (this.isSwitching || !this.viewer || !url) return;
      const targetUrl = new URL(url, window.location.href).href;
      const targetIndex = this.scenes.findIndex((scene) => new URL(scene.url, window.location.href).href === targetUrl);
      const scene = targetIndex >= 0 ? this.scenes[targetIndex] : { url: targetUrl, hotspots: [] };
      this.isSwitching = true;
      this.pausePanoramaRotation();
      try {
        await this.crossfadeToScene(scene);
        if (targetIndex >= 0) this.imageIndex = targetIndex;
      } catch (error) {
        console.warn("A360 Panorama: could not open target panorama.", error);
      } finally {
        this.isSwitching = false;
        this.restartPanoramaRotation();
      }
    }

    async crossfadeToScene(scene) {
      const viewer = this.viewer;
      const duration = 2000;

      viewer.render();
      const outgoingCanvas = document.createElement("canvas");
      outgoingCanvas.width = viewer.canvas.width;
      outgoingCanvas.height = viewer.canvas.height;
      outgoingCanvas.className = "a360-panorama__canvas a360-panorama__canvas--outgoing";
      outgoingCanvas.getContext("2d", { alpha: false }).drawImage(viewer.canvas, 0, 0);
      outgoingCanvas.style.cssText = "position:absolute;inset:0;z-index:1;width:100%;height:100%;opacity:1;pointer-events:none";
      viewer.viewport.appendChild(outgoingCanvas);

      const outgoingHotspots = viewer.hotspotLayer.cloneNode(true);
      outgoingHotspots.classList.add("a360-panorama__hotspots--outgoing");
      outgoingHotspots.style.cssText += ";z-index:3;opacity:1;pointer-events:none";
      viewer.viewport.appendChild(outgoingHotspots);

      viewer.canvas.style.cssText += ";position:absolute;inset:0;z-index:2;opacity:0";
      viewer.hotspotLayer.style.zIndex = "4";
      viewer.hotspotLayer.style.opacity = "0";

      await viewer.setImage(scene.url);
      viewer.setHotspots(scene.hotspots || []);
      viewer.render();

      const options = { duration, easing: "ease-in-out", fill: "forwards" };
      const animations = [
        outgoingCanvas.animate([{ opacity: 1 }, { opacity: 0 }], options),
        viewer.canvas.animate([{ opacity: 0 }, { opacity: 1 }], options),
        outgoingHotspots.animate([{ opacity: 1 }, { opacity: 0 }], options),
        viewer.hotspotLayer.animate([{ opacity: 0 }, { opacity: 1 }], options),
      ];
      await Promise.all(animations.map((animation) => animation.finished));

      viewer.canvas.style.opacity = "1";
      viewer.hotspotLayer.style.opacity = "1";
      outgoingCanvas.remove();
      outgoingHotspots.remove();
      viewer.hotspotLayer.style.zIndex = "";
    }

    toggleRotation() {
      this.isPaused = !this.isPaused;
      this.viewer?.setPaused(this.isPaused);
      this.updateToggle();
    }

    dismissHint() {
      if (!this.hint || this.element.classList.contains("has-used-panorama")) return;
      this.element.classList.add("has-used-panorama");
    }

    updateToggle() {
      if (!this.toggle) return;
      this.toggle.setAttribute("aria-pressed", String(this.isPaused));
      this.toggle.setAttribute("aria-label", this.isPaused ? this.resumeLabel : this.pauseLabel);
      const icon = this.toggle.querySelector("span");
      if (icon) icon.textContent = this.isPaused ? "▶" : "Ⅱ";
    }

    numberInRange(value, fallback, min, max) {
      const parsed = Number.parseFloat(value);
      return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
    }

    markHero(className) {
      const media = this.element.closest(".a360-panorama-hero__media");
      const hero = this.element.closest(".a360-panorama-hero");
      if (media) media.removeAttribute("aria-busy");
      if (hero) hero.classList.add(className);
    }

    showError() {
      this.element.classList.add("has-error");
      this.markHero("has-panorama-error");
      if (this.loader) this.loader.hidden = true;
      if (this.error) this.error.hidden = false;
      if (this.toggle) this.toggle.hidden = true;
    }
  }

  function initPanoramas(root = document) {
    const elements = [];
    if (root.matches?.(SELECTOR)) elements.push(root);
    root.querySelectorAll?.(SELECTOR).forEach((element) => elements.push(element));
    elements.forEach((element) => {
      if (element.dataset.a360Initialized === "true") return;
      element.dataset.a360Initialized = "true";
      const controller = new A360PanoramaController(element);
      element.a360Controller = controller;
      controller.init();
    });
  }

  function destroyPanorama(element) {
    element?.a360Controller?.destroy();
  }

  function scheduleInit() {
    const run = () => initPanoramas();
    const afterLoad = () => "requestIdleCallback" in window
      ? window.requestIdleCallback(run, { timeout: 1000 })
      : window.setTimeout(run, 250);
    if (document.readyState === "complete") afterLoad();
    else window.addEventListener("load", afterLoad, { once: true });
  }

  scheduleInit();
  new MutationObserver((mutations) => {
    if (mutations.some((mutation) => mutation.addedNodes.length)) initPanoramas();
  }).observe(document.documentElement, { childList: true, subtree: true });

  window.A360PanoramaViewer = {
    init: initPanoramas,
    destroy: destroyPanorama,
  };
})();
