import { isDev, isServer } from '@qwik.dev/core/build';
import { qTest } from './utils/qdev';
import { vnode_isVNode } from '../client/vnode-utils';
import { isSignal } from '../reactive-primitives/utils';
import { clearAllEffects } from '../reactive-primitives/cleanup';
import { invokeApply, newRenderInvokeContext, type RenderInvokeContext } from '../use/use-core';
import { type EventQRL, type UseOnEvent, type UseOnMap } from '../use/use-on';
import { isQwikComponent, type OnRenderFn } from './component.public';
import { assertDefined } from './error/assert';
import { Fragment, type Props } from './jsx/jsx-runtime';
import { _jsxSorted } from './jsx/jsx-internal';
import { JSXNodeImpl, isJSXNode } from './jsx/jsx-node';
import type { JSXNodeInternal, JSXOutput } from './jsx/types/jsx-node';
import type { KnownEventNames } from './jsx/types/jsx-qwik-events';
import type { QRLInternal } from './qrl/qrl-class';
import { isQrl } from './qrl/qrl-utils';
import type { Container, HostElement } from './types';
import { EMPTY_OBJ } from './utils/flyweight';
import { logWarn } from './utils/log';
import {
  ELEMENT_PROPS,
  ELEMENT_SEQ_IDX,
  OnRenderProp,
  USE_ON_LOCAL,
  USE_ON_LOCAL_SEQ_IDX,
} from './utils/markers';
import { MAX_RETRY_ON_PROMISE_COUNT, isPromise, maybeThen, safeCall } from './utils/promises';
import { isArray, isPrimitiveOrNullUndefined, type ValueOrPromise } from './utils/types';
import { getSubscriber } from '../reactive-primitives/subscriber';
import { EffectProperty } from '../reactive-primitives/types';
import { EventNameHtmlScope, getEventDataFromHtmlAttribute } from './utils/event-names';
import { isServerPlatform } from './platform/platform';
import type { ISsrNode, SSRContainer } from '../ssr/ssr-types';
import { ChoreBits } from './vnode/enums/chore-bits.enum';
import type { SignalImpl } from '../reactive-primitives/impl/signal-impl';
import { isTask } from '../use/use-task';

/**
 * Use `executeComponent` to execute a component.
 *
 * Component execution can be complex because of:
 *
 * - It can by async
 * - It can contain many tasks which need to be awaited
 * - Each task can run multiple times if they track signals which change.
 * - The JSX may be re-generated multiple times of a task needs to be rerun due to signal change.
 * - It needs to keep track of hook state.
 *
 * For `component$`: `renderHost` === `subscriptionHost` For inlined-components: the
 * `subscriptionHost` is a parent `component$` which needs to re-execute.
 *
 * @param container
 * @param renderHost - VNode into which the component is rendered into.
 * @param subscriptionHost - VNode which will be re-executed if the component needs to re-render.
 * @param componentQRL
 * @param props
 * @returns
 */
export const executeComponent = (
  container: Container,
  renderHost: HostElement,
  subscriptionHost: HostElement | null,
  componentQRL: OnRenderFn<unknown> | QRLInternal<OnRenderFn<unknown>> | null,
  props: Props | null
): ValueOrPromise<JSXOutput> => {
  const iCtx = newRenderInvokeContext(
    container.$locale$,
    renderHost,
    container
  ) as RenderInvokeContext;
  if (subscriptionHost) {
    iCtx.$effectSubscriber$ = getSubscriber(subscriptionHost, EffectProperty.COMPONENT);
    iCtx.$container$ = container;
  }
  let componentFn: (props: unknown) => ValueOrPromise<JSXOutput>;
  container.ensureProjectionResolved(renderHost);
  let isInlineComponent = false;
  if (componentQRL === null) {
    componentQRL = container.getHostProp(renderHost, OnRenderProp)!;
    isDev && assertDefined(componentQRL, 'No Component found at this location');
  }
  if (isQrl(componentQRL)) {
    props = props || container.getHostProp(renderHost, ELEMENT_PROPS) || EMPTY_OBJ;
    // TODO is this possible? JSXNode handles this, no?
    if ('children' in props) {
      delete props.children;
    }
    componentFn = componentQRL.getFn(iCtx);
  } else if (isQwikComponent(componentQRL)) {
    const qComponentFn = componentQRL as any as (
      props: Props,
      key: string | null,
      flags: number
    ) => JSXNodeInternal;
    componentFn = () => invokeApply(iCtx, qComponentFn, [props || EMPTY_OBJ, null, 0]);
  } else {
    isInlineComponent = true;
    const inlineComponent = componentQRL as (props: Props) => JSXOutput;
    componentFn = () => invokeApply(iCtx, inlineComponent, [props || EMPTY_OBJ]);
  }

  const isSsr = qTest ? isServerPlatform() : isServer;

  const executeComponentWithPromiseExceptionRetry = (retryCount = 0): ValueOrPromise<JSXOutput> =>
    safeCall<JSXOutput, JSXOutput, JSXOutput>(
      () => {
        if (!isInlineComponent) {
          container.setHostProp(renderHost, ELEMENT_SEQ_IDX, null);
          container.setHostProp(renderHost, USE_ON_LOCAL_SEQ_IDX, null);
        }

        if (retryCount > 0 && vnode_isVNode(renderHost)) {
          clearAllEffects(container, renderHost);
        }

        return maybeThen(componentFn(props), (jsx) => maybeThen(iCtx.$waitOn$, () => jsx));
      },
      (jsx) => {
        // In SSR, check if the component was marked dirty (COMPONENT bit) during execution.
        // This happens when something completes and updates reactive primitives
        // while we're waiting on $waitOn$. If so, we need to re-execute the component
        // to get fresh JSX with updated values.
        if (isSsr && !isInlineComponent) {
          const ssrNode = renderHost as ISsrNode;
          if (ssrNode.dirty & ChoreBits.COMPONENT) {
            ssrNode.dirty &= ~ChoreBits.COMPONENT;
            if (retryCount < MAX_RETRY_ON_PROMISE_COUNT) {
              return executeComponentWithPromiseExceptionRetry(retryCount + 1);
            }
          }
        }

        const useOnEvents = container.getHostProp<UseOnMap>(renderHost, USE_ON_LOCAL);
        if (useOnEvents) {
          return addUseOnEvents(container, jsx, useOnEvents);
        }
        return jsx;
      },
      (err) => {
        if (isPromise(err) && retryCount < MAX_RETRY_ON_PROMISE_COUNT) {
          return err.then(() =>
            executeComponentWithPromiseExceptionRetry(++retryCount)
          ) as Promise<JSXOutput>;
        } else {
          if (retryCount >= MAX_RETRY_ON_PROMISE_COUNT) {
            throw new Error(`Max retry count of component execution reached`);
          }
          throw err;
        }
      }
    );
  return executeComponentWithPromiseExceptionRetry();
};

