/**
 * Non-side-effect class export for `<swiper-ui>`.
 *
 * Importing this file gives you the class(es) without auto-registering the tag.
 * Useful for test isolation, subclassing with tag-name override, or selective
 * composition.
 *
 * The auto-register path stays at `@adia-ai/web-components/components/swiper`
 * (which imports this file + calls `defineIfFree()`).
 *
 * @see ../../USAGE.md#registration--auto-vs-explicit
 */

/**
 * <swiper-ui autoplay interval="5000" peek>
 *   <img src="slide1.jpg" alt="Slide 1">
 *   <card-ui>...</card-ui>
 *   <div>Any content</div>
 * </swiper-ui>
 *
 * CSS-first carousel. Scroll-snap does all positioning.
 * JS handles: auto-advance, programmatic API, ARIA, paddles + dots.
 *
 * On connect, wraps user children into a [data-swiper-track] scroll
 * container so pagination dots can sit below (outside overflow).
 *
 * Pagination dots and prev/next paddles are JS-stamped on every
 * engine. Native ::scroll-marker / ::scroll-button are Chrome-135-only
 * with no Safari / Firefox roadmap; re-evaluate when cross-engine.
 *
 * Events:
 *   change         — { detail: { index, slide } }
 *   autoplay-pause — { detail: { reason } }
 *   autoplay-resume
 */

import { UIElement } from '../../core/element.js';

export class UISwiper extends UIElement {
  static properties = {
    autoplay:          { type: Boolean, default: false, reflect: true },
    interval:          { type: Number,  default: 5000,  reflect: true },
    noPauseOnHover:    { type: Boolean, default: false, reflect: true, attribute: 'no-pause-on-hover' },
    loop:              { type: Boolean, default: false, reflect: true },
    peek:              { type: Boolean, default: false, reflect: true },
    snap:              { type: String,  default: 'start', reflect: true },
    gap:               { type: String,  default: '',    reflect: true },
    slidesPerView:     { type: String,  default: '',    reflect: true, attribute: 'slides-per-view' },
    chrome:            { type: String,  default: 'default', reflect: true },
    label:             { type: String,  default: '' },
    counter:           { type: Boolean, default: false, reflect: true },
  };

  static template = () => null;

  #track = null;
  #timer = null;
  #resizeObserver = null;
  #scrollHandler = null;
  #activeIndex = 0;
  #bound = false;
  #fallbackNav = false;
  #drag = null; /* { pointerId, startX, startScrollLeft, hasMoved } */

