import { LitElement, html, TemplateResult, PropertyValues } from 'lit'
import { property, state } from 'lit/decorators.js'
import { classMap } from 'lit/directives/class-map.js'
import { type IRequestLog, RequestLog } from '@api-client/core/models/RequestLog.js'
import { Headers } from '@api-client/core/lib/headers/Headers.js'
import type { SentRequest } from '@api-client/core/models/SentRequest.js'
import type { ErrorResponse } from '@api-client/core/models/ErrorResponse.js'
import type { Response } from '@api-client/core/models/Response.js'
import { Events as CoreEvents } from '@api-client/core/events/Events.js'
import { statusTemplate } from './HttpStatus.js'
import '../../md/icons/ui-icon.js'
import '../../md/divider/ui-divider.js'

/**
 * An element that renders summary headers view for an HTTP response.
 */
export default class LogHeaders extends LitElement {
  private httpLogInternal?: IRequestLog | RequestLog

  /**
   * This property is set when the `httpLog` is set. It always represent an instance
   * of the `RequestLog` (or undefined) regardless of the input format.
   */
  @state() protected accessor effectiveLog: RequestLog | undefined

  /**
   * The request log to render. When not set it renders nothing.
   */
  get httpLog(): IRequestLog | RequestLog | undefined {
    return this.httpLogInternal
  }

  @property({ type: Object })
  set httpLog(value: IRequestLog | RequestLog | undefined) {
    const old = this.httpLogInternal
    if (old === value) {
      return
    }
    this.httpLogInternal = value
    if (value) {
      if (typeof (value as RequestLog).toJSON === 'function') {
        this.effectiveLog = value as RequestLog
      } else {
        this.effectiveLog = new RequestLog(value as IRequestLog)
      }
    } else {
      this.effectiveLog = undefined
    }
    this.parseHeaders()
  }

  @state() protected accessor activeItem: string | undefined

  @state() protected accessor focusedItem: string | undefined

  /**
   * The currently focused node.
   */
  protected focusedElement?: HTMLElement

  /**
   * Parsed request headers, when the request is set.
   */
  @state() protected accessor requestHeaders: Headers | undefined

  /**
   * Parsed response headers, when the response is set.
   */
  @state() protected accessor responseHeaders: Headers | undefined

  /**
   * The list of ids of tree parents that are opened.
   */
  protected openedParents: string[] = ['general', 'response-headers', 'request-headers']

  constructor() {
    super()
    this.addEventListener('keydown', this.handleKeydown.bind(this))
    this.addEventListener('click', this.handleClick.bind(this))
    this.addEventListener('dblclick', this.handleDblclick.bind(this))
    this.addEventListener('focus', this.handleFocus.bind(this))
  }

  override connectedCallback(): void {
    super.connectedCallback()
    this.manageTabindex()
  }

  protected override updated(cp: PropertyValues): void {
    super.updated(cp)
    if (cp.has('focusedItem')) {
      this.manageTabindex()
    }
  }

  /**
   * Manages the state of the tabindex attribute of the element.
   * When the user changes the focus in the tree then the tabindex is removed from the element
   * and moved to the focused element.
   * When there's no focused element, the tabindex is again set on the element.
   */
  protected manageTabindex(): void {
    const id = this.focusedItem
    if (id && this.hasAttribute('tabindex')) {
      this.removeAttribute('tabindex')
    } else if (!id && !this.hasAttribute('tabindex')) {
      this.setAttribute('tabindex', '0')
    }
    if (id) {
      const node = this.shadowRoot?.querySelector(`[data-id="${id}"]`) as HTMLElement | null
      if (node) {
        node.focus()
      }
    }
  }

  /**
   * Processes the request and response headers when the setter is invoked.
   * This way we can cache results instead of generating the headers in the
   * template.
   */
  protected parseHeaders(): void {
    const { effectiveLog } = this
    if (!effectiveLog) {
      this.requestHeaders = undefined
      this.responseHeaders = undefined
      return
    }
    const { request, response } = effectiveLog
    if (request && request.headers) {
      this.requestHeaders = new Headers(request.headers)
    }
    if (response && response.headers) {
      this.responseHeaders = new Headers(response.headers)
    }
  }

  protected handleFocus(): void {
    let node: HTMLLIElement | undefined | null
    if (this.focusedItem) {
      node = this.shadowRoot?.querySelector(`[data-id="${this.focusedItem}"]`) as HTMLLIElement
    } else {
      node = this.shadowRoot?.querySelector(`[data-id="general"]`) as HTMLLIElement
    }
    if (node) {
      node.focus()
    }
  }

