import { MediaStateChangeEvents } from './constants.js';
import MediaController from './media-controller.js';
import { globalThis, document } from './utils/server-safe-globals.js';
import { TemplateInstance } from './utils/template-parts.js';
import { processor } from './utils/template-processor.js';
import { camelCase, isNumericString } from './utils/utils.js';

// Export Template parts for players.
export * from './utils/template-parts.js';

const observedMediaAttributes = {
  mediatargetlivewindow: 'targetlivewindow',
  mediastreamtype: 'streamtype',
};

const prependTemplate = document.createElement('template');

prependTemplate.innerHTML = /*html*/ `
  <style>
    :host {
      display: inline-block;
      line-height: 0;
    }

    media-controller {
      width: 100%;
      height: 100%;
    }

    media-captions-button:not([mediasubtitleslist]),
    media-captions-menu:not([mediasubtitleslist]),
    media-captions-menu-button:not([mediasubtitleslist]),
    media-audio-track-menu[mediaaudiotrackunavailable],
    media-audio-track-menu-button[mediaaudiotrackunavailable],
    media-rendition-menu[mediarenditionunavailable],
    media-rendition-menu-button[mediarenditionunavailable],
    media-volume-range[mediavolumeunavailable],
    media-airplay-button[mediaairplayunavailable],
    media-fullscreen-button[mediafullscreenunavailable],
    media-cast-button[mediacastunavailable],
    media-pip-button[mediapipunavailable] {
      display: none;
    }
  </style>
`;

/**
 * @extends {HTMLElement}
 *
 * @attr {string} template - The element `id` of the template to render.
 */
export class MediaThemeElement extends globalThis.HTMLElement {
  static template: HTMLTemplateElement;
  static observedAttributes = ['template'];
  static processor = processor;

  renderRoot: ShadowRoot;
  renderer?: TemplateInstance;
  #template: HTMLTemplateElement;
  #prevTemplate: HTMLTemplateElement;
  #prevTemplateId: string | null;
  #observer: MutationObserver;

