import { Config, MultipartError, PartInfo } from "../index.js"
import * as CT from "./contentType.js"
import * as HP from "./headers.js"
import * as Search from "./search.js"

const enum State {
  headers,
  body,
}

const errInvalidDisposition: MultipartError = { _tag: "InvalidDisposition" }
const errEndNotReached: MultipartError = { _tag: "EndNotReached" }
const errMaxParts: MultipartError = { _tag: "ReachedLimit", limit: "MaxParts" }
const errMaxTotalSize: MultipartError = {
  _tag: "ReachedLimit",
  limit: "MaxTotalSize",
}
const errMaxPartSize: MultipartError = {
  _tag: "ReachedLimit",
  limit: "MaxPartSize",
}
const errMaxFieldSize: MultipartError = {
  _tag: "ReachedLimit",
  limit: "MaxFieldSize",
}

const constCR = new TextEncoder().encode("\r\n")

export function defaultIsFile(info: PartInfo) {
  return (
    info.filename !== undefined ||
    info.contentType === "application/octet-stream"
  )
}

function parseBoundary(headers: Record<string, string>) {
  const contentType = CT.parse(headers["content-type"])
  return contentType.parameters.boundary
}

function noopOnChunk(_chunk: Uint8Array | null) {}

export function make({
  headers,
  onFile: onPart,
  onField,
  onError,
  onDone,
  isFile = defaultIsFile,
  maxParts = Infinity,
  maxTotalSize = Infinity,
  maxPartSize = Infinity,
  maxFieldSize = 1024 * 1024,
}: Config) {
  const boundary = parseBoundary(headers)
  if (boundary === undefined) {
    onError({ _tag: "InvalidBoundary" })
    return {
      write: noopOnChunk,
      end() {},
    }
  }

  const state = {
    state: State.headers,
    index: 0,
    parts: 0,
    onChunk: noopOnChunk,
    info: undefined as any as PartInfo,
    headerSkip: 0,
    partSize: 0,
    totalSize: 0,
    isFile: false,
    fieldChunks: [] as Array<Uint8Array>,
    fieldSize: 0,
  }

  function skipBody() {
    state.state = State.body
    state.isFile = true
    state.onChunk = noopOnChunk
  }

  const headerParser = HP.make()

  const split = Search.make(
    `\r\n--${boundary}`,
    function (index, chunk) {
      if (index === 0) {
        // data before the first boundary
        skipBody()
        return
      } else if (index !== state.index) {
        if (state.index > 0) {
          if (state.isFile) {
            state.onChunk(null)
            state.partSize = 0
          } else {
            if (state.fieldChunks.length === 1) {
              onField(state.info, state.fieldChunks[0])
            } else {
              const buf = new Uint8Array(state.fieldSize)
              let offset = 0
              for (let i = 0; i < state.fieldChunks.length; i++) {
                const chunk = state.fieldChunks[i]
                buf.set(chunk, offset)
                offset += chunk.length
              }
              onField(state.info, buf)
            }
            state.fieldSize = 0
            state.fieldChunks = []
          }
        }

        state.state = State.headers
        state.index = index
        state.headerSkip = 2 // skip the first \r\n

        // trailing --
        if (chunk[0] === 45 && chunk[1] === 45) {
          return onDone()
        }

        state.parts++
        if (state.parts > maxParts) {
          onError(errMaxParts)
        }
      }

      if ((state.partSize += chunk.length) > maxPartSize) {
        onError(errMaxPartSize)
      }

      if (state.state === State.headers) {
        const result = headerParser(chunk, state.headerSkip)
        state.headerSkip = 0

        if (result._tag === "Continue") {
          return
        } else if (result._tag === "Failure") {
          skipBody()
          return onError({ _tag: "BadHeaders", error: result })
        }

        const contentType = CT.parse(result.headers["content-type"] as string)
        const contentDisposition = CT.parse(
          result.headers["content-disposition"] as string,
          true,
        )

        if (
          "form-data" === contentDisposition.value &&
          !("name" in contentDisposition.parameters)
        ) {
          skipBody()
          return onError(errInvalidDisposition)
        }

        let encodedFilename: string | undefined
        if ("filename*" in contentDisposition.parameters) {
          const parts = contentDisposition.parameters["filename*"].split("''")
          if (parts.length === 2) {
            encodedFilename = decodeURIComponent(parts[1])
          }
        }

        state.info = {
          name: contentDisposition.parameters.name ?? "",
          filename: encodedFilename ?? contentDisposition.parameters.filename,
          contentType:
            contentType.value === ""
              ? contentDisposition.parameters.filename !== undefined
                ? "application/octet-stream"
                : "text/plain"
              : contentType.value,
          contentTypeParameters: contentType.parameters,
          contentDisposition: contentDisposition.value,
          contentDispositionParameters: contentDisposition.parameters as any,
          headers: result.headers,
        }

        state.state = State.body
        state.isFile = isFile(state.info)

        if (state.isFile) {
          state.onChunk = onPart(state.info)
        }

        if (result.endPosition < chunk.length) {
          if (state.isFile) {
            state.onChunk(chunk.subarray(result.endPosition))
          } else {
            const buf = chunk.subarray(result.endPosition)
            if ((state.fieldSize += buf.length) > maxFieldSize) {
              onError(errMaxFieldSize)
            }
            state.fieldChunks.push(buf)
          }
        }
      } else if (state.isFile) {
        state.onChunk(chunk)
      } else {
        if ((state.fieldSize += chunk.length) > maxFieldSize) {
          onError(errMaxFieldSize)
        }
        state.fieldChunks.push(chunk)
      }
    },
    constCR,
  )

  return {
    write(chunk: Uint8Array) {
      if ((state.totalSize += chunk.length) > maxTotalSize) {
        return onError(errMaxTotalSize)
      }
      return split.write(chunk)
    },
    end() {
      split.end()
      if (state.state === State.body) {
        onError(errEndNotReached)
      }

      state.state = State.headers
      state.index = 0
      state.parts = 0
      state.onChunk = noopOnChunk
      state.info = undefined as any as PartInfo
      state.totalSize = 0
      state.partSize = 0
      state.fieldChunks = []
      state.fieldSize = 0
    },
  } as const
}

const utf8Decoder = new TextDecoder("utf-8")
function getDecoder(charset: string) {
  if (charset === "utf-8" || charset === "utf8" || charset === "") {
    return utf8Decoder
  }

  try {
    return new TextDecoder(charset)
  } catch (error) {
    return utf8Decoder
  }
}

export function decodeField(info: PartInfo, value: Uint8Array): string {
  return getDecoder(info.contentTypeParameters.charset ?? "utf-8").decode(value)
}