  protected handleKeydown(e: KeyboardEvent): void {
    const node = this.findClosestListItem(e)
    if (!node) {
      return
    }
    switch (e.key) {
      case 'ArrowRight':
        this.handleItemRight(node)
        break
      case 'ArrowLeft':
        this.handleItemLeft(node)
        break
      case 'ArrowDown':
        this.handleItemDown(node)
        break
      case 'ArrowUp':
        this.handleItemUp(node)
        break
      case 'Home':
        this.handleHome()
        break
      case 'End':
        this.handleEnd()
        break
      case 'Enter':
        this.handleItemEnter(node)
        break
      case 'c':
        this.handleItemC(e, node)
        break
      default:
    }
  }

  protected async handleItemC(e: KeyboardEvent, node: HTMLElement): Promise<void> {
    if (!e.ctrlKey || !navigator.clipboard) {
      return
    }
    const txt = node.textContent?.trim()
    if (txt) {
      await navigator.clipboard.writeText(txt)
    }
    CoreEvents.Telemetry.event(
      {
        category: 'Usage',
        action: 'Click',
        label: 'Response header clipboard copy',
      },
      this
    )
  }

  protected handleClick(e: Event): void {
    const node = this.findClosestListItem(e)
    if (!node) {
      return
    }
    this.focusListItem(node)
  }

  protected handleDblclick(e: Event): void {
    const node = this.findClosestListItem(e)
    if (!node) {
      return
    }
    if (!node.classList.contains('tree-parent')) {
      return
    }
    const key = node.dataset.id as string
    this.toggleParent(key)
    this.requestUpdate()
  }

  protected toggleParent(key: string): void {
    const index = this.openedParents.indexOf(key)
    if (index >= 0) {
      this.openedParents.splice(index, 1)
    } else {
      this.openedParents.push(key)
    }
  }

  protected findClosestListItem(e: Event): HTMLElement | undefined {
    const path = e.composedPath()
    while (path.length) {
      const target = path.shift() as Node
      if (!target) {
        break
      }
      if (target.nodeType !== Node.ELEMENT_NODE) {
        continue
      }
      const elm = target as HTMLElement
      if (elm.classList.contains('tree-item') || elm.classList.contains('tree-parent')) {
        return elm
      }
    }
    return undefined
  }

  /**
   * 1. When focus is on a closed node, opens the node; focus does not move.
   * 2. When focus is on a open node, moves focus to the first child node.
   * 3. When focus is on an end node (a tree item with no children), does nothing.
   */
  protected handleItemRight(node: HTMLElement): void {
    // #3
    if (!node.classList.contains('tree-parent')) {
      return
    }
    const key = node.dataset.id as string
    const opened = this.openedParents.includes(key)
    if (!opened) {
      // #1
      this.openedParents.push(key)
      this.requestUpdate()
      return
    }
    // #2
    const list = node.nextElementSibling as HTMLOListElement | null
    if (list) {
      this.focusFirstDescendant(list)
    }
  }

  /**
   * 1. When focus is on an open node, closes the node.
   * 2. When focus is on a child node that is also either an end node or a closed node, moves focus to its parent node.
   * 3. When focus is on a closed `tree`, does nothing.
   */
  protected handleItemLeft(node: HTMLElement): void {
    const isParent = node.classList.contains('tree-parent')
    const key = node.dataset.id as string
    if (isParent) {
      const opened = this.openedParents.includes(key)
      if (opened) {
        // #1
        const index = this.openedParents.indexOf(key)
        this.openedParents.splice(index, 1)
        this.requestUpdate()
        return
      }
      // #3
      return
    }
    const list = node.parentElement as HTMLOListElement | null
    if (!list) {
      return
    }
    // #2
    const item = list.previousElementSibling as HTMLLIElement | null
    if (item) {
      this.focusListItem(item)
    }
  }

  /**
   * Moves focus to the next node that is focusable without opening or closing a node.
   */
  protected handleItemDown(node: HTMLElement): void {
    const isParent = node.classList.contains('tree-parent')
    if (!isParent) {
      if (this.focusNextSibling(node)) {
        return
      }
      // focus on the next parent
      const myParent = this.findParentListItem(node)
      if (!myParent) {
        return
      }
      let firstParent: HTMLLIElement | undefined
      let current = myParent.nextElementSibling as HTMLElement | null
      while (current) {
        const isList = current.localName === 'li'
        if (isList) {
          firstParent = current as HTMLLIElement
          break
        }
        current = current.nextElementSibling as HTMLElement | null
      }
      if (firstParent) {
        this.focusListItem(firstParent)
      }
      return
    }
    const key = node.dataset.id as string
    // if we are opened then we focus on the first child.
    // otherwise we focus on the next parent
    const opened = this.openedParents.includes(key)
    if (opened && this.focusFirstDescendant(node.nextElementSibling as HTMLOListElement)) {
      return
    }
    this.focusNextSibling(node)
  }