  constructor() {
    super();

    if (this.shadowRoot) {
      this.renderRoot = this.shadowRoot;
    } else {
      // Set up the Shadow DOM if not using Declarative Shadow DOM.
      this.renderRoot = this.attachShadow({ mode: 'open' });
      this.createRenderer();
    }

    this.#observer = new MutationObserver((mutationList) => {
      // Only update if `<media-controller>` has computed breakpoints at least once.
      if (this.mediaController && !this.mediaController?.breakpointsComputed)
        return;

      if (
        mutationList.some((mutation) => {
          const target = mutation.target as HTMLElement;

          // Render on each attribute change of the `<media-theme(-x)>` element.
          if (target === this) return true;

          // Only check `<media-controller>`'s attributes below.
          if (target.localName !== 'media-controller') return false;

          // Render if this attribute is directly observed.
          if (observedMediaAttributes[mutation.attributeName]) return true;

          // Render if `breakpointx` attributes change.
          if (mutation.attributeName.startsWith('breakpoint')) return true;

          return false;
        })
      ) {
        this.render();
      }
    });

    this.#renderBind = this.render.bind(this);

    // In case the template prop was set before custom element upgrade.
    // https://web.dev/custom-elements-best-practices/#make-properties-lazy
    this.#upgradeProperty('template');
  }

  #upgradeProperty(prop: string): void {
    if (Object.prototype.hasOwnProperty.call(this, prop)) {
      const value = this[prop];
      // Delete the set property from this instance.
      delete this[prop];
      // Set the value again via the (prototype) setter on this class.
      this[prop] = value;
    }
  }

  /** @type {HTMLElement & { breakpointsComputed?: boolean }} */
  get mediaController(): MediaController {
    // Expose the media controller if API access is needed
    return this.renderRoot.querySelector('media-controller');
  }

  get template(): string | HTMLTemplateElement | null {
    return (
      this.#template ?? (this.constructor as typeof MediaThemeElement).template
    );
  }

  set template(value: string | HTMLTemplateElement | null) {
    if (value === null) {
      this.removeAttribute('template');
      return;
    }

    if (typeof value === 'string') {
      this.setAttribute('template', value);
    } else if (value instanceof HTMLTemplateElement) {
      this.#template = value;
      this.#prevTemplateId = null;
      this.createRenderer();
    }
  }

  get props() {
    const observedAttributes = [
      ...Array.from(this.mediaController?.attributes ?? []).filter(
        ({ name }) => {
          return observedMediaAttributes[name] || name.startsWith('breakpoint');
        }
      ),
      ...Array.from(this.attributes),
    ];

    const props = {};
    for (const attr of observedAttributes) {
      const name = observedMediaAttributes[attr.name] ?? camelCase(attr.name);
      let { value } = attr;

      if (value != null) {
        if (isNumericString(value)) {
          // @ts-ignore
          value = parseFloat(value);
        }

        props[name] = value === '' ? true : value;
      } else {
        props[name] = false;
      }
    }

    return props;
  }

  attributeChangedCallback(
    attrName: string,
    oldValue: string,
    newValue: string | null
  ): void {
    if (attrName === 'template' && oldValue != newValue) {
      this.#updateTemplate();
    }
  }

  connectedCallback(): void {
    this.addEventListener(
      MediaStateChangeEvents.BREAKPOINTS_COMPUTED,
      this.#renderBind
    );
    
    // Observe the `<media-theme>` element for attribute changes.
    this.#observer.observe(this, { attributes: true });

    // Observe the subtree of the render root, by default the elements in the shadow dom.
    this.#observer.observe(this.renderRoot, {
      attributes: true,
      subtree: true,
    });

    this.#updateTemplate();
  }

  disconnectedCallback(): void {
    this.removeEventListener(
      MediaStateChangeEvents.BREAKPOINTS_COMPUTED,
      this.#renderBind
    );
    this.#observer.disconnect();
  }

  #updateTemplate(): void {
    const templateId = this.getAttribute('template');
    if (!templateId || templateId === this.#prevTemplateId) return;

    const rootNode = this.getRootNode() as Document;
    const template = rootNode?.getElementById?.(
      templateId
    ) as HTMLTemplateElement | null;

    if (template) {
      this.#prevTemplateId = templateId;
      this.#template = template;
      this.createRenderer();
      return;
    }

    if (isValidUrl(templateId)) {
      this.#prevTemplateId = templateId;

      request(templateId)
        .then((data) => {
          const template = document.createElement('template');
          template.innerHTML = data;

          this.#template = template;
          this.createRenderer();
        })
        .catch(console.error);
    }
  }

  createRenderer(): void {
    if (
      this.template instanceof HTMLTemplateElement &&
      this.template !== this.#prevTemplate
    ) {
      this.#prevTemplate = this.template as HTMLTemplateElement;

      this.renderer = new TemplateInstance(
        this.template,
        this.props,
        // @ts-ignore
        this.constructor.processor
      );

      this.renderRoot.textContent = '';
      this.renderRoot.append(
        prependTemplate.content.cloneNode(true),
        this.renderer
      );
    }
  }

  #renderBind: () => void;
  render(): void {
    this.renderer?.update(this.props);
  }
}

function isValidUrl(url: string): boolean {
  // Valid URL e.g. /absolute, ./relative, http://, https://
  if (!/^(\/|\.\/|https?:\/\/)/.test(url)) return false;

  // Add base if url doesn't start with http:// or https://
  const base = /^https?:\/\//.test(url) ? undefined : location.origin;
  try {
    new URL(url, base);
  } catch (e) {
    return false;
  }
  return true;
}

async function request(resource: string | URL | Request): Promise<string> {
  const response = await fetch(resource);

  if (response.status !== 200) {
    throw new Error(
      `Failed to load resource: the server responded with a status of ${response.status}`
    );
  }

  return response.text();
}

if (!globalThis.customElements.get('media-theme')) {
  globalThis.customElements.define('media-theme', MediaThemeElement);
}
