import { nothing, TemplateResult } from 'lit'
import { Activity } from './Activity.js'
import { FragmentState, type FragmentOptions, FragmentManager } from './FragmentManager.js'
import type { Application, UpdateRequest } from './Application.js'
import { FragmentRenderer } from './renderer/FragmentRenderer.js'
import { Intent, IntentResult } from './ActivityManager.js'
import { bound } from '../decorators/bound.js'
import type { ActivityDetail, ActivityWithResultDetail } from '../events/IntentEvents.js'
import { EventTypes } from '../events/EventTypes.js'
import { type RefOrCallback } from 'lit/directives/ref.js'

export interface PendingActivityResult {
  onResult(result: IntentResult, intent: Intent): void
}

/**
 * Similar to Activity, with lifecycle methods (onCreate, onAttach, onDetach, etc.).
 * The crucial difference is that a Fragment is always hosted by an `Activity` or
 * another `Fragment`.
 *
 * A `Fragment` represents a reusable portion of the app's UI.
 * A fragment defines and manages its own layout, has its own lifecycle,
 * and can handle its own input events.
 * Fragments can't live on their own. They must be hosted by an activity or another
 * fragment. The fragment’s view hierarchy becomes part of, or attaches to,
 * the host’s view hierarchy.
 */
export class Fragment extends EventTarget {
  public state: FragmentState = FragmentState.Initialized
  public parent?: Activity | Fragment
  protected children = new Map<string, Fragment>()
  protected fragmentManager: FragmentManager

  /**
   * The request code used to start an activity for a result.
   */
  requestCode = -1

  /**
   * A list of pending activity results that were requested by the components
   * hosted in this fragment.
   * The key is the request code and the value contains the callback
   * that will be called when the activity result is received.
   * The callback is called with the result code and the resulting intent.
   */
  pendingActivityResult: Map<number, PendingActivityResult> = new Map<number, PendingActivityResult>()

  #renderer: FragmentRenderer

  get renderer(): FragmentRenderer {
    return this.#renderer
  }

  /**
   * The host to be used by the FragmentRenderer to assign it to the Lit's render root.
   * Whatever is assigned to this property will be used as the root element.
   *
   * Fragments should use this property to manipulate the event's target.
   * If a fragment renders multiple children, but only one is visible,
   * it should assign the host value to the currently rendered child.
   */
  host: Fragment = this

  constructor(options?: FragmentOptions) {
    super()
    this.parent = options?.parent
    this.fragmentManager = new FragmentManager(this)
    this.#renderer = new FragmentRenderer(this)
  }

  /**
   * When implemented, this method returns the class name to be used as a root class for the fragment.
   * This class name can be used to apply styles or identify the fragment in the DOM.
   * @returns The class name to be used as a root class for the fragment.
   */
  public rootClass(): string | undefined {
    return undefined
  }

  /**
   * @see {@link ./Renderer.js}
   */
  public setRenderRoot(renderRoot: HTMLElement | string): void {
    this.#renderer.setRenderRoot(renderRoot)
  }

  onData(data: unknown): void | Promise<void> {
    this.dispatchEvent(new CustomEvent('fragment:data', { detail: data }))
  }

  /**
   * Called once when the fragment is created.
   *
   * In the `onCreate()` method, perform basic fragment startup logic that happens only once
   * for the entire life of the fragment.
   * @param data Optional init data.
   */
  onCreate(data?: unknown): void | Promise<void> {
    this.dispatchEvent(new CustomEvent('fragment:create', { detail: data }))
  }

  /**
   * Called when the fragment is attached to a parent
   */
  onAttach(data?: unknown): void | Promise<void> {
    this.dispatchEvent(new CustomEvent('fragment:attach', { detail: data }))
  }

  /**
   * Called when the fragment becomes visible.
   */
  onStart(): void | Promise<void> {
    this.dispatchEvent(new CustomEvent('fragment:start', { detail: null }))
  }

  /**
   * Called when the fragment gains focus.
   */
  onResume(): void | Promise<void> {
    this.dispatchEvent(new CustomEvent('fragment:resume', { detail: null }))
  }

