import { nanoid } from '../nanoid.js'
import { ProjectDefinitionProperty, IProjectDefinitionProperty } from './ProjectDefinitionProperty.js'
import { ProjectFolder } from './ProjectFolder.js'
import { IHttpRequest, Kind as HttpRequestKind } from './HttpRequest.js'
import { HttpProject } from './HttpProject.js'
import { IRequest, Request } from './Request.js'
import { Environment } from './Environment.js'
import { Property } from './Property.js'
import { Server } from './Server.js'
import { UriTemplate } from '../lib/parsers/UriTemplate.js'
import { RequestAuthorization } from './RequestAuthorization.js'
import { Thing } from './Thing.js'

export const Kind = 'Core#ProjectRequest'

export interface RequestUrlReadOptions {
  /**
   * When set it won't apply variables to the URL.
   */
  noVariables?: boolean
  /**
   * When set is returns the only the base URL defined in the environment without
   * adding the `url` defined on the request object.   *
   */
  baseUrl?: boolean
  /**
   * When set it uses these variables instead one the onces defined on the project.
   * When this is defined then `noVariables` has no effect.
   */
  variables?: Record<string, string>
}

export interface IProjectRequest extends IProjectDefinitionProperty, IRequest {
  kind: typeof Kind
  /**
   * The identifier of the request.
   */
  key: string
  /**
   * The key of an environment defined on the project to apply to this request.
   * When not set it inherits environments from parents.
   * However, when this is set, it makes sure that server/variables from this environment are always applied.
   */
  environment?: string
}

export interface IRequestCloneOptions {
  /**
   * By default it revalidates (re-creates) keys in the request.
   * Set this to true to not make any changes to the keys.
   */
  withoutRevalidate?: boolean
  /**
   * By default it attaches the request to the same parent as the original request.
   * Set this to `true` when moving a request between projects to prevent adding the request to the project.
   * Note, the request still have a reference to the original project. You need to update the `project` property.
   */
  withoutAttach?: boolean
}

/**
 * **Note about the payload.**
 *
 * The payload is **always** stored in the request object in its serialized form.
 * Use the `readPayload()` to read the correct data type and the `writePayload()` to
 * safely store the payload.
 */
export class ProjectRequest extends Request implements ProjectDefinitionProperty {
  override kind = Kind

  /**
   * The identifier of the request.
   */
  key = ''

  /**
   * A reference to the top level project object.
   */
  project: HttpProject

  /**
   * The key of an environment defined on the project to apply to this request.
   * When not set it inherits environments from parents.
   * However, when this is set, it makes sure that server/variables from this environment are always applied.
   */
  environment?: string

  /**
   * Checks whether the input is a definition of a project folder.
   */
  static isProjectRequest(input: unknown): boolean {
    const typed = input as IProjectRequest
    if (!input || typed.kind !== Kind) {
      return false
    }
    return true
  }

  /**
   * Creates a project request from an URL.
   * This does not manipulate the project.
   *
   * @param url The Request URL. This is required.
   * @param project The parent project.
   */
  static override fromUrl(url: string, project?: HttpProject): ProjectRequest {
    if (!project) {
      throw new Error(`The project is required.`)
    }
    const now: number = Date.now()
    const request = new ProjectRequest(project, {
      key: nanoid(),
      kind: Kind,
      created: now,
      updated: now,
      expects: {
        kind: HttpRequestKind,
        method: 'GET',
        url,
      },
      info: Thing.fromName(url).toJSON(),
    })
    return request
  }

  /**
   * Creates a project request from a name.
   * This does not manipulate the project.
   *
   * @param name The Request name.
   * @param project The parent project.This is required.
   */
  static override fromName(name: string, project?: HttpProject): ProjectRequest {
    if (!project) {
      throw new Error(`The project is required.`)
    }
    const now: number = Date.now()
    const request = new ProjectRequest(project, {
      key: nanoid(),
      kind: Kind,
      created: now,
      updated: now,
      expects: {
        kind: HttpRequestKind,
        method: 'GET',
        url: '',
      },
      info: Thing.fromName(name).toJSON(),
    })
    return request
  }

  /**
   * Creates a project request from an HttpRequest definition.
   * This does not manipulate the project.
   *
   * @param project The parent project This is required.
   * @param info The request data.
   */
  static override fromHttpRequest(info: IHttpRequest, project?: HttpProject): ProjectRequest {
    if (!project) {
      throw new Error(`The project is required.`)
    }
    const now: number = Date.now()
    const request = new ProjectRequest(project, {
      key: nanoid(),
      kind: Kind,
      created: now,
      updated: now,
      expects: {
        kind: HttpRequestKind,
        method: info.method,
        url: info.url,
        headers: info.headers,
        payload: info.payload,
      },
      info: Thing.fromName(info.url).toJSON(),
    })
    return request
  }

