const parseViewport = (str) =>
  str
    ?.split(/[,;\s]/) // NOTE: technically, only the comma is valid
    ?.filter((x) => x)
    ?.map((x) => x.split("=").map((x) => x.trim()));

const getViewport = (doc, viewport) => {
  // use `viewBox` for SVG
  if (doc.documentElement.localName === "svg") {
    const [, , width, height] = doc.documentElement.getAttribute("viewBox")?.split(/\s/) ?? [];
    return { width, height };
  }

  // get `viewport` `meta` element
  const meta = parseViewport(doc.querySelector('meta[name="viewport"]')?.getAttribute("content"));
  if (meta) return Object.fromEntries(meta);

  // fallback to book's viewport
  if (typeof viewport === "string") return parseViewport(viewport);
  if (viewport?.width && viewport.height) return viewport;

  // if no viewport (possibly with image directly in spine), get image size
  const img = doc.querySelector("img");
  if (img) return { width: img.naturalWidth, height: img.naturalHeight };

  // just show *something*, i guess...
  console.warn(new Error("Missing viewport properties"));
  return { width: 1000, height: 2000 };
};

export class FixedLayout extends HTMLElement {
  static observedAttributes = ["zoom", "zoom-factor", "spread"];
  #root = this.attachShadow({ mode: "closed" });
  #observer = new ResizeObserver(() => this.#render());
  #spreads;
  #index = -1;
  defaultViewport;
  spread;
  #portrait = false;
  #left;
  #right;
  #center;
  #side;
  #zoom;
  #zoomFactor = 1;
  constructor() {
    super();

    const sheet = new CSSStyleSheet();
    this.#root.adoptedStyleSheets = [sheet];
    sheet.replaceSync(`:host {
            width: 100%;
            height: 100%;
            display: flex;
            justify-content: safe center;
            align-items: safe center;
            overflow: auto;
        }`);

    this.#observer.observe(this);
  }
  attributeChangedCallback(name, _, value) {
    switch (name) {
      case "zoom":
        this.#zoom =
          value !== "fit-width" && value !== "fit-page" ? Number.parseFloat(value) : value;
        this.#render();
        break;
      case "zoom-factor": {
        const parsed = Number.parseFloat(value);
        this.#zoomFactor = Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
        this.#render();
        break;
      }
      case "spread":
        this.spread = value;
        void this.#applySpreadChange(value);
        break;
    }
  }
  #buildSpreads(book) {
    const { rendition } = book;
    const rtl = book.dir === "rtl";
    const ltr = !rtl;
    if (rendition?.spread === "none") return book.sections.map((section) => ({ center: section }));