  /**
   * Called when the fragment loses focus.
   */
  onPause(): void | Promise<void> {
    this.dispatchEvent(new CustomEvent('fragment:pause', { detail: null }))
  }

  /**
   * Called when the fragment is no longer visible.
   */
  onStop(): void | Promise<void> {
    this.dispatchEvent(new CustomEvent('fragment:stop', { detail: null }))
  }

  /**
   * Called when the fragment is detached from a parent
   */
  onDetach(): void | Promise<void> {
    this.dispatchEvent(new CustomEvent('fragment:detach', { detail: null }))
  }

  /**
   * Called before the fragment is destroyed.
   */
  onDestroy(): void | Promise<void> {
    this.dispatchEvent(new CustomEvent('fragment:destroy', { detail: null }))
    this.fragmentManager.onDestroy()
  }

  /**
   * Called by the renderer when the fragment is rendered for the first time.
   */
  onFirstRender(): void {
    //
  }

  /**
   * A function called when it should render the view.
   * By default it renders a fragment view if the fragment hosts other fragments and only one is visible.
   */
  render(): TemplateResult | typeof nothing {
    const fragment = this.getSingleVisibleFragment()
    if (fragment) {
      // we can manage the default rendering.
      // we make an assumption that by default only one fragment is rendered,
      // otherwise the parent activity or fragment would have to setup render roots.
      return fragment.render()
    }
    return nothing
  }

  /**
   * Checks whether there's only one `Fragment` that is in the `Resumed` state
   * and returns it. It returns `null` if there is no `Fragment`s or more than a single
   * visible `Fragment`.
   * @returns A Fragment that is the only fragment that should be visible.
   */
  getSingleVisibleFragment(): Fragment | null {
    const fragments = this.getVisibleFragments()
    return fragments.length === 1 ? fragments[0] : null
  }

  /**
   * Checks whether this fragment is a host for other fragments.
   * @returns `true` when this fragment hosts other fragments.
   */
  isRenderingFragments(): boolean {
    return this.children.size > 0
  }

  /**
   * @returns The list of all fragments that are in the Resumed state.
   */
  getVisibleFragments(): Fragment[] {
    return this.fragmentManager.getVisible()
  }

  /**
   * Adds a child fragment to this fragment.
   * The FragmentManager will handle lifecycle.
   * @param key The name of the fragment.
   * @param fragment The fragment to add.
   */
  async addChild(key: string, fragment: Fragment, data?: unknown): Promise<void> {
    this.children.set(key, fragment)
    await this.fragmentManager.attachFragment(key, fragment, this, data)
  }

  /**
   * Removes a fragment from this fragment.
   * @param key The name of the fragment to remove.
   */
  async removeChild(key: string): Promise<void> {
    const child = this.children.get(key)
    if (child) {
      await this.fragmentManager.detachFragment(key)
      this.children.delete(key)
    }
  }

  /**
   * Creates a RefCallback that can be used with lit's `ref()` directive
   * to automatically show/hide a child fragment when its container element
   * is added or removed from the DOM.
   *
   * @param key The key of the child fragment to manage.
   * @returns A RefCallback to be used with the `ref()` directive.
   *
   * @example
   * // In your parent fragment's render method:
   * html`<div ${ref(this.createFragmentRef('my-child-fragment'))}></div>`
   */
  createFragmentRef(key: string): RefOrCallback<Element> {
    return (element?: Element) => {
      if (element) {
        this.fragmentManager.showFragment(key, element as HTMLElement)
      } else {
        const fragment = this.fragmentManager.findFragment(key)
        if (fragment) {
          this.fragmentManager.hideFragment(fragment)
        }
      }
    }
  }

  /**
   * A helper to request an update.
   * Application or parent Fragment will handle rendering.
   */
  requestUpdate(opts: UpdateRequest = {}): void {
    if (opts.fragment !== false) {
      this.renderer.requestUpdate()
    }
    if (this.parent && (opts.activity || opts.app)) {
      this.parent.requestUpdate(opts)
    }
  }

  getApplication(): Application | undefined {
    return this.parent?.getApplication()
  }

  getActivity(): Activity | undefined {
    return this.parent?.getActivity()
  }