/**
 * Adds `useOn` events to the JSX output.
 *
 * @param jsx The JSX output to modify.
 * @param useOnEvents The `useOn` events to add.
 * @returns The modified JSX output.
 */
function addUseOnEvents(
  container: Container,
  jsx: JSXOutput,
  useOnEvents: UseOnMap
): ValueOrPromise<JSXNodeInternal<string> | null | JSXOutput> {
  const jsxElement = findFirstElementNode(jsx);
  let jsxResult = jsx;
  const qVisibleEvent = 'q-e:qvisible';
  return maybeThen(jsxElement, (jsxElement) => {
    // headless components are components that don't render a real DOM element
    const isHeadless = !jsxElement;
    // placeholder element is a <script> element that is used to add events to the document or window
    let placeholderElement: JSXNodeInternal<string> | null = null;
    for (const key in useOnEvents) {
      if (Object.prototype.hasOwnProperty.call(useOnEvents, key)) {
        let targetElement = jsxElement;
        let eventKey = key;

        if (isHeadless) {
          // if the component is headless, we need to add the event to the placeholder element
          if (
            key === qVisibleEvent ||
            key.startsWith(EventNameHtmlScope.document) ||
            key.startsWith(EventNameHtmlScope.window)
          ) {
            if (!placeholderElement) {
              placeholderElement = createPlaceholderScriptNode();
              // A headless component that projects the document root (e.g. wrapping `<head>`/`<body>`
              // in a `<Slot/>`) would otherwise place the placeholder `<script>` as a direct child of
              // `<html>`, which is invalid. On the server let the container defer it into `<head>`.
              const isSsr = qTest ? isServerPlatform() : isServer;
              if (
                !(isSsr && (container as SSRContainer).$deferRootPlaceholder$(placeholderElement))
              ) {
                jsxResult = injectPlaceholderElement(jsxResult, placeholderElement);
              }
            }
            targetElement = placeholderElement;
          } else {
            if (isDev) {
              const sourceLocation = getUseOnSourceLocation(useOnEvents[key].qrls);
              logWarn(
                'You are trying to add an event "' +
                  key +
                  '" using `useOn` hook, ' +
                  'but a node to which you can add an event is not found. ' +
                  'Please make sure that the component outputs a DOM element.' +
                  (sourceLocation ? ` Offending \`useOn\`: ${sourceLocation}.` : '')
              );
            }
            continue;
          }
        }
        if (targetElement) {
          if (targetElement.type === 'script' && key === qVisibleEvent) {
            eventKey = 'q-d:qinit';
            if (isDev) {
              const sourceLocation = getUseOnSourceLocation(useOnEvents[key].qrls);
              logWarn(
                `You are trying to add the event "${key}" ` +
                  'using the `useVisibleTask$` hook with the "intersection-observer" strategy, ' +
                  'but this only works when the component outputs a DOM element. Falling back to ' +
                  '"document-ready" instead.' +
                  (sourceLocation ? ` Offending \`useVisibleTask$\`: ${sourceLocation}.` : '')
              );
            }
          }
          addUseOnEvent(targetElement, eventKey, useOnEvents[key]);
        }
      }
    }
    return jsxResult;
  });
}