    return book.sections.reduce(
      (arr, section, i) => {
        const last = arr[arr.length - 1];
        const { pageSpread } = section;
        const newSpread = () => {
          const spread = {};
          arr.push(spread);
          return spread;
        };
        if (pageSpread === "center") {
          const spread = last.left || last.right ? newSpread() : last;
          spread.center = section;
        } else if (pageSpread === "left") {
          const spread = last.center || last.left || (ltr && i) ? newSpread() : last;
          spread.left = section;
        } else if (pageSpread === "right") {
          const spread = last.center || last.right || (rtl && i) ? newSpread() : last;
          spread.right = section;
        } else if (ltr) {
          if (last.center || last.right) newSpread().left = section;
          else if (last.left || !i) last.right = section;
          else last.left = section;
        } else {
          if (last.center || last.left) newSpread().right = section;
          else if (last.right || !i) last.left = section;
          else last.right = section;
        }
        return arr;
      },
      [{}],
    );
  }
  async #applySpreadChange(value) {
    if (!this.book?.sections?.length) return;
    if (this.book.rendition) this.book.rendition.spread = value;

    const currentSection = this.book.sections[this.index] ?? this.book.sections[0];
    this.#spreads = this.#buildSpreads(this.book);
    const target = currentSection ? this.getSpreadOf(currentSection) : null;
    this.#index = -1;
    await this.goToSpread(
      target?.index ?? 0,
      target?.side ?? (this.rtl ? "right" : "left"),
      "layout",
    );
  }
  async #createFrame({ index, src: srcOption }) {
    const srcOptionIsString = typeof srcOption === "string";
    const src = srcOptionIsString ? srcOption : srcOption?.src;
    const onZoom = srcOptionIsString ? null : srcOption?.onZoom;
    const element = document.createElement("div");
    element.setAttribute("dir", "ltr");
    const iframe = document.createElement("iframe");
    element.append(iframe);
    Object.assign(iframe.style, {
      border: "0",
      display: "none",
      overflow: "hidden",
    });
    // `allow-scripts` is needed for events because of WebKit bug
    // https://bugs.webkit.org/show_bug.cgi?id=218086
    iframe.setAttribute("sandbox", "allow-same-origin allow-scripts");
    iframe.setAttribute("scrolling", "no");
    iframe.setAttribute("part", "filter");
    this.#root.append(element);
    if (!src) return { blank: true, element, iframe };
    return new Promise((resolve) => {
      iframe.addEventListener(
        "load",
        () => {
          const doc = iframe.contentDocument;
          this.dispatchEvent(new CustomEvent("load", { detail: { doc, index } }));
          const { width, height } = getViewport(doc, this.defaultViewport);
          resolve({
            element,
            iframe,
            width: Number.parseFloat(width),
            height: Number.parseFloat(height),
            onZoom,
          });
        },
        { once: true },
      );
      iframe.src = src;
    });
  }
  #render(side = this.#side) {
    if (!side) return;
    const left = this.#left ?? {};
    const right = this.#center ?? this.#right ?? {};
    const target = side === "left" ? left : right;
    const { width, height } = this.getBoundingClientRect();
    const portrait = this.spread !== "both" && this.spread !== "portrait" && height > width;
    this.#portrait = portrait;
    const blankWidth = left.width ?? right.width ?? 0;
    const blankHeight = left.height ?? right.height ?? 0;

    const fitScale =
      typeof this.#zoom === "number" && !Number.isNaN(this.#zoom)
        ? this.#zoom
        : (this.#zoom === "fit-width"
            ? portrait || this.#center
              ? width / (target.width ?? blankWidth)
              : width / ((left.width ?? blankWidth) + (right.width ?? blankWidth))
            : portrait || this.#center
              ? Math.min(
                  width / (target.width ?? blankWidth),
                  height / (target.height ?? blankHeight),
                )
              : Math.min(
                  width / ((left.width ?? blankWidth) + (right.width ?? blankWidth)),
                  height / Math.max(left.height ?? blankHeight, right.height ?? blankHeight),
                )) || 1;
    const scale =
      typeof this.#zoom === "number" && !Number.isNaN(this.#zoom)
        ? fitScale
        : fitScale * this.#zoomFactor;

    const transform = (frame) => {
      const { element, iframe, width, height, blank, onZoom } = frame;
      if (!iframe) return;
      if (onZoom) onZoom({ doc: frame.iframe.contentDocument, scale });
      const iframeScale = onZoom ? scale : 1;
      Object.assign(iframe.style, {
        width: `${width * iframeScale}px`,
        height: `${height * iframeScale}px`,
        transform: onZoom ? "none" : `scale(${scale})`,
        transformOrigin: "top left",
        display: blank ? "none" : "block",
      });
      Object.assign(element.style, {
        width: `${(width ?? blankWidth) * scale}px`,
        height: `${(height ?? blankHeight) * scale}px`,
        overflow: "hidden",
        display: "block",
        flexShrink: "0",
        marginBlock: "auto",
      });
      if (portrait && frame !== target) {
        element.style.display = "none";
      }
    };
    if (this.#center) {
      transform(this.#center);
    } else {
      transform(left);
      transform(right);
    }
  }
  async #showSpread({ left, right, center, side }) {
    this.#root.replaceChildren();
    this.#left = null;
    this.#right = null;
    this.#center = null;
    if (center) {
      this.#center = await this.#createFrame(center);
      this.#side = "center";
      this.#render();
    } else {
      this.#left = await this.#createFrame(left);
      this.#right = await this.#createFrame(right);
      this.#side = this.#left.blank ? "right" : this.#right.blank ? "left" : side;
      this.#render();
    }
  }
  #goLeft() {
    if (this.#center || this.#left?.blank) return;
    if (this.#portrait && this.#left?.element?.style?.display === "none") {
      this.#side = "left";
      this.#render();
      this.#reportLocation("page");
      return true;
    }
  }
  #goRight() {
    if (this.#center || this.#right?.blank) return;
    if (this.#portrait && this.#right?.element?.style?.display === "none") {
      this.#side = "right";
      this.#render();
      this.#reportLocation("page");
      return true;
    }
  }
  open(book) {
    this.book = book;
    const { rendition } = book;
    this.spread = rendition?.spread;
    this.defaultViewport = rendition?.viewport;

    const rtl = book.dir === "rtl";
    this.rtl = rtl;

    this.#spreads = this.#buildSpreads(book);
  }
  get index() {
    const spread = this.#spreads[this.#index];
    if (!spread) return -1;
    const section =
      spread?.center ??
      (this.#side === "left" ? (spread.left ?? spread.right) : (spread.right ?? spread.left));
    return this.book.sections.indexOf(section);
  }
  #reportLocation(reason) {
    this.dispatchEvent(
      new CustomEvent("relocate", {
        detail: { reason, range: null, index: this.index, fraction: 0, size: 1 },
      }),
    );
  }
  getSpreadOf(section) {
    const spreads = this.#spreads;
    for (let index = 0; index < spreads.length; index++) {
      const { left, right, center } = spreads[index];
      if (left === section) return { index, side: "left" };
      if (right === section) return { index, side: "right" };
      if (center === section) return { index, side: "center" };
    }
  }
  async goToSpread(index, side, reason) {
    if (index < 0 || index > this.#spreads.length - 1) return;
    if (index === this.#index) {
      this.#render(side);
      return;
    }
    this.#index = index;
    const spread = this.#spreads[index];
    if (spread.center) {
      const index = this.book.sections.indexOf(spread.center);
      const src = await spread.center?.load?.();
      await this.#showSpread({ center: { index, src } });
    } else {
      const indexL = this.book.sections.indexOf(spread.left);
      const indexR = this.book.sections.indexOf(spread.right);
      const srcL = await spread.left?.load?.();
      const srcR = await spread.right?.load?.();
      const left = { index: indexL, src: srcL };
      const right = { index: indexR, src: srcR };
      await this.#showSpread({ left, right, side });
    }
    this.#reportLocation(reason);
  }
  async select(target) {
    await this.goTo(target);
    // TODO
  }
  async goTo(target) {
    const { book } = this;
    const resolved = await target;
    if (!resolved || typeof resolved.index !== "number") return;
    const section = book.sections[resolved.index];
    if (!section) return;
    const spread = this.getSpreadOf(section);
    if (!spread) return;
    const { index, side } = spread;
    await this.goToSpread(index, side);
  }
  async next() {
    const s = this.rtl ? this.#goLeft() : this.#goRight();
    if (!s) return this.goToSpread(this.#index + 1, this.rtl ? "right" : "left", "page");
  }
  async prev() {
    const s = this.rtl ? this.#goRight() : this.#goLeft();
    if (!s) return this.goToSpread(this.#index - 1, this.rtl ? "left" : "right", "page");
  }
  getContents() {
    return Array.from(this.#root.querySelectorAll("iframe"), (frame) => ({
      doc: frame.contentDocument,
      // TODO: index, overlayer
    }));
  }
  destroy() {
    this.#observer.unobserve(this);
  }
}

if (!customElements.get("foliate-fxl")) customElements.define("foliate-fxl", FixedLayout);