  /**
   * Moves focus to the previous node that is focusable without opening or closing a node.
   */
  protected handleItemUp(node: HTMLElement): void {
    const isParent = node.classList.contains('tree-parent')
    if (isParent) {
      // focuses on a last item of the first opened previous parent
      // or when the parent is closed then just on the parent.
      let firstParent: HTMLLIElement | undefined
      let current = node.previousElementSibling as HTMLElement | null
      while (current) {
        if (current.localName === 'li') {
          firstParent = current as HTMLLIElement
          break
        }
        current = current.previousElementSibling as HTMLElement | null
      }
      if (firstParent) {
        if (
          firstParent.classList.contains('opened') &&
          this.focusLastDescendant(firstParent.nextElementSibling as HTMLOListElement)
        ) {
          return
        }
        this.focusListItem(firstParent)
      }
      return
    }
    if (this.focusPreviousSibling(node)) {
      return
    }
    this.focusFirstParent(node)
  }

  protected handleHome(): void {
    const node = this.shadowRoot?.querySelector(`[data-id="general"]`) as HTMLLIElement | null
    if (!node) {
      return
    }
    this.focusListItem(node)
  }

  protected handleEnd(): void {
    const first = this.shadowRoot?.querySelector('ol')
    if (!first) {
      return
    }
    const children = Array.from(first.children).reverse() as HTMLElement[]
    for (const node of children) {
      if (node.localName === 'li') {
        if (this.focusLastDescendant(node.nextElementSibling as HTMLOListElement)) {
          return
        }
        this.focusListItem(node)
        return
      }
    }
  }

  /**
   * Performs the default action of the currently focused node.
   * For parent nodes, it opens or closes the node.
   * In single-select trees, if the node has no children, selects the current node
   * if not already selected (which is the default action).
   */
  protected handleItemEnter(node: HTMLElement): void {
    const isParent = node.classList.contains('tree-parent')
    if (!isParent) {
      return
    }
    const key = node.dataset.id as string
    this.toggleParent(key)
    this.requestUpdate()
  }

  /**
   * Focuses on the first descendant of a folder item.
   *
   * @param node The list containing children to focus on.
   * @returns true when focused on any of the descendants
   */
  protected focusFirstDescendant(node: HTMLOListElement): boolean {
    if (!node) {
      return false
    }
    const items = Array.from(node.children).filter((n) => n.localName === 'li') as HTMLLIElement[]
    if (!items || !items.length) {
      return false
    }
    for (const child of items) {
      if (child.hasAttribute('disabled')) {
        continue
      }
      this.focusListItem(child)
      return true
    }
    return false
  }

  /**
   * Focuses on the last descendant of a folder item.
   *
   * @param node The list containing children to focus on.
   * @returns true when focused on any of the descendants
   */
  protected focusLastDescendant(node: HTMLOListElement): boolean {
    if (!node) {
      return false
    }
    const items = Array.from(node.children).filter((n) => n.localName === 'li') as HTMLLIElement[]
    if (!items || !items.length) {
      return false
    }
    items.reverse()
    for (const child of items) {
      if (child.hasAttribute('disabled')) {
        continue
      }
      this.focusListItem(child)
      return true
    }
    return false
  }

  protected focusListItem(node: HTMLElement): void {
    node.focus()
    this.activeItem = node.dataset.id as string
    this.focusedItem = node.dataset.id as string
    this.focusedElement = node
    node.scrollIntoView({
      block: 'nearest',
      inline: 'nearest',
    })
  }

  /**
   * Focuses on the next node relative to the passed node.
   *
   * @param node The node to use as a mark. When not set it uses the currently focused node.
   * @returns true when focused on any of the next siblings
   */
  protected focusNextSibling(node: HTMLElement): boolean {
    if (!node) {
      return false
    }
    let current = node.nextElementSibling as HTMLElement
    while (current) {
      if (current.localName !== 'li' || current.hasAttribute('disabled')) {
        current = current.nextElementSibling as HTMLElement
        continue
      }
      this.focusListItem(current)
      return true
    }
    return false
  }

  protected focusPreviousSibling(node: HTMLElement): boolean {
    if (!node) {
      return false
    }
    let current = node.previousElementSibling as HTMLElement
    while (current) {
      if (current.localName !== 'li' || current.hasAttribute('disabled')) {
        current = current.previousElementSibling as HTMLElement
        continue
      }
      this.focusListItem(current)
      return true
    }
    return false
  }