  get slides() {
    if (!this.#track) return [];
    return [...this.#track.children];
  }

  get activeIndex() { return this.#activeIndex; }

  connected() {
    // Wrap user children into a scroll track (once)
    if (!this.#track) {
      this.#track = document.createElement('div');
      this.#track.setAttribute('data-swiper-track', '');

      // Move all current children into the track
      while (this.firstChild) {
        this.#track.appendChild(this.firstChild);
      }
      this.appendChild(this.#track);
    }

    // ARIA
    this.setAttribute('role', 'region');
    this.setAttribute('aria-roledescription', 'carousel');
    if (!this.getAttribute('aria-label')) {
      this.setAttribute('aria-label', 'Carousel');
    }

    this.#labelSlides();
    this.#setupObserver();
    this.#setupFallbackNav();

    if (!this.#bound) {
      this.#bound = true;

      // Pause on hover/focus (default on; suppress with [no-pause-on-hover])
      if (!this.noPauseOnHover) {
        this.addEventListener('pointerenter', this.#onPauseHover);
        this.addEventListener('pointerleave', this.#onResumeHover);
        this.addEventListener('focusin', this.#onPauseFocus);
        this.addEventListener('focusout', this.#onResumeFocus);
      }

      // Keyboard navigation
      this.addEventListener('keydown', this.#onKeydown);

      // Click + drag (mouse / pen). Touch keeps native pan via scroll-snap.
      this.#track.addEventListener('pointerdown', this.#onPointerDown);
      this.#track.addEventListener('pointermove', this.#onPointerMove);
      this.#track.addEventListener('pointerup',     this.#onPointerUp);
      this.#track.addEventListener('pointercancel', this.#onPointerUp);
      // Capture-phase click suppression so a drag-on-button doesn't activate it.
      this.#track.addEventListener('click', this.#onClickCapture, true);
      // Suppress native HTML5 drag (e.g. on <img>) so it doesn't preempt
      // the pointermove gesture before our threshold is reached.
      this.#track.addEventListener('dragstart', this.#onDragStart);
    }

    if (this.autoplay) this.play();
  }

  render() {
    if (this.gap && this.#track) {
      // Pure-numeric values ("0", "4") are space-token indices that
      // resolve to `--a-space-N`. Anything else (e.g. "1rem", "8px",
      // "var(--my-gap)") is treated as a raw CSS length — matches the
      // example HTML's "Accepts any CSS length or spacing token" claim.
      const isTokenIndex = /^[0-9]+$/.test(this.gap);
      const value = isTokenIndex ? `var(--a-space-${this.gap})` : this.gap;
      this.style.setProperty('--swiper-gap', value);
    }
  }

  disconnected() {
    this.pause();
    this.#resizeObserver?.disconnect();
    this.#resizeObserver = null;
    if (this.#track && this.#scrollHandler) {
      this.#track.removeEventListener('scroll', this.#scrollHandler);
    }
    this.#scrollHandler = null;
    this.removeEventListener('pointerenter', this.#onPauseHover);
    this.removeEventListener('pointerleave', this.#onResumeHover);
    this.removeEventListener('focusin', this.#onPauseFocus);
    this.removeEventListener('focusout', this.#onResumeFocus);
    this.removeEventListener('keydown', this.#onKeydown);
    if (this.#track) {
      this.#track.removeEventListener('pointerdown',   this.#onPointerDown);
      this.#track.removeEventListener('pointermove',   this.#onPointerMove);
      this.#track.removeEventListener('pointerup',     this.#onPointerUp);
      this.#track.removeEventListener('pointercancel', this.#onPointerUp);
      this.#track.removeEventListener('click',         this.#onClickCapture, true);
      this.#track.removeEventListener('dragstart',     this.#onDragStart);
    }
    this.#drag = null;
    this.#track = null;
    this.#bound = false;
  }

  // ── Public API ──

  next() {
    const lastPage = this.#getPageCount() - 1;
    const currentPage = this.#slideToPageIndex(this.#activeIndex);
    const nextPage = Math.min(currentPage + 1, lastPage);
    if (nextPage !== currentPage || this.loop) {
      const target = this.loop && nextPage === currentPage ? 0 : nextPage;
      this.goTo(this.#pageToSlideIndex(target));
    }
  }

  prev() {
    const lastPage = this.#getPageCount() - 1;
    const currentPage = this.#slideToPageIndex(this.#activeIndex);
    const prevPage = Math.max(currentPage - 1, 0);
    if (prevPage !== currentPage || this.loop) {
      const target = this.loop && prevPage === currentPage ? lastPage : prevPage;
      this.goTo(this.#pageToSlideIndex(target));
    }
  }

  goTo(index) {
    const slide = this.slides[index];
    if (!slide || !this.#track) return;
    const target = Math.max(0, this.#snapTargetFor(slide));
    this.#track.scrollTo({ left: target, behavior: 'smooth' });
  }

  play() {
    this.pause();
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

    this.#timer = setInterval(() => {
      const lastPage = this.#getPageCount() - 1;
      const currentPage = this.#slideToPageIndex(this.#activeIndex);
      const atEnd = currentPage >= lastPage;
      if (atEnd && this.loop) this.goTo(this.#pageToSlideIndex(0));
      else if (!atEnd) this.next();
      else this.pause();
    }, this.interval || 5000);
  }

  pause() {
    if (this.#timer) {
      clearInterval(this.#timer);
      this.#timer = null;
    }
  }

  // ── Internal ──

  #labelSlides() {
    const slides = this.slides;
    slides.forEach((slide, i) => {
      slide.setAttribute('role', 'group');
      slide.setAttribute('aria-roledescription', 'slide');
      if (!slide.getAttribute('aria-label')) {
        slide.setAttribute('aria-label', `${i + 1} of ${slides.length}`);
      }
    });
  }

  #setupObserver() {
    // Scroll-based, snap-aware active tracking. `activeIndex` is the
    // slide whose snap target sits closest to the current scrollLeft:
    //   start  → slide whose left edge is at the track's left edge
    //   center → slide whose center is at the track's center
    //   end    → slide whose right edge is at the track's right edge
    //
    // Replaces an earlier IntersectionObserver-based "leftmost visible"
    // approach which couldn't distinguish the last two snap positions
    // for center- and end-snap (multiple slides remained >50% visible).
    this.#scrollHandler = () => {
      const newIndex = this.#computeActiveIndex();
      if (newIndex === this.#activeIndex) return;
      this.#activeIndex = newIndex;
      this.#updateFallbackDots();
      this.dispatchEvent(new CustomEvent('change', {
        bubbles: true,
        detail: { index: newIndex, slide: this.slides[newIndex] },
      }));
    };
    this.#track.addEventListener('scroll', this.#scrollHandler, { passive: true });
    // Initial stamp (no event — there's no prior state to diff against).
    this.#activeIndex = this.#computeActiveIndex();
  }

  // ── Snap & page helpers ──
  // The column count drives the page/dot math. `[slides-per-view]` (and the
  // responsive @container rules) set `--swiper-columns` on the track; we read
  // the computed value so the dot count tracks the real per-view count
  // (bug-29: 5 slides at slides-per-view=3 → 3 dots, not 5). Consumers may
  // override `--swiper-columns` at any ancestor.
  #getColumns() {
    if (!this.#track) return 1;
    const cs = getComputedStyle(this.#track);
    const v = cs.getPropertyValue('--swiper-columns').trim();
    const n = parseInt(v, 10);
    return Number.isFinite(n) && n > 0 ? n : 1;
  }

  #getPageCount() {
    const total = this.slides.length;
    if (total === 0) return 0;
    // Center-snap allows every slide to be the centered one (edge
    // slides clamped at the scroll bounds), so each slide is its own
    // "page". Start- and end-snap collapse equivalent positions to
    // (slides − cols + 1) distinct pages.
    if (this.snap === 'center') return total;
    return Math.max(1, total - this.#getColumns() + 1);
  }

  // Snap target for a slide (in scrollLeft coordinates, unclamped).
  #snapTargetFor(slide) {
    const trackWidth = this.#track.clientWidth;
    const left = slide.offsetLeft - this.#track.offsetLeft;
    const width = slide.offsetWidth;
    if (this.snap === 'center') return left + width / 2 - trackWidth / 2;
    if (this.snap === 'end')    return left + width - trackWidth;
    return left;
  }

  // Returns the slide index whose clamped snap target is closest to
  // the current scrollLeft. Snap-mode-aware so the "active" slide
  // matches the snap point currently aligned with the track.
  #computeActiveIndex() {
    if (!this.#track) return 0;
    const slides = this.slides;
    if (slides.length === 0) return 0;
    const sl = this.#track.scrollLeft;
    const maxScroll = Math.max(0, this.#track.scrollWidth - this.#track.clientWidth);
    let bestIndex = 0;
    let bestDistance = Infinity;
    for (let i = 0; i < slides.length; i++) {
      const target = Math.max(0, Math.min(maxScroll, this.#snapTargetFor(slides[i])));
      const distance = Math.abs(sl - target);
      if (distance < bestDistance) {
        bestDistance = distance;
        bestIndex = i;
      }
    }
    return bestIndex;
  }

  // Page↔slide mapping. Center- and start-snap use the slide index
  // directly as page index; end-snap shifts by (cols−1) because page 0
  // shows slide (cols−1) end-aligned.
  #pageToSlideIndex(page) {
    if (this.snap !== 'end') return page;
    const cols = this.#getColumns();
    return Math.min(this.slides.length - 1, page + cols - 1);
  }

  #slideToPageIndex(slideIdx) {
    if (this.snap !== 'end') return slideIdx;
    const cols = this.#getColumns();
    return Math.max(0, slideIdx - cols + 1);
  }

  #setupFallbackNav() {
    if (this.#fallbackNav) return;
    this.#fallbackNav = true;

    if (this.chrome === 'toolbar') {
      this.#stampToolbarChrome();
    } else {
      this.#stampDefaultChrome();
    }

    this.#refreshDots();

    // Container queries can change `--swiper-columns` mid-life; re-stamp
    // dots when the host width crosses a breakpoint.
    if (typeof ResizeObserver !== 'undefined') {
      this.#resizeObserver = new ResizeObserver(() => this.#refreshDots());
      this.#resizeObserver.observe(this);
    }
  }

  #stampDefaultChrome() {
    // Overlay paddles (absolutely positioned over the track) + centered dot row.
    const prev = document.createElement('button-ui');
    prev.setAttribute('data-swiper-btn', '');
    prev.setAttribute('data-swiper-prev', '');
    prev.setAttribute('icon', 'caret-left');
    prev.setAttribute('variant', 'ghost');
    prev.setAttribute('aria-label', 'Previous slide');
    prev.addEventListener('press', () => this.prev());

    const next = document.createElement('button-ui');
    next.setAttribute('data-swiper-btn', '');
    next.setAttribute('data-swiper-next', '');
    next.setAttribute('icon', 'caret-right');
    next.setAttribute('variant', 'ghost');
    next.setAttribute('aria-label', 'Next slide');
    next.addEventListener('press', () => this.next());

    this.appendChild(prev);
    this.appendChild(next);

    const dots = document.createElement('div');
    dots.setAttribute('data-swiper-dots', '');
    dots.setAttribute('role', 'tablist');
    dots.setAttribute('aria-label', 'Slide indicators');
    this.appendChild(dots);
  }

  #stampToolbarChrome() {
    // Header row: [label] [spacer] [prev | next].
    const head = document.createElement('div');
    head.setAttribute('data-swiper-head', '');

    const labelEl = document.createElement('span');
    labelEl.setAttribute('data-swiper-label', '');
    labelEl.textContent = this.label || '';
    head.appendChild(labelEl);

    const paddles = document.createElement('div');
    paddles.setAttribute('data-swiper-paddles', '');

    const prev = document.createElement('button-ui');
    prev.setAttribute('icon', 'caret-left');
    prev.setAttribute('variant', 'ghost');
    prev.setAttribute('size', 'sm');
    prev.setAttribute('aria-label', 'Previous slide');
    prev.addEventListener('press', () => this.prev());

    const next = document.createElement('button-ui');
    next.setAttribute('icon', 'caret-right');
    next.setAttribute('variant', 'ghost');
    next.setAttribute('size', 'sm');
    next.setAttribute('aria-label', 'Next slide');
    next.addEventListener('press', () => this.next());

    paddles.appendChild(prev);
    paddles.appendChild(next);
    head.appendChild(paddles);

    // Insert head BEFORE the track so it sits above the slides.
    this.insertBefore(head, this.#track);

    // Footer row: [counter] [spacer] [dots].
    const foot = document.createElement('div');
    foot.setAttribute('data-swiper-foot', '');

    const counterEl = document.createElement('span');
    counterEl.setAttribute('data-swiper-counter', '');
    foot.appendChild(counterEl);

    const dots = document.createElement('div');
    dots.setAttribute('data-swiper-dots', '');
    dots.setAttribute('role', 'tablist');
    dots.setAttribute('aria-label', 'Slide indicators');
    foot.appendChild(dots);

    this.appendChild(foot);
    this.#updateCounter();
  }

  #updateCounter() {
    const el = this.querySelector('[data-swiper-counter]');
    if (!el) return;
    if (!this.counter) { el.textContent = ''; return; }
    const cur = this.#slideToPageIndex(this.#activeIndex) + 1;
    const total = this.#getPageCount();
    el.textContent = `${cur} / ${total}`;
  }

  #refreshDots() {
    const dots = this.querySelector('[data-swiper-dots]');
    if (!dots) return;
    const desired = this.#getPageCount();
    if (dots.children.length !== desired) {
      while (dots.firstChild) dots.removeChild(dots.firstChild);
      for (let i = 0; i < desired; i++) {
        const dot = document.createElement('button');
        dot.setAttribute('role', 'tab');
        dot.setAttribute('aria-label', `Page ${i + 1}`);
        dot.addEventListener('click', () => this.goTo(this.#pageToSlideIndex(i)));
        dots.appendChild(dot);
      }
    }
    this.#updateFallbackDots();
  }

  #updateFallbackDots() {
    if (!this.#fallbackNav) return;
    const dots = this.querySelector('[data-swiper-dots]');
    if (dots) {
      const lastPage = this.#getPageCount() - 1;
      const active = Math.min(Math.max(this.#slideToPageIndex(this.#activeIndex), 0), lastPage);
      [...dots.children].forEach((dot, i) => {
        dot.setAttribute('aria-current', i === active ? 'true' : 'false');
      });
    }
    this.#updateCounter();
  }

  // ── Event handlers ──

  #onPauseHover = () => {
    if (this.#timer) {
      this.pause();
      this.dispatchEvent(new CustomEvent('autoplay-pause', { bubbles: true, detail: { reason: 'hover' } }));
    }
  };

  #onResumeHover = () => {
    if (this.autoplay && !this.#timer) {
      this.play();
      this.dispatchEvent(new CustomEvent('autoplay-resume', { bubbles: true }));
    }
  };

  #onPauseFocus = () => {
    if (this.#timer) {
      this.pause();
      this.dispatchEvent(new CustomEvent('autoplay-pause', { bubbles: true, detail: { reason: 'focus' } }));
    }
  };

  #onResumeFocus = (e) => {
    if (this.autoplay && !this.contains(e.relatedTarget)) {
      this.play();
      this.dispatchEvent(new CustomEvent('autoplay-resume', { bubbles: true }));
    }
  };

  #onKeydown = (e) => {
    if (e.key === 'ArrowRight') { e.preventDefault(); this.next(); }
    if (e.key === 'ArrowLeft') { e.preventDefault(); this.prev(); }
  };

  // ── Click + drag (mouse / pen) ──
  // Native touch already scroll-pans the track; we only intercept mouse and
  // pen so cursor users get the same swipe affordance as touch users.

  #DRAG_THRESHOLD_PX = 5;

  #onPointerDown = (e) => {
    if (e.pointerType === 'touch') return;        /* native pan handles touch */
    if (e.button !== 0) return;                   /* primary button only */
    if (e.target.closest('button, button-ui, a, input, select, textarea')) return;
    this.#drag = {
      pointerId: e.pointerId,
      startX: e.clientX,
      startScrollLeft: this.#track.scrollLeft,
      hasMoved: false,
    };
    /* Disable smooth scroll during drag so the bar tracks the pointer 1:1. */
    this.#track.style.scrollBehavior = 'auto';
    this.#track.setAttribute('data-dragging', '');
  };

  #onPointerMove = (e) => {
    if (!this.#drag || e.pointerId !== this.#drag.pointerId) return;
    const dx = e.clientX - this.#drag.startX;
    if (Math.abs(dx) >= this.#DRAG_THRESHOLD_PX) {
      if (!this.#drag.hasMoved) {
        this.#drag.hasMoved = true;
        /* Capture pointer so we keep tracking even if it leaves the track. */
        try { this.#track.setPointerCapture(e.pointerId); } catch { /* noop */ }
      }
      this.#track.scrollLeft = this.#drag.startScrollLeft - dx;
      e.preventDefault(); /* suppress text selection while dragging */
    }
  };

  #onPointerUp = (e) => {
    if (!this.#drag || e.pointerId !== this.#drag.pointerId) return;
    const wasMoved = this.#drag.hasMoved;
    if (wasMoved) {
      try { this.#track.releasePointerCapture(e.pointerId); } catch { /* noop */ }
    }
    this.#drag = null;
    /* Re-enable smooth scrolling; scroll-snap will glide to the nearest slide. */
    this.#track.style.scrollBehavior = '';
    this.#track.removeAttribute('data-dragging');
    /* Ensure dragging stays "active" through the click event that follows
       on the same gesture so #onClickCapture can suppress it. */
    if (wasMoved) {
      this.#track.setAttribute('data-just-dragged', '');
      requestAnimationFrame(() => this.#track?.removeAttribute('data-just-dragged'));
    }
  };

  #onClickCapture = (e) => {
    if (this.#track?.hasAttribute('data-just-dragged')) {
      e.preventDefault();
      e.stopPropagation();
    }
  };

  // Suppress native HTML5 drag (e.g. `<img>` ghost-drag) so it doesn't
  // hijack the pointer gesture before `#onPointerMove` crosses its
  // 5px threshold. Without this, dragging on top of an image inside
  // a slide never reaches our pointermove handler. We only suppress
  // when a swipe gesture is actually in progress (set by `#onPointerDown`)
  // so genuinely-draggable content (e.g. a palette item the consumer
  // wants to drag out of the swiper) is preserved when not swiping.
  #onDragStart = (e) => {
    if (this.#drag) e.preventDefault();
  };
}
