/*
 * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to
 * deal in the Software without restriction, including without limitation the
 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
 * sell copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 * IN THE SOFTWARE.
 */

import {
  EMPTY,
  Observable,
  Subject,
  bufferCount,
  catchError,
  concat,
  debounceTime,
  distinctUntilKeyChanged,
  endWith,
  filter,
  fromEvent,
  ignoreElements,
  map,
  of,
  sample,
  share,
  skip,
  startWith,
  switchMap,
  take,
  withLatestFrom
} from "rxjs"

import { configuration, feature } from "~/_"
import {
  Viewport,
  getElement,
  getElements,
  getLocation,
  getOptionalElement,
  request,
  setLocation,
  setLocationHash
} from "~/browser"
import { getComponentElement } from "~/components"

import { fetchSitemap } from "../sitemap"

/* ----------------------------------------------------------------------------
 * Helper types
 * ------------------------------------------------------------------------- */

/**
 * Setup options
 */
interface SetupOptions {
  location$: Subject<URL>              // Location subject
  viewport$: Observable<Viewport>      // Viewport observable
  progress$: Subject<number>           // Progress suject
}

/* ----------------------------------------------------------------------------
 * Helper functions
 * ------------------------------------------------------------------------- */

/**
 * Create a map of head elements for lookup and replacement
 *
 * @param head - Document head
 *
 * @returns Element map
 */
function lookup(head: HTMLHeadElement): Map<string, HTMLElement> {

  // @todo When resolving URLs, we must make sure to use the correct base for
  // resolution. The next time we refactor instant loading, we should use the
  // location subject as a source, which is also used for anchor links tracking,
  // but for now we just rely on canonical.
  const canonical = getElement<HTMLLinkElement>("[rel=canonical]", head)
  canonical.href = canonical.href.replace("//localhost:", "//127.0.0.1")

  // Create tag map and index elements in head
  const tags = new Map<string, HTMLElement>()
  for (const el of getElements(":scope > *", head)) {
    let html = el.outerHTML

    // If the current element is a style sheet or script, we must resolve the
    // URL relative to the current location and make it absolute, so it's easy
    // to deduplicate it later on by comparing the outer HTML of tags. We must
    // keep identical style sheets and scripts without replacing them.
    for (const key of ["href", "src"]) {
      const value = el.getAttribute(key)!
      if (value === null)
        continue

      // Resolve URL relative to current location
      const url = new URL(value, canonical.href)
      const ref = el.cloneNode() as HTMLElement

      // Set resolved URL and retrieve HTML for deduplication
      ref.setAttribute(key, `${url}`)
      html = ref.outerHTML
      break
    }

    // Index element in tag map
    tags.set(html, el)
  }

  // Return tag map
  return tags
}

/* ----------------------------------------------------------------------------
 * Functions
 * ------------------------------------------------------------------------- */

/**
 * Set up instant navigation
 *
 * This is a heavily orchestrated operation - see inline comments to learn how
 * this works with Material for MkDocs, and how you can hook into it.
 *
 * @param options - Options
 *
 * @returns Document observable
 */
