package zio.http.datastar.model

import scala.language.implicitConversions

import zio._
import zio.json._
import zio.json.ast.Json

import zio.schema._
import zio.schema.annotation.fieldName
import zio.schema.codec.JsonCodec.jsonCodec

import zio.http.URL.Location
import zio.http.URL.Location.Relative
import zio.http._
import zio.http.internal.{HeaderOps, QueryOps, QueryParamEncoding, ThreadLocals}
import zio.http.template2.{CssSelector, Js}

final case class DatastarRequest(
  method: Method,
  url: URL,
  options: DatastarRequestOptions,
) extends QueryOps[DatastarRequest]
    with HeaderOps[DatastarRequest] {

  def baseUrl(url: URL): DatastarRequest = this.copy(url = this.url.copy(kind = url.kind))

  def renderUrl: String = DatastarRequest.encodeUrl(url)

  def render: String =
    if (options == DatastarRequestOptions.default) s"""@${method.name.toLowerCase}('$renderUrl')"""
    else s"""@${method.name.toLowerCase}('$renderUrl', ${options.toJson})"""

  /**
   * Updates the current query parameters with new one, using the provided
   * update function passed.
   */
  override def updateQueryParams(f: QueryParams => QueryParams): DatastarRequest =
    this.copy(url = url.updateQueryParams(f))

  /**
   * Updates the current Headers with new one, using the provided update
   * function passed.
   */
  override def updateHeaders(update: Headers => Headers)(implicit trace: Trace): DatastarRequest =
    this.copy(options = options.updateHeaders(update))

  /**
   * Returns the headers
   */
  override def headers: Headers = options.headers

  override def queryParameters: QueryParams = url.queryParams
}

object DatastarRequest {

  implicit def toJsExpr(request: DatastarRequest): Js =
    Js(request.render)

  private def encodePath(path: Path): java.lang.StringBuilder = {
    val sb      = ThreadLocals.stringBuilder
    if (path.hasLeadingSlash) sb.append('/')
    var idx     = 0
    val lastIdx = path.segments.length - 1
    while (idx <= lastIdx) {
      val segment = path.segments(idx)
      if (segment.nonEmpty && segment.head == '$') sb.append(segment)
      else QueryParamEncoding.encodeComponentInto(path.segments(idx), Charsets.Http, sb, "%20", isPath = true)
      if (path.hasTrailingSlash || idx != lastIdx) sb.append('/')
      idx += 1
    }
    sb
  }

  private def encodeUrl(url: URL): String = {
    val path =
      QueryParamEncoding.encode(
        encodePath(url.path),
        url.queryParams,
        Charsets.Http,
      )

    def fromAbsURL(abs: Location.Absolute, path: String) = {
      // Do not use `abs.portIfNotDefault` here. Setting a port in the URL that is the default
      // is an edge case. But checking it allocates an `Option` that is not needed in most cases.
      abs.originalPort match {
        case None =>
          val sb = ThreadLocals.stringBuilder.append(abs.scheme.encode).append("://").append(abs.host)
          if (path.nonEmpty && path.head != '/') sb.append('/')
          sb
        case port =>
          val sb = ThreadLocals.stringBuilder
            .append(abs.scheme.encode)
            .append("://")
            .append(abs.host)
            .append(':')
            .append(port.get)
          if (path.nonEmpty && path.head != '/') sb.append('/')
          sb
      }
    }

    url.kind match {
      case Location.Relative if url.fragment.isEmpty      =>
        path
      case Relative                                       =>
        ThreadLocals.stringBuilder.append(path).append('#').append(url.fragment.get.raw).toString
      case abs: Location.Absolute if url.fragment.isEmpty =>
        fromAbsURL(abs, path).append(path).toString
      case abs: Location.Absolute                         =>
        fromAbsURL(abs, path).append(path).append('#').append(url.fragment.get.raw).toString

    }
  }

  def apply(method: Method, url: URL): DatastarRequest =
    DatastarRequest(method, url, DatastarRequestOptions.default)
}

