import { html, nothing, TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { AmfNamespace, ApiDefinitions, IHttpRequest, IRequest, SecurityProcessor } from '@api-client/core/build/browser.js';
import { ApiDocumentationBase } from './ApiDocumentationBase.js';
import { joinTraitNames } from '../../amf/lib/Utils.js';
import * as UrlLib from '../../amf/lib/UrlUtils.js';
import { ReportingEvents } from '../../events/amf/ReportingEvents.js';
import { TelemetryEvents } from '../../events/amf/TelemetryEvents.js';
import { EndpointEvents } from '../../events/amf/EndpointEvents.js';
import { ServerEvents } from '../../events/amf/ServerEvents.js';
import { ApiEvents } from '../../events/amf/ApiEvents.js';
import ApiRequestElement from './ApiRequest.element.js';
import { Oauth2Credentials } from '../authorization/types.js';
import '../../define/amf/api-request.js';
import '../../define/amf/api-operation-document.js'
import '../../define/amf/api-parameter-document.js';
import '../../define/http/http-snippets.js';

interface SnippetInfo {
  value?: IRequest;
  reading: boolean;
}

/**
 * A web component that renders the resource documentation page for an API resource built from 
 * the AMF graph model.
 * 
 * @fires tryit
 */
export default class ApiResourceDocument extends ApiDocumentationBase {
  protected endpointValue?: ApiDefinitions.IApiEndPoint;

  protected serverInternal?: ApiDefinitions.IApiServer;

  protected serversValue?: ApiDefinitions.IApiServer[];

  protected operationIdValue?: string;

  protected serverIdValue?: string;

  protected baseUriValue?: string;

  protected urlValue?: string;

  protected protocolsValue?: string[];

  protected snippetsInternal: Record<string, SnippetInfo>;

  get snippets(): Record<string, SnippetInfo> {
    return { ...this.snippetsInternal };
  }

  protected processSelectionTimeout?: number;
  
  get endpoint(): ApiDefinitions.IApiEndPoint | undefined {
    return this.endpointValue;
  }

  set endpoint(value) {
    const old = this.endpointValue;
    if (old === value) {
      return;
    }
    this.endpointValue = value;
    this.processGraph();
  }

  get operationId(): string | undefined {
    return this.operationIdValue;
  }

  /** 
   * When set it scrolls to the operation with the given id, if exists.
   * The operation is performed after render.
   * @attribute
   */
  @property({ type: String, reflect: true })
  set operationId(value) {
    const old = this.operationIdValue;
    if (old === value) {
      return;
    }
    this.operationIdValue = value;
    this.requestUpdate('operationId', old);
    this.operationIdChanged();
  }

  get serverId(): string | undefined {
    return this.serverIdValue;
  }

  /** 
   * The id of the currently selected server to use to construct the URL.
   * If not set a first server in the API servers array is used.
   * @attribute
   */
  @property({ type: String, reflect: true })
  set serverId(value) {
    const old = this.serverIdValue;
    if (old === value) {
      return;
    }
    this.serverIdValue = value;
    this.selectServer();
    this.processServerSelection();
    this.requestUpdate();
  }

  get server(): ApiDefinitions.IApiServer | undefined {
    if (this.serverInternal) {
      return this.serverInternal;
    }
    const servers = this.serversValue;
    if (Array.isArray(servers) && servers.length) {
      const [server] = servers;
      if (server) {
        this.serverInternal = server;
      }
    }
    return this.serverInternal;
  }

  set server(value) {
    const old = this.serverInternal;
    if (old === value) {
      return;
    }
    this.serverInternal = value;
    this.processServerSelection();
    this.requestUpdate('server', old);
  }

  /**
   * The list of the servers read from the API and the endpoint.
   */
  get servers(): ApiDefinitions.IApiServer[] | undefined {
    return this.serversValue;
  }

  /**
   * The list of protocols to render.
   */
  get protocol(): string | undefined {
    const { server } = this;
    if (!server) {
      return undefined;
    }
    const { protocol } = server;
    return protocol;
  }

  /**
   * A property to set to override AMF's model base URI information.
   * When this property is set, the `endpointUri` property is recalculated.
   * @attribute
   */
  @property({ type: String })
  get baseUri(): string | undefined {
    return this.baseUriValue;
  }


  set baseUri(value) {
    const old = this.baseUriValue;
    if (old === value) {
      return;
    }
    this.baseUriValue = value;
    this.computeUrlValue();
    this.requestUpdate();
  }

  /**
   * The computed URI for the endpoint.
   */
  get endpointUri(): string | undefined {
    return this.urlValue;
  }

  /**
   * The API's protocols.
   */
  get protocols(): string[] | undefined {
    return this.protocolsValue;
  }

  /** 
   * When set it renders the "try it" button that dispatches the `tryit` event.
   * @attribute
   */
  @property({ type: Boolean, reflect: true }) tryItButton?: boolean;

  /** 
   * When set it renders the "try it" panel next to the operation documentation.
   * Setting this automatically disables the `tryItButton` property.
   * 
   * Note, use this only when there's enough space on the screen to render 2 panels side-by-side.
   * @attribute
   */
  @property({ type: Boolean, reflect: true }) tryItPanel?: boolean;

  /** 
   * When set it renders the URL input above the URL parameters in the HTTP editor.
   * @attribute
   */
  @property({ type: Boolean, reflect: true }) httpUrlEditor?: boolean;

  /**
   * List of credentials source passed to the HTTP editor
   */
  @property({ type: Array }) httpCredentialsSource?: Oauth2Credentials[];

  /**
   * OAuth2 redirect URI.
   * This value **must** be set in order for OAuth 1/2 to work properly.
   * This is only required in inline mode (`inlineMethods`).
   * @attribute
   */
  @property({ type: String }) redirectUri?: string;

  /**
   * Optional property to set on the request editor. 
   * When true, the server selector is not rendered
   * @attribute
   */
  @property({ type: Boolean, reflect: true }) httpNoServerSelector?: boolean;

  /**
   * When set it renders "add custom" item button in the HTTP request editor.
   * If the element is to be used without AMF model this should always
   * be enabled. Otherwise users won't be able to add a parameter.
   * @attribute
   */
  @property({ type: Boolean, reflect: true }) httpAllowCustom?: boolean;

  /**
   * Optional property to set on the request editor. 
   * If true, the server selector custom base URI option is rendered
   * @attribute
   */
  @property({ type: Boolean, reflect: true }) httpAllowCustomBaseUri?: boolean;

  /** 
   * When set it renders the view optimised for asynchronous API operation.
   * @attribute
   */
  @property({ type: Boolean, reflect: true }) asyncApi?: boolean;

  /**
   * Holds the value of the currently selected server
   * Data type: URI
   * @attribute
   */
  @property({ type: String }) serverValue?: string;

  /**
   * Holds the type of the currently selected server
   * Values: `server` | `uri` | `custom`
   * @attribute
   */
  @property({ type: String }) serverType?: string;

  constructor() {
    super();
    
    this.snippetsInternal = {};
  }

  override disconnectedCallback(): void {
    if (this.processSelectionTimeout) {
      clearTimeout(this.processSelectionTimeout);
      this.processSelectionTimeout = undefined;
    }
    super.disconnectedCallback();
  }

  /**
   * Scrolls the view to the operation, when present in the DOM.
   * @param id The operation domain id to scroll into.
   */
  scrollToOperation(id: string): void {
    const { shadowRoot } = this;
    if (!shadowRoot) {
      return;
    }
    const elm = shadowRoot.querySelector(`api-operation-document[data-domain-id="${id}"]`);
    if (!elm) {
      return;
    }
    elm.scrollIntoView({block: 'start', inline: 'nearest', behavior: 'smooth'});
  }

  override async processGraph(): Promise<void> {
    await this.queryEndpoint();
    await this.queryServers();
    await this.queryProtocols();
    this.computeUrlValue();
    this.requestUpdate();
    await this.updateComplete;
    if (this.processSelectionTimeout) {
      clearTimeout(this.processSelectionTimeout);
      this.processSelectionTimeout = undefined;
    }
    // this timeout gives few milliseconds for operations to render.
    this.processSelectionTimeout = setTimeout(() => {
      this.processSelectionTimeout = undefined;
      this.collectCodeSnippets();
      if (this.operationId) {
        this.scrollToOperation(this.operationId);
      }
    }, 200) as unknown as number;
  }

  /**
   * Queries the API store for the API summary object.
   */
  async queryProtocols(): Promise<void> {
    this.protocolsValue = undefined;
    try {
      const info = await ApiEvents.protocols(this);
      this.protocolsValue = info;
    } catch (e) {
      const ex = e as Error;
      TelemetryEvents.exception(this, ex.message, false);
      ReportingEvents.error(this, ex, `Unable to query for API protocols list: ${ex.message}`, this.localName);
    }
  }

  /**
   * Queries the store for the endpoint data.
   * @returns {Promise<void>}
   */
  async queryEndpoint(): Promise<void> {
    const { domainId } = this;
    if (!domainId) {
      // this.endpointValue = undefined;
      return;
    }
    if (this.endpointValue && this.endpointValue.id === domainId) {
      // in case the endpoint model was provided via property setter.
      return;
    }
    try {
      const info = await EndpointEvents.get(this, domainId);
      this.endpointValue = info;
    } catch (e) {
      const ex = e as Error;
      TelemetryEvents.exception(this, ex.message, false);
      ReportingEvents.error(this, ex, `Unable to query for API endpoint data: ${ex.message}`, this.localName);
    }
  }

  /**
   * Scrolls to the selected operation after view update.
   */
  protected async operationIdChanged(): Promise<void> {
    await this.updateComplete;
    const { operationId } = this;
    if (operationId) {
      this.scrollToOperation(operationId);
    } else {
      this.scrollIntoView({block: 'start', inline: 'nearest', behavior: 'smooth'});
    }
  }

  /**
   * Queries the store for the current servers value.
   */
  async queryServers(): Promise<void> {
    this.serversValue = undefined;
    const { domainId } = this;
    const endpointId = this.endpointValue && this.endpointValue.id;
    try {
      const info = await ServerEvents.query(this, {
        endpointId,
        methodId: domainId,
      });
      this.serversValue = info;
    } catch (e) {
      const ex = e as Error;
      TelemetryEvents.exception(this, ex.message, false);
      ReportingEvents.error(this, ex, `Unable to query for API servers: ${ex.message}`, this.localName);
    }
  }

  /**
   * Sets the private server value for the current server defined by `serverId`.
   * Calls the `.processServerSelection()` function to set server related values.
   */
  protected selectServer(): void {
    this.serverInternal = undefined;
    const { serverId } = this;
    const servers = this.serversValue;
    if (!serverId || !Array.isArray(servers)) {
      return;
    }
    this.serverInternal = servers.find(s => s.id === serverId);
  }
  
  /**
   * Performs actions after a server is selected.
   */
  protected processServerSelection(): void {
    this.computeUrlValue();
  }

  /**
   * Computes the URL value for the current serves, selected server, and endpoint's path.
   */
  protected computeUrlValue(): void {
    const endpoint = this.endpointValue;
    const { baseUri, server, protocols } = this;
    const url = UrlLib.computeEndpointUri({ baseUri, server, endpoint, protocols, });
    this.urlValue = url;
  }

  /**
   * Runs over each request editor and collects request values for code snippets generators.
   */
  protected collectCodeSnippets(): void {
    const { shadowRoot } = this;
    if (!shadowRoot) {
      return;
    }
    const panels = shadowRoot.querySelectorAll('api-request');
    if (!panels.length) {
      return;
    }
    Array.from(panels).forEach((panel) => this.readCodeSnippets(panel));
    this.requestUpdate();
  }

  protected requestChangeHandler(e: Event): void {
    const panel = e.target as ApiRequestElement;
    this.readCodeSnippets(panel);
    this.requestUpdate();
  }

  /**
   * Reads the request data from the request panel for the code snippets.
   */
  protected async readCodeSnippets(panel: ApiRequestElement): Promise<void> {
    const { requestId } = panel.dataset;
    if (!requestId) {
      return;
    }
    if (!this.snippetsInternal[requestId]) {
      this.snippetsInternal[requestId] = { reading: false }
    }
    const info = this.snippetsInternal[requestId];
    if (info.reading) {
      return;
    }
    try {
      info.reading = true;
      const request = await panel.serialize();
      if (request.authorization && request.authorization.length) {
        SecurityProcessor.applyAuthorization(request.expects, request.authorization);
      }
      info.value = request;
      this.requestUpdate();
    } catch (e) {
      // what can go wrong it that the request is not yet set on the editor
      // due to the debouncer and we are trying to serialize a request
      // that is not yet ready. This will redo the operation when the request panel 
      // render the HTTP editor.
      // ...
    } finally {
      info.reading = false;
    }
  }

  override render(): TemplateResult {
    const { endpoint } = this;
    if (!endpoint) {
      return html``;
    }
    return html`
    ${this.renderTitle(endpoint)}
    ${this.renderUrl()}
    ${this.renderExtensions(endpoint)}
    ${this.descriptionTemplate(endpoint.description)}
    ${this.customDomainPropertiesTemplate(endpoint.customDomainProperties)}
    ${this.renderOperations(endpoint)}
    `;
  }

  /**
   * @returns The template for the Operation title.
   */
  protected renderTitle(endpoint: ApiDefinitions.IApiEndPoint): TemplateResult | string {
    const { name, path } = endpoint;
    const label = name || path;
    if (!label) {
      return '';
    }
    const subLabel = this.asyncApi ? 'API channel' : 'API endpoint';
    return html`
    <div class="endpoint-header">
      <div class="endpoint-title">
        <span class="label text-selectable">${label}</span>
      </div>
      <p class="sub-header">${subLabel}</p>
    </div>
    `;
  }

  /**
   * @returns The template for the operation's URL.
   */
  protected renderUrl(): TemplateResult {
    const url = this.urlValue;
    return html`
    <div class="endpoint-url">
      <div class="url-value text-selectable">${url}</div>
    </div>
    `;
  }

  /**
   * @returns The template for the list of operations.
   */
  protected renderOperations(endpoint: ApiDefinitions.IApiEndPoint): TemplateResult | string {
    const { operations } = endpoint;
    if (!operations.length) {
      return '';
    }
    return html`
    <div class="operations">
      ${operations.map((operation) => this.renderOperation(endpoint, operation))}
    </div>
    `;
  }

  /**
   * @param operation The operation to render.
   * @returns The template for the API operation.
   */
  protected renderOperation(endpoint: ApiDefinitions.IApiEndPoint, operation: ApiDefinitions.IApiOperation): TemplateResult {
    const { serverId, baseUri, tryItPanel, tryItButton, asyncApi } = this;
    const renderTryIt = !tryItPanel && !asyncApi && !!tryItButton;
    const classes = {
      'operation-container': true,
      surface1: true,
      tryit: !!tryItPanel,
    };
    return html`
    <div class="${classMap(classes)}">
      <api-operation-document
        .domainId="${operation.id}"
        .operation="${operation}"
        .endpoint="${endpoint}"
        .endpointId="${endpoint.id}"
        .serverId="${serverId}" 
        .baseUri="${baseUri}" 
        data-domain-id="${operation.id}"
        ?tryItButton="${renderTryIt}"
        showSecurity
        ?renderCodeSnippets="${!tryItPanel}"
        ?asyncApi="${this.asyncApi}"
        class="operation"
      ></api-operation-document>
      ${tryItPanel ? this.renderTryItColumn(operation) : ''}
    </div>
    `;
  }

  /**
   * @param operation The operation to render.
   * @returns The template for the try it column panel rendered next to the operation documentation/
   */
  protected renderTryItColumn(operation: ApiDefinitions.IApiOperation): TemplateResult | string {
    if (this.asyncApi) {
      return '';
    }
    return html`
    <div class="try-it-column">
      <!-- <div class="sticky-content"> -->
        ${this.renderHttpRequest(operation)}
        ${this.renderCodeSnippetsPanel(operation)}
      <!-- </div> -->
    </div>
    `;
  }

  /**
   * @param operation The operation to render.
   * @returns The template for the request editor.
   */
  protected renderHttpRequest(operation: ApiDefinitions.IApiOperation): TemplateResult {
    const content = html`
    <api-request
      .domainId="${operation.id}"
      .serverValue="${this.serverValue}"
      .serverType="${this.serverType}"
      .baseUri="${this.baseUri}"
      .redirectUri="${this.redirectUri}"
      .credentialsSource="${this.httpCredentialsSource}"
      ?urlEditor="${this.httpUrlEditor}"
      ?urlLabel="${!this.httpUrlEditor}"
      ?noServerSelector="${this.httpNoServerSelector}"
      ?allowCustomBaseUri="${this.httpAllowCustomBaseUri}"
      ?allowCustom="${this.httpAllowCustom}"
      allowHideOptional
      data-request-id="${operation.id}"
      @change="${this.requestChangeHandler}"
    ></api-request>
    `;

    return content;
  }

  /**
   * @param operation The operation to render.
   * @returns The template for the request's code snippets.
   */
  protected renderCodeSnippetsPanel(operation: ApiDefinitions.IApiOperation): TemplateResult | typeof nothing {
    const values = this.snippetsInternal[operation.id];
    if (!values || !values.value) {
      return nothing;
    }
    const { expects } = values.value;
    let { payload } = expects;
    if (payload && typeof payload !== 'string') {
      payload = '';
    }
    const request: IHttpRequest = {
      url: expects.url,
      headers: expects.headers,
      method: expects.method,
      payload: payload as string,
    };
    return html`
    <section class="snippets text-selectable">
      <http-snippets .request="${request}"></http-snippets>
    </section>
    `;
  }

  /**
   * @return The template for the endpoint's extensions.
   */
  protected renderExtensions(endpoint: ApiDefinitions.IApiEndPoint): TemplateResult | typeof nothing {
    const { extends: extensions } = endpoint;
    if (!extensions || !extensions.length) {
      return nothing;
    }
    const type = extensions.find(e => e.types.includes(AmfNamespace.aml.vocabularies.apiContract.ParametrizedResourceType));
    const traits = extensions.filter(e => e.types.includes(AmfNamespace.aml.vocabularies.apiContract.ParametrizedTrait));
    const traitsLabel = joinTraitNames(traits);
    const typeLabel = type && type.name;
    if (!traitsLabel && !typeLabel) {
      return nothing;
    }
    return html`
    <section class="extensions">
      ${this.renderExtends(typeLabel)} ${this.renderTraits(traitsLabel)}
    </section>
    `;
  }

  /**
   * @returns The template for the parent resource type.
   */
  protected renderExtends(label?: string): TemplateResult | typeof nothing {
    if (!label) {
      return nothing;
    }
    return html`<span>Implements </span><span class="resource-type-name text-selectable" title="Resource type applied to this endpoint">${label}</span>.`;
  }

  /**
   * @returns The template for the traits applied to the resource.
   */
  protected renderTraits(label?: string): TemplateResult | typeof nothing {
    if (!label) {
      return nothing;
    }
    return html`<span>Mixes in </span><span class="trait-name text-selectable">${label}</span>.`;
  }
}