function getUseOnSourceLocation(eventQrls: EventQRL<KnownEventNames>[]): string | null {
  for (let i = 0; i < eventQrls.length; i++) {
    const eventQrl = eventQrls[i];
    const task = eventQrl?.getCaptured()?.[0];
    if (isTask(task)) {
      const dev = task.$qrl$.dev;
      if (dev?.file) {
        return typeof dev.lo === 'number' && typeof dev.hi === 'number'
          ? `${dev.file}:${dev.lo}-${dev.hi}`
          : dev.file;
      }
    }
  }
  return null;
}

/**
 * Adds an event to the JSX element.
 *
 * @param jsxElement The JSX element to add the event to.
 * @param key The event name.
 * @param value The event value.
 */
function addUseOnEvent(jsxElement: JSXNodeInternal, key: string, value: UseOnEvent) {
  // These handlers are always there, so they go in constProps
  const props = (jsxElement.constProps ||= {} as Props);
  const propValue = props[key] as
    | EventQRL<KnownEventNames>[]
    | EventQRL<KnownEventNames>
    | undefined;
  const qrls = value.qrls;
  if (propValue == null) {
    props[key] = qrls;
  } else if (Array.isArray(propValue)) {
    propValue.push(...qrls);
  } else {
    props[key] = [propValue, ...qrls];
  }
  const varProp = jsxElement.varProps[key];
  if (varProp) {
    // we need to demote the handlers to varProps
    if (Array.isArray(propValue)) {
      propValue.push(...(props[key] as any));
    } else {
      jsxElement.varProps[key] = [propValue, ...qrls];
    }
    props[key] = undefined;
  }

  const capture = value.capture;
  const preventdefault = value.preventdefault;
  const stoppropagation = value.stoppropagation;
  if (!capture && !preventdefault && !stoppropagation) {
    return;
  }
  const [, eventName] = getEventDataFromHtmlAttribute(key);
  capture && addUseOnModifier(jsxElement, eventName, 'capture');
  preventdefault && addUseOnModifier(jsxElement, eventName, 'preventdefault');
  stoppropagation && addUseOnModifier(jsxElement, eventName, 'stoppropagation');
}

function addUseOnModifier(
  jsxElement: JSXNodeInternal,
  eventName: string,
  modifier: keyof Omit<UseOnEvent, 'qrls'>
) {
  const key = `${modifier}:${eventName}`;
  const varProps = jsxElement.varProps;
  if (varProps === EMPTY_OBJ) {
    jsxElement.varProps = {};
  }
  jsxElement.varProps[key] = true;
}

/**
 * Finds the first element node in the JSX output.
 *
 * @param jsx The JSX output to search.
 * @returns The first element node or null if no element node is found.
 */
function findFirstElementNode(jsx: JSXOutput): ValueOrPromise<JSXNodeInternal<string> | null> {
  const queue: any[] = [jsx];
  while (queue.length) {
    const jsx = queue.shift();
    if (isJSXNode(jsx)) {
      if (typeof jsx.type === 'string') {
        return jsx as JSXNodeInternal<string>;
      }
      queue.push(jsx.children);
    } else if (isArray(jsx)) {
      queue.push(...jsx);
    } else if (isPromise<JSXOutput>(jsx)) {
      return maybeThen(jsx, (jsx) => findFirstElementNode(jsx));
    } else if (isSignal(jsx)) {
      return findFirstElementNode((jsx as SignalImpl).untrackedValue);
    }
  }
  return null;
}

/**
 * Injects a placeholder <script> element into the JSX output.
 *
 * This is necessary for headless components (components that don't render a real DOM element) to
 * have an anchor point for `useOn` event listeners that target the document or window.
 *
 * @param jsx The JSX output to modify.
 * @param placeholder The placeholder element to inject.
 * @returns The modified JSX output.
 */
function injectPlaceholderElement(jsx: JSXOutput, placeholder: JSXNodeInternal<string>): JSXOutput {
  // For regular JSX nodes, we can append the placeholder to its children.
  if (isJSXNode(jsx)) {
    // Inline components may ignore children, while component$ children become projections.
    if (jsx.type !== Fragment) {
      return _jsxSorted(Fragment, null, null, [jsx, placeholder], 0, null);
    }

    if (jsx.children == null) {
      jsx.children = placeholder;
    } else if (isArray(jsx.children)) {
      jsx.children.push(placeholder);
    } else {
      jsx.children = [jsx.children, placeholder];
    }
    return jsx;
  }

  // For primitives, we can't add children, so we wrap them in a fragment.
  if (isPrimitiveOrNullUndefined(jsx)) {
    return _jsxSorted(Fragment, null, null, [jsx, placeholder], 0, null);
  }

  // For an array of nodes, we inject the placeholder into the first element.
  if (isArray(jsx) && jsx.length > 0) {
    injectPlaceholderElement(jsx[0], placeholder);
    return jsx;
  }

  // For anything else we do nothing.
  return jsx;
}

/** @returns An empty <script> element for adding qwik metadata attributes to */
function createPlaceholderScriptNode(): JSXNodeInternal<string> {
  return new JSXNodeImpl('script', null, { hidden: '' }, null, 0, null);
}
