import SwaggerParser from "@apidevtools/swagger-parser";
import { OpenAPI } from "openapi-types";

import { User } from "./contexts/UserContext";
import { getCookie } from "./util/get_cookie";
import { PageContribComponent } from "./types";

export const BODY_APPLICATION_TYPES = ["application/json", "multipart/form-data"] as const;
export type BodyApplicationType = (typeof BODY_APPLICATION_TYPES)[number];

export class ApiLoadFailedError extends Error {
  readonly response: Response;

  constructor(response: Response, message?: string) {
    super(message);
    this.response = response;
  }
}

export interface Request extends OpenAPI.Request {
  headers?: Record<string, string>;
  params?: Record<string, string | number>;
  query?: URLSearchParams;
}

export class ApiOperation {
  readonly id: string;
  readonly tags: string[];
  readonly server: string;
  readonly endpoint: string;
  readonly method: string;
  readonly request: OpenAPI.Request;
  readonly app: string;
  readonly summary?: string;
  readonly description?: string;
  readonly contribs?: string[];
  readonly bodyType?: BodyApplicationType;
  component?: PageContribComponent;
  order?: number;

  constructor(
    id: string,
    tags: string[],
    server: string,
    endpoint: string,
    method: string,
    request: OpenAPI.Request,
    app: string,
    summary?: string,
    description?: string,
    bodyType?: BodyApplicationType,
    contribs?: string[],
    component?: PageContribComponent,
    order?: number,
  ) {
    this.id = id;
    this.tags = tags;
    this.server = server;
    this.endpoint = endpoint;
    this.method = method;
    this.request = request;
    this.app = app;
    this.summary = summary;
    this.description = description;
    this.bodyType = bodyType;
    this.contribs = contribs ?? [];
    this.component = component;
    this.order = order;
  }

  /**
   * Return the `URL` for `endpoint`, possibly enriched with `request` params.
   * Useful for operations that generate a static url, like a file download.
   */
  url(request?: OpenAPI.Request) {
    let endpoint = this.endpoint;

    // Add params to url
    if (request?.params !== undefined) {
      for (const [name, value] of Object.entries(request.params)) {
        endpoint = endpoint.replace(`{${name}}`, value);
      }
    }

    const url = new URL(this.server + endpoint);

    // Add query to url
    if (request?.query) {
      let query;
      try {
        // @ts-expect-error - TODO
        query = new URLSearchParams(request.query);
      } catch {
        query = request?.query;
      }

      url.search = query.toString();
    }

    return url;
  }

  async call(request?: OpenAPI.Request, init?: RequestInit) {
    const csrftoken = getCookie("csrftoken");

    init ??= {};
    init.method ??= this.method;
    init.headers = new Headers(init.headers);
    if (csrftoken !== undefined) init.headers.append("X-CSRFToken", csrftoken);
    init.credentials = "include";

    // Add body to request
    if (request?.body !== undefined) {
      // "multipart/form-data" includes information about the FormData Boundry.
      // This is a bit arbitrary, so we need to have it set automatically.
      // Don't try to be smart when dealing with forms.
      if (this.bodyType && this.bodyType !== "multipart/form-data") {
        init.headers.set("Content-Type", this.bodyType);
      }

      switch (this.bodyType) {
        case "application/json":
          init.body ??= JSON.stringify(request?.body);
          break;
        case "multipart/form-data":
          if (init.body) {
            break;
          } else if (request.body instanceof FormData) {
            init.body = request.body;
            break;
          } else if (request.body instanceof HTMLFormElement) {
            new FormData(request.body);
            break;
          } else {
            throw TypeError(
              `Wrong type passed to ApiOperation.Call. Expected multipart/form-data as FormData or HTMLFormElement.`,
            );
          }
        default:
          init.body ??= request.body;
          break;
      }
    }

    return await fetch(this.url(request), init);
  }
}

export class ApiClient {
  readonly document: OpenAPI.Document;
  readonly operations: Record<string, ApiOperation> = {};
  readonly contrib: Record<string, Record<string, ApiOperation>> = {};

  static async load(source: string | URL, server?: string | URL) {
    const response = await fetch(source);
    if (!response.ok) {
      throw new ApiLoadFailedError(response, `Could not load schema from ${source}`);
    }
    const schema = await response.json().catch((e) => {
      throw new ApiLoadFailedError(response, `Could not parse schema from ${source}: ${e}`);
    });

    const document = await SwaggerParser.dereference(schema);
    return new ApiClient(document, server);
  }