  /**
   * Creates a project request for a schema of a Request.
   */
  static fromRequest(request: IRequest, project: HttpProject): ProjectRequest {
    const key = nanoid()
    const init: IProjectRequest = { ...request, key, kind: Kind }
    const result = new ProjectRequest(project, init)
    return result
  }

  constructor(project: HttpProject, input?: string | IProjectRequest) {
    super(input)
    this.project = project
    let init: IProjectRequest
    if (typeof input === 'string') {
      init = JSON.parse(input)
    } else if (typeof input === 'object') {
      init = input
    } else {
      const now: number = Date.now()
      init = {
        key: nanoid(),
        kind: Kind,
        created: now,
        updated: now,
        expects: {
          kind: HttpRequestKind,
          method: 'GET',
          url: '',
        },
        info: Thing.fromName('').toJSON(),
      }
    }
    this.new(init)
  }

  override new(init: IProjectRequest): void {
    super.new(init)

    const { key, environment } = init as IProjectRequest
    this.key = key || nanoid()
    if (environment) {
      this.environment = environment
    }
  }

  override toJSON(): IProjectRequest {
    const request = super.toJSON()
    const result: IProjectRequest = { ...request, key: this.key, kind: Kind }
    if (this.environment) {
      result.environment = this.environment
    }
    return result
  }

  /**
   * The callback called when the object is attached to a parent.
   * This is called when the object is created and inserted to a project or a folder
   * and when the object is moved between folders.
   */
  attachedCallback(): void {
    // ...
  }

  /**
   * The callback called when the object is detached from its parent.
   * This callback is called when the item is deleted from a folder or a project,
   * or when the item is about to be moved to another folder.
   */
  detachedCallback(): void {
    // ...
  }

  /**
   * @returns The instance of the HttpProject or a ProjectFolder that is a closes parent of this instance.
   */
  getParent(): ProjectFolder | HttpProject | undefined {
    const { project, key } = this
    return project.findParent(key)
  }

  /**
   * @returns A reference to the parent folder or the top-level HTTP project.
   */
  getProject(): HttpProject {
    return this.project
  }

  /**
   * Removes this request from the project.
   */
  remove(): void {
    this.project.removeRequest(this.key)
  }

  /**
   * Makes a copy of this request.
   * By default it attaches the copied request to the same parent.
   * Use the options dictionary to control this behavior.
   */
  clone(opts: IRequestCloneOptions = {}): ProjectRequest {
    const copy = new ProjectRequest(this.project, this.toJSON())
    if (!opts.withoutRevalidate) {
      copy.key = nanoid()
    }
    if (!opts.withoutAttach) {
      // if the parent is the project then add the request to the project.
      const parent = this.getParent()
      if (parent) {
        parent.addRequest(copy)
      }
    }
    return copy
  }

  /**
   * The static version of the `clone()` method.
   *
   * @param request The request schema to clone.
   * @param project The project to add the request to.
   * @param opts Optional options.
   * @returns The copied request.
   */
  static clone(request: IProjectRequest, project: HttpProject, opts: IRequestCloneOptions = {}): ProjectRequest {
    const obj = new ProjectRequest(project, request)
    return obj.clone(opts)
  }

  /**
   * @returns The environment that should be applied to the request, if any.
   */
  getEnvironment(): Environment | undefined {
    const { environment, project } = this
    if (!environment) {
      return undefined
    }
    let parent = this.getParent()
    if (parent === project) {
      parent = undefined
    }
    return project.findEnvironment(environment)
  }

  /**
   * @returns All environments that can be applied to this request.
   */
  readEnvironments(): Environment[] {
    let parent = this.getParent()
    if (parent === this.project) {
      parent = undefined
    }
    return this.project.readEnvironments({ parent: parent?.key })
  }