  /**
   * Starts an activity for result.
   * @param intent The intent to start.
   */
  async startActivityForResult(intent: Intent): Promise<void> {
    const activity = this.getActivity()
    if (!activity) {
      throw new Error(`The fragment has no activity. Unable to start an intent.`)
    }
    this.requestCode = await activity.startActivityForResult(intent)
  }

  /**
   * Starts another activity.
   * @param intent The intent to start.
   */
  async startActivity(intent: Intent): Promise<void> {
    const activity = this.getActivity()
    if (!activity) {
      throw new Error(`The fragment has no activity. Unable to start an intent.`)
    }
    await activity.startActivity(intent)
  }

  /**
   * The callback method that is triggered when another activity that was
   * started for result finishes.
   *
   * @param requestCode The request code that was used to start the activity.
   * @param data The data that was passed back.
   * @param intent The intent that was used to start the activity.
   */
  async onActivityResult(requestCode: number, resultCode: IntentResult, intent: Intent): Promise<void> {
    const { pendingActivityResult, children } = this
    if (pendingActivityResult.has(requestCode)) {
      const { onResult } = pendingActivityResult.get(requestCode) as PendingActivityResult
      pendingActivityResult.delete(requestCode)
      onResult(resultCode, intent)
      return
    }
    for (const fragment of children.values()) {
      if (fragment.hasRequestCode(requestCode)) {
        fragment.onActivityResult(requestCode, resultCode, intent)
        return
      }
    }
    this.requestCode = -1
    if (this.constructor === Fragment) {
      // eslint-disable-next-line no-console
      console.info('Fragment#onActivityResult() not implemented', requestCode, resultCode, intent)
    }
  }

  hasRequestCode(requestCode: number): boolean {
    for (const fragment of this.children.values()) {
      if (fragment.hasRequestCode(requestCode)) {
        return true
      }
    }
    return this.requestCode === requestCode
  }

  /**
   * A handler for the intent event dispatched by web components hosted by this fragment.
   *
   * **Usage example:**
   *
   * ```ts
   * <custom-element @startactivity="${this.handleIntentEvent}"></custom-element>
   * <custom-element @startactivityforresult="${this.handleIntentEvent}"></custom-element>
   * ```
   *
   * @param event The event that was dispatched.
   * @returns A promise that resolves when the intent is handled.
   * @throws An error if the fragment has no activity.
   * @throws An error if the event type is not recognized.
   */
  @bound
  async handleIntentEvent(event: CustomEvent<ActivityDetail | ActivityWithResultDetail>): Promise<void> {
    const activity = this.getActivity()
    if (!activity) {
      throw new Error(`The fragment has no activity. Unable to start an intent.`)
    }
    if (event.type === EventTypes.Intent.startActivityForResult) {
      const info = event.detail as ActivityWithResultDetail
      const code = await activity.startActivityForResult(info.intent)
      this.requestCode = code
      this.pendingActivityResult.set(code, {
        onResult: info.onResult,
      })
    } else if (event.type === EventTypes.Intent.startActivity) {
      await activity.startActivity(event.detail.intent)
    } else {
      throw new Error(`Unrecognized intent event: ${event.type}`)
    }
  }

  /**
   * Shows a registered fragment.
   * @param key The fragment's key.
   * @param options Fragment rendering options.
   */
  async showFragment(key: string, options: { renderTarget?: HTMLElement; isHost?: boolean } = {}): Promise<void> {
    if (options.isHost) {
      const fragment = this.fragmentManager.findFragment(key)
      if (!fragment) {
        throw new Error(`Fragment with key ${key} not found.`)
      }
      this.host = fragment
    }
    await this.fragmentManager.showFragment(key, options.renderTarget)
  }

  /**
   * Hides a fragment by its key.
   * @param key The fragment's key.
   */
  async hideFragment(fragment: Fragment | string): Promise<void> {
    let ref: Fragment | undefined
    if (typeof fragment === 'string') {
      ref = this.fragmentManager.findFragment(fragment)
    } else {
      ref = fragment as Fragment
    }
    if (!ref) {
      throw new Error(`Fragment not found`)
    }
    if (this.host === ref) {
      this.host = this
    }
    await this.fragmentManager.hideFragment(ref)
  }
}