  constructor(document: OpenAPI.Document, server?: string | URL) {
    this.document = document;

    // If the document uses servers and it is not manually set this will be prepended to path later
    if (
      "servers" in this.document &&
      this.document.servers !== undefined &&
      this.document.servers.length >= 1
    ) {
      server ??= this.document.servers.pop()!.url;
    }

    // Make sure the server variable is an URL
    if (server !== undefined && !(server instanceof URL)) {
      server = new URL(server);
    }

    // Build operations
    for (const [path, definition] of Object.entries(this.document.paths ?? {}) as [
      string,
      Record<string, OpenAPI.Operation>,
    ][]) {
      for (const [method, operation] of Object.entries(definition)) {
        let appName: string | undefined;
        const contribs: string[] = [];

        // All operations require an operation id
        if (!operation.operationId) {
          break;
        }

        // Check if an tag starting with `app:` exists on the operation
        operation.tags?.forEach((tag) => {
          if (tag.startsWith("app:")) {
            appName = tag.replace(/^app:/, "");
          } else if (tag.startsWith("contrib:")) {
            contribs.push(tag.replace(/^contrib:/, ""));
          }
        });
        if (appName === undefined) {
          break;
        }

        const request: OpenAPI.Request = {};

        // Fill the request object with `path`, `query` and `body` data which may be provided
        for (const parameter of operation.parameters ?? ([] as OpenAPI.Parameter[])) {
          if ("in" in parameter) {
            switch (parameter.in) {
              case "body": {
                request["body"] = {
                  required: parameter.required ?? false,
                  schema: parameter.schema,
                };
                break;
              }

              case "path": {
                request["params"] ??= {};
                // eslint-disable-next-line
                // @ts-ignore
                request["params"][parameter.name] = {
                  // Path parameters are always required: https://swagger.io/docs/specification/describing-parameters/#path-parameters
                  required: true,
                  schema: parameter.schema,
                };
                break;
              }

              case "query": {
                request["query"] ??= {};
                // eslint-disable-next-line
                // @ts-ignore
                request["query"][parameter.name] = {
                  required: parameter.required ?? false,
                  schema: parameter.schema,
                };
              }
            }
          }
        }

        let bodyType: BodyApplicationType | undefined;
        // If `requestBody` exists then add it to the request objects body object
        if ("requestBody" in operation && operation.requestBody !== undefined) {
          if ("content" in operation.requestBody) {
            const bodyTypes = Object.keys(operation.requestBody.content).map((bodyType) =>
              bodyType.toLowerCase(),
            );
            for (const type of bodyTypes) {
              // @ts-expect-error this is actually totally fine
              if (BODY_APPLICATION_TYPES.includes(type)) {
                const schema = operation.requestBody.content[type].schema;
                const required = operation.requestBody.required ?? false;

                bodyType = type as BodyApplicationType;
                request["body"] = {
                  schema,
                  required,
                };

                break;
              }
            }
          }
        }

        // If no server is set, fall back to the current origin
        server = server ? server.toString() : globalThis.origin;

        // Combine base url and path
        if (server.endsWith("/")) {
          server = server.slice(0, -1);
        }

        const endpoint = path.startsWith("/") ? path : "/" + path;

        // Create a new `ApiOperation` with the relevant information
        this.operations[operation.operationId] = new ApiOperation(
          operation.operationId,
          operation.tags || [],
          server,
          endpoint,
          method.toUpperCase(),
          request,
          appName,
          operation.summary,
          operation.description,
          bodyType,
          contribs,
          undefined,
          undefined,
        );

        for (const contribName of contribs) {
          this.contrib[contribName] ??= {};
          this.contrib[contribName][appName] = this.operations[operation.operationId];
        }
      }
    }
  }

  async getAuthenticatedUser() {
    try {
      const response = await this.operations["bananas.me:list"].call();
      return response.ok ? (response.json() as Promise<User>) : null;
    } catch {
      return null;
    }
  }

  findContrib(prefix: string) {
    const results = [];

    for (const [tag, operations] of Object.entries(this.contrib)) {
      for (const [_, operation] of Object.entries(operations)) {
        if (tag.startsWith(prefix) && operation.component !== undefined) {
          results.push(operation);
        }
      }
    }

    return results.sort((a, b) => {
      if (a.order !== undefined && b.order !== undefined) {
        return a.order - b.order;
      }
      return 0;
    });
  }
}