  /**
   * It reads the URL from an environment defined for the request..
   * The environment servers and variables are applies for all environments defined on each folder down to this request.
   * This respects the `encapsulate` flag defined on an environment.
   *
   * By default it applies variables collected from the environments to the url. You can configure this behavior
   * by passing a configuration option.
   *
   * Note on performance.
   * This is a heave computing operation so use it sparingly. For best performance only compute the URL when
   * environments, environment selection or variables change.
   *
   * @param opts Read URL options
   * @returns the read URL or undefined when the URL/environments are not defined.
   */
  readUrl(opts: RequestUrlReadOptions = {}): string | undefined {
    const { environment } = this
    const expects = this.getExpects()
    // note, this list respects the `encapsulate` flag.
    const environments = this.readEnvironments()

    let requestUrl = opts.baseUrl ? undefined : expects.url
    if (!environments) {
      if (requestUrl && opts.variables) {
        return this.evaluateUri(requestUrl, opts.variables)
      }
      return requestUrl || undefined
    }

    // we assume that absolute URL has a scheme with a delimiter (://). However, the scheme value can be an URL.
    // When the request starts with `/` is obviously a relative (no domain) URL
    // The edge case is `{` because it may mean a variable with a domain or a path to be appended to the base URI
    // defined in an environment. However, we choose to use `/` as the indicator of being a "relative" URL.
    const isAbsolute = !!requestUrl && !requestUrl.startsWith('/') && requestUrl.includes('://')
    if (isAbsolute && opts.noVariables) {
      // we know the URL exists, it is absolute (server wouldn't be applied anyway), and we don't
      // want to apply variables. We return what we have so far.
      return requestUrl
    }

    const flatProperties: Property[] = []
    let effectiveServer: Server | undefined
    for (const env of environments) {
      if (env.server && env.server.uri) {
        effectiveServer = env.server
      }
      if (!opts.noVariables && !opts.variables && env.variables) {
        for (const prop of env.variables) {
          // environments are listed from the project's root down to the folder where the request
          // exists. When a folder has a variable that was already defined it is replaced.
          this.addProperty(prop, flatProperties)
        }
      }
      if (env.key === environment) {
        break
      }
    }

    if (!requestUrl) {
      // we return whatever we have in the server.
      if (effectiveServer) {
        // flatProperties is be empty if `noVariables` option is passed.
        const uri = effectiveServer.readUri()
        if (opts.variables) {
          return this.evaluateUri(uri, opts.variables)
        }
        return this.evaluateUri(uri, flatProperties)
      }
      return undefined
    }

    if (isAbsolute) {
      // ignore server reading and just apply variables to the request URL
      if (opts.variables) {
        return this.evaluateUri(requestUrl, opts.variables)
      }
      return this.evaluateUri(requestUrl, flatProperties)
    }

    let serverUrl = ''
    if (effectiveServer) {
      serverUrl = effectiveServer.readUri()
    }
    if (!serverUrl.endsWith('/')) {
      serverUrl += '/'
    }
    if (requestUrl.startsWith('/')) {
      requestUrl = requestUrl.substring(1)
    }
    const url = serverUrl + requestUrl
    if (opts.variables) {
      return this.evaluateUri(url, opts.variables)
    }
    return this.evaluateUri(url, flatProperties)
  }

  protected addProperty(item: Property, list: Property[]): void {
    const index = list.findIndex((i) => i.name === item.name)
    if (index >= 0) {
      list.splice(index, 1)
    }
    list.push(item)
  }

  /**
   * Evaluates the URI against variables.
   *
   * Note, this doesn't throw errors. When error occurs it returns the input string.
   *
   * @param uri The URI to process
   * @param variables The list of variables to use
   * @returns Expanded URI.
   */
  protected evaluateUri(uri: string, variables?: Property[] | Record<string, string>): string {
    if (!variables) {
      return uri
    }

    let vars: Record<string, string> | undefined
    if (Array.isArray(variables)) {
      if (variables.length) {
        vars = Property.toMap(variables) as Record<string, string>
      }
    } else {
      vars = variables
    }
    if (!vars) {
      return uri
    }
    let result = uri
    try {
      result = new UriTemplate(uri).expand(vars, { ignoreMissing: true })
    } catch {
      //
    }
    return result
  }

  /**
   * Reads the authorization that should be applied to the request.
   *
   * 1) First it returns the locally (on the request) defined authorization.
   * 2) Then it returns authorization defined on the selected environment.
   * 3) If has defined environment but no authorization on it, then it returns empty array.
   * 4) Only if the request has no selected environment then it reads first environment
   * that has authorization (from the request up to the project's root).
   */
  readAuthorization(): RequestAuthorization[] {
    if (Array.isArray(this.authorization) && this.authorization.length) {
      return this.authorization
    }
    const envAuth = this.readEnvironmentAuthorization()
    if (envAuth) {
      return envAuth
    }
    if (this.environment) {
      return []
    }
    const inherited = this.readEnvironmentsAuthorization()
    if (inherited) {
      return inherited
    }
    return []
  }

  /**
   * @returns Authorization defined on the selected environment or undefined.
   */
  protected readEnvironmentAuthorization(): RequestAuthorization[] | undefined {
    const selfEnv = this.getEnvironment()
    if (!selfEnv) {
      return undefined
    }
    if (Array.isArray(selfEnv.authorization) && selfEnv.authorization.length) {
      return selfEnv.authorization
    }
    return undefined
  }

  /**
   * @returns Authorization defined on any inherited environment or undefined.
   */
  protected readEnvironmentsAuthorization(): RequestAuthorization[] | undefined {
    const envs = this.readEnvironments()
    const authEnv = envs.find((i) => Array.isArray(i.authorization) && !!i.authorization.length)
    if (authEnv) {
      return authEnv.authorization as RequestAuthorization[]
    }
    return undefined
  }
}