final case class DatastarRequestOptions(
  contentType: MediaType = MediaType.application.json,
  filterSignals: Option[DatastarSignalFilter] = None,
  selector: Option[CssSelector] = None,
  @fieldName("headers")
  hdrs: Headers = Headers.empty,
  openWhenHidden: Boolean = false,
  retryInterval: Int = 1000,
  retryScaler: Int = 2,
  retryMaxWaitMs: Int = 30000,
  retryMaxCount: Int = 10,
  requestCancellation: DatastarRequestCancellation = DatastarRequestCancellation.Auto,
  retry: Option[DatastarRetry] = None,
  payload: Option[Js] = None,
  cleanup: Option[Js] = None,
) extends HeaderOps[DatastarRequestOptions] {

  /**
   * Returns the headers
   */
  override def headers: Headers = hdrs

  /**
   * Updates the current Headers with new one, using the provided update
   * function passed.
   */
  override def updateHeaders(update: Headers => Headers)(implicit trace: Trace): DatastarRequestOptions =
    this.copy(hdrs = update(this.headers))
}

object DatastarRequestOptions {
  val default: DatastarRequestOptions = DatastarRequestOptions()

  implicit val headersSchema: Schema[Headers] =
    Schema[Map[String, String]].transform[Headers](
      map => Headers.fromIterable(map.map { case (k, v) => Header.Custom(k, v) }),
      headers => headers.map(h => h.headerName -> h.renderedValue).toMap,
    )

  implicit val jsSchema: Schema[Js] = Schema[String].transform[Js](
    Js(_),
    _.value,
  )

  implicit val schema: Schema[DatastarRequestOptions] = DeriveSchema.gen

  private val fullCodec: JsonCodec[DatastarRequestOptions] = jsonCodec(schema)

  private lazy val defaultFields: Map[String, Json] =
    fullCodec.encoder.toJsonAST(default) match {
      case Right(Json.Obj(fields)) => fields.toMap
      case _                       => Map.empty
    }

  implicit val jsonEncoder: JsonEncoder[DatastarRequestOptions] =
    JsonEncoder[Json].contramap[DatastarRequestOptions] { a =>
      fullCodec.encoder.toJsonAST(a) match {
        case Right(Json.Obj(fields)) =>
          Json.Obj(Chunk.fromIterable(fields.filter { case (k, v) => !defaultFields.get(k).contains(v) }))
        case Right(other)            => other
        case Left(_)                 => Json.Obj()
      }
    }

  implicit val jsonDecoder: JsonDecoder[DatastarRequestOptions] = fullCodec.decoder

  implicit val json: JsonCodec[DatastarRequestOptions] = JsonCodec(jsonEncoder, jsonDecoder)
}

final case class DatastarSignalFilter(
  include: String,
  exclude: Option[String] = None,
)

object DatastarSignalFilter {
  implicit val schema: Schema[DatastarSignalFilter] = DeriveSchema.gen
}

sealed trait DatastarRequestCancellation
object DatastarRequestCancellation {
  case object Auto             extends DatastarRequestCancellation
  case object Disabled         extends DatastarRequestCancellation
  case class Custom(value: Js) extends DatastarRequestCancellation

  implicit val schema: Schema[DatastarRequestCancellation] = Schema[String].transform[DatastarRequestCancellation](
    {
      case "auto"     => Auto
      case "disabled" => Disabled
      case other      => Custom(Js(other))
    },
    {
      case Auto          => "auto"
      case Disabled      => "disabled"
      case Custom(value) => value.value
    },
  )
}

sealed trait DatastarRetry
object DatastarRetry {
  case object Auto   extends DatastarRetry
  case object Error  extends DatastarRetry
  case object Always extends DatastarRetry
  case object Never  extends DatastarRetry

  implicit val schema: Schema[DatastarRetry] = Schema[String].transformOrFail[DatastarRetry](
    {
      case "auto"   => Right(Auto)
      case "error"  => Right(Error)
      case "always" => Right(Always)
      case "never"  => Right(Never)
      case other    => Left(s"Invalid DatastarRetry value: '$other'. Expected 'auto', 'error', 'always', or 'never'.")
    },
    {
      case Auto   => Right("auto")
      case Error  => Right("error")
      case Always => Right("always")
      case Never  => Right("never")
    },
  )
}