export function setupInstantNavigation(
  { location$, viewport$, progress$ }: SetupOptions
): Observable<Document> {
  const config = configuration()
  if (location.protocol === "file:")
    return EMPTY

  // Load sitemap immediately, so we have it available when the user initiates
  // the first instant navigation request, and canonicalize URLs to the current
  // base URL. The base URL will remain stable in between loads, as it's only
  // read at the first initialization of the application.
  const sitemap$ = fetchSitemap()
    .pipe(
      map(paths => paths.map(path => `${new URL(path, config.base)}`))
    )

  // Intercept inter-site navigation - to keep the number of event listeners
  // low we use the fact that uncaptured events bubble up to the body. This also
  // has the nice property that we don't need to detach and then again attach
  // event listeners when instant navigation occurs.
  const instant$ = fromEvent<MouseEvent>(document.body, "click")
    .pipe(
      withLatestFrom(sitemap$),
      switchMap(([ev, sitemap]) => {
        if (!(ev.target instanceof Element))
          return EMPTY

        // Skip, as target is not within a link - clicks on non-link elements
        // are also captured, which we need to exclude from processing
        const el = ev.target.closest("a")
        if (el === null)
          return EMPTY

        // Skip, as link opens in new window - we now know we have captured a
        // click on a link, but the link either has a `target` property defined,
        // or the user pressed the `meta` or `ctrl` key to open it in a new
        // window. Thus, we need to filter those events, too.
        if (el.target || ev.metaKey || ev.ctrlKey)
          return EMPTY

        // Next, we must check if the URL is relevant for us, i.e., if it's an
        // internal link to a page that is managed by MkDocs. Only then we can
        // be sure that the structure of the page to be loaded adheres to the
        // current document structure and can subsequently be injected into it
        // without doing a full reload. For this reason, we must canonicalize
        // the URL by removing all search parameters and hash fragments.
        const url = new URL(el.href)
        url.search = url.hash = ""

        // Skip, if URL is not included in the sitemap - this could be the case
        // when linking between versions or languages, or to another page that
        // the author included as part of the build, but that is not managed by
        // MkDocs. In that case we must not continue with instant navigation.
        if (!sitemap.includes(`${url}`))
          return EMPTY

        // We now know that we have a link to an internal page, so we prevent
        // the browser from navigation and emit the URL for instant navigation.
        // Note that this also includes anchor links, which means we need to
        // implement anchor positioning ourselves. The reason for this is that
        // if we wouldn't manage anchor links as well, scroll restoration will
        // not work correctly (e.g. following an anchor link and scrolling).
        ev.preventDefault()
        return of(new URL(el.href))
      }),
      share()
    )

  // Before fetching for the first time, resolve the absolute favicon position,
  // as the browser will try to fetch the icon immediately
  instant$.pipe(take(1))
    .subscribe(() => {
      const favicon = getOptionalElement<HTMLLinkElement>("link[rel=icon]")
      if (typeof favicon !== "undefined")
        favicon.href = favicon.href
    })

  // Enable scroll restoration before window unloads - this is essential to
  // ensure that full reloads (F5) restore the viewport offset correctly. If
  // only popstate events wouldn't reset the scroll position prior to their
  // emission, we could just reset this in popstate. Meh.
  fromEvent(window, "beforeunload")
    .subscribe(() => {
      history.scrollRestoration = "auto"
    })

  // When an instant navigation event occurs, disable scroll restoration, since
  // we must normalize and synchronize the behavior across all browsers. For
  // instance, when the user clicks the back or forward button, the browser
  // would immediately jump to the position of the previous document.
  instant$.pipe(withLatestFrom(viewport$))
    .subscribe(([url, { offset }]) => {
      history.scrollRestoration = "manual"

      // While it would be better UX to defer the history state change until the
      // document was fully fetched and parsed, we must schedule it here, since
      // popstate events are emitted when history state changes happen. Moreover
      // we need to back up the current viewport offset, so we can restore it
      // when popstate events occur, e.g., when the browser's back and forward
      // buttons are used for navigation.
      history.replaceState(offset, "")
      history.pushState(null, "", url)
    })

  // Emit URL that should be fetched via instant navigation on location subject,
  // which was passed into this function. Instant navigation can be intercepted
  // by other parts of the application, which can synchronously back up or
  // restore state before instant navigation happens.
  instant$.subscribe(location$)

  // Fetch document - when fetching, we could use `responseType: document`, but
  // since all MkDocs links are relative, we need to make sure that the current
  // location matches the document we just loaded. Otherwise any relative links
  // in the document might use the old location. If the request fails for some
  // reason, we fall back to regular navigation and set the location explicitly,
  // which will force-load the page. Furthermore, we must pre-warm the buffer
  // for the duplicate check, or the first click on an anchor link will also
  // trigger an instant navigation event, which doesn't make sense.
  const response$ = location$
    .pipe(
      startWith(getLocation()),
      distinctUntilKeyChanged("pathname"),
      skip(1),
      switchMap(url => request(url, { progress$ })
        .pipe(
          catchError(() => {
            setLocation(url, true)
            return EMPTY
          })
        )
      )
    )

  // Initialize the DOM parser, parse the returned HTML, and replace selected
  // components before handing control down to the application
  const dom = new DOMParser()
  const document$ = response$
    .pipe(
      switchMap(res => res.text()),
      switchMap(res => {
        const next = dom.parseFromString(res, "text/html")
        for (const selector of [
          "[data-md-component=announce]",
          "[data-md-component=container]",
          "[data-md-component=header-topic]",
          "[data-md-component=outdated]",
          "[data-md-component=logo]",
          "[data-md-component=skip]",
          ...feature("navigation.tabs.sticky")
            ? ["[data-md-component=tabs]"]
            : []
        ]) {
          const source = getOptionalElement(selector)
          const target = getOptionalElement(selector, next)
          if (
            typeof source !== "undefined" &&
            typeof target !== "undefined"
          ) {
            source.replaceWith(target)
          }
        }

        // Update meta tags
        const source = lookup(document.head)
        const target = lookup(next.head)
        for (const [html, el] of target) {

          // Hack: skip stylesheets and scripts until we manage to replace them
          // entirely in order to omit flashes of white content @todo refactor
          if (
            el.getAttribute("rel") === "stylesheet" ||
            el.hasAttribute("src")
          )
            continue

          if (source.has(html)) {
            source.delete(html)
          } else {
            document.head.appendChild(el)
          }
        }

        // Remove meta tags that are not present in the new document
        for (const el of source.values())

          // Hack: skip stylesheets and scripts until we manage to replace them
          // entirely in order to omit flashes of white content @todo refactor
          if (
            el.getAttribute("rel") === "stylesheet" ||
            el.hasAttribute("src")
          )
            continue
          else
            el.remove()

        // After components and meta tags were replaced, re-evaluate scripts
        // that were provided by the author as part of Markdown files
        const container = getComponentElement("container")
        return concat(getElements("script", container))
          .pipe(
            switchMap(el => {
              const script = next.createElement("script")
              if (el.src) {
                for (const name of el.getAttributeNames())
                  script.setAttribute(name, el.getAttribute(name)!)
                el.replaceWith(script)

                // Complete when script is loaded
                return new Observable(observer => {
                  script.onload = () => observer.complete()
                })

              // Complete immediately
              } else {
                script.textContent = el.textContent
                el.replaceWith(script)
                return EMPTY
              }
            }),
            ignoreElements(),
            endWith(next)
          )
      }),
      share()
    )

  // Intercept popstate events, e.g. when using the browser's back and forward
  // buttons, and emit new location for fetching and parsing
  const popstate$ = fromEvent<PopStateEvent>(window, "popstate")
  popstate$.pipe(map(getLocation))
    .subscribe(location$)

  // Intercept clicks on anchor links, and scroll document into position - as
  // we disabled scroll restoration, we need to do this manually here
  location$
    .pipe(
      startWith(getLocation()),
      bufferCount(2, 1),
      filter(([prev, next]) => (
        prev.pathname === next.pathname &&
        prev.hash     !== next.hash
      )),
      map(([, next]) => next)
    )
      .subscribe(url => {
        if (history.state !== null || !url.hash) {
          window.scrollTo(0, history.state?.y ?? 0)
        } else {
          history.scrollRestoration = "auto"
          setLocationHash(url.hash)
          history.scrollRestoration = "manual"
        }
      })

  // Intercept clicks on the same anchor link - we must use a distinct pipeline
  // for this, or we'd end up in a loop, setting the hash again and again
  location$
    .pipe(
      sample(instant$),
      startWith(getLocation()),
      bufferCount(2, 1),
      filter(([prev, next]) => (
        prev.pathname === next.pathname &&
        prev.hash     === next.hash
      )),
      map(([, next]) => next)
    )
      .subscribe(url => {
        history.scrollRestoration = "auto"
        setLocationHash(url.hash)
        history.scrollRestoration = "manual"

        // Hack: we need to make sure that we don't end up with multiple history
        // entries for the same anchor link, so we just remove the last entry
        history.back()
      })

  // After parsing the document, check if the current history entry has a state.
  // This may happen when users press the back or forward button to visit a page
  // that was already seen. If there's no state, it means a new page was visited
  // and we should scroll to the top, unless an anchor is given.
  document$.pipe(withLatestFrom(location$))
    .subscribe(([, url]) => {
      if (history.state !== null || !url.hash) {
        window.scrollTo(0, history.state?.y ?? 0)
      } else {
        setLocationHash(url.hash)
      }
    })

  // If the current history is not empty, register an event listener updating
  // the current history state whenever the scroll position changes. This must
  // be debounced and cannot be done in popstate, as popstate has already
  // removed the entry from the history.
  viewport$
    .pipe(
      distinctUntilKeyChanged("offset"),
      debounceTime(100)
    )
      .subscribe(({ offset }) => {
        history.replaceState(offset, "")
      })

  // Return document
  return document$
}