  /**
   * Focuses on a first parent list item that accepts focus relative to the given node.
   *
   * @param node The node to use as a reference.
   * @returns true when focused on any of the parents
   */
  protected focusFirstParent(node: HTMLElement): boolean {
    if (!node) {
      return false
    }
    const parent = this.findParentListItem(node)
    if (parent) {
      this.focusListItem(parent)
      return true
    }
    return false
  }

  protected findParentListItem(node: HTMLElement): HTMLLIElement | undefined {
    let parent = node.parentElement as HTMLElement
    while (parent) {
      if (parent === this) {
        return undefined
      }
      if (parent.localName === 'ol') {
        const item = parent.previousElementSibling as HTMLLIElement | null
        return item || undefined
      }
      parent = parent.parentElement as HTMLElement
    }
    return undefined
  }

  protected override render(): TemplateResult {
    const { effectiveLog } = this
    if (!effectiveLog) {
      return html``
    }
    const { request, response } = effectiveLog
    return html`
      <ol role="tree" tabindex="-1" class="tree" aria-multiselectable="false">
        ${this.RenderRequestGeneral(request, response)} ${this.renderResponseHeaders()} ${this.renderRequestHeaders()}
      </ol>
    `
  }

  protected renderListItem(name: string, value: string | TemplateResult, id: string): TemplateResult {
    const active = this.activeItem === id
    const focused = this.focusedItem === id
    const classes = {
      active,
      'tree-item': true,
    }
    const label = typeof value === 'string' ? `${name}: ${value}` : [name, value]
    return html`
      <li
        role="treeitem"
        class="${classMap(classes)}"
        data-id="${id}"
        tabindex="${focused ? '0' : '-1'}"
        aria-selected="${active ? 'true' : 'false'}"
      >
        <span class="item-content">${label}</span>
      </li>
    `
  }

  protected renderParentListItem(label: string, id: string): TemplateResult {
    const active = this.activeItem === id
    const focused = this.focusedItem === id
    const opened = this.openedParents.includes(id)
    const classes = {
      active,
      opened,
      'closed': !opened,
      focused,
      'tree-parent': true,
    }
    return html`
      <li
        title="${label}"
        class="${classMap(classes)}"
        data-id="${id}"
        role="treeitem"
        tabindex="${focused ? '0' : '-1'}"
        aria-expanded="${opened ? 'true' : 'false'}"
        aria-selected="${active ? 'true' : 'false'}"
      >
        <ui-icon icon="arrowDropDown" role="presentation" class="toggle-icon"></ui-icon>
        <span class="tree-element-title">${label}</span>
      </li>
    `
  }

  protected renderChildrenItems(id: string, items: TemplateResult | TemplateResult[]): TemplateResult {
    const opened = this.openedParents.includes(id)
    const classes = {
      expanded: opened,
      collapsed: !opened,
      children: true,
    }
    return html`
      <ol class="${classMap(classes)}" role="group">
        ${items}
      </ol>
      <ui-divider type="inset"></ui-divider>
    `
  }

  protected RenderRequestGeneral(sent?: SentRequest, response?: Response | ErrorResponse): TemplateResult {
    if (!sent) {
      return html``
    }
    const id = 'general'
    const items = html`
      ${this.renderListItem('Request URL', sent.url, `${id}-request-url`)}
      ${this.renderListItem('Request Method', sent.method, `${id}-request-method`)}
      ${this.renderResponseStatus(id, response)}
    `
    return html` ${this.renderParentListItem('General', id)} ${this.renderChildrenItems(id, items)} `
  }

  protected renderResponseStatus(id: string, response?: Response | ErrorResponse): TemplateResult | string {
    if (!response) {
      return ''
    }
    const { status = 0, statusText = '' } = response
    const tpl = statusTemplate(status, statusText)
    return this.renderListItem('Status Code', tpl, `${id}-status-code`)
  }

  protected renderRequestHeaders(): TemplateResult {
    const id = 'request-headers'
    return html`
      ${this.renderParentListItem('Request headers', id)}
      ${this.renderChildrenItems(id, this.renderHeadersList(id, this.requestHeaders))}
    `
  }

  protected renderResponseHeaders(): TemplateResult {
    const id = 'response-headers'
    return html`
      ${this.renderParentListItem('Response headers', id)}
      ${this.renderChildrenItems(id, this.renderHeadersList(id, this.responseHeaders))}
    `
  }

  protected renderHeadersList(prefix: string, headers?: Headers): TemplateResult {
    if (!headers) {
      return html`<p class="no-data">No headers</p>`
    }
    let i = 0
    const templates = headers.map((value: string, name: string) => {
      const id = `${prefix}-${i}`
      i += 1
      return this.renderListItem(name, value, id)
    })
    return html`${templates}`
  }
}
