/*
 * Copyright 2021 - 2023 Sporta Technologies PVT LTD & the ZIO HTTP contributors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package zio.http.netty

import scala.annotation.tailrec

import zio.Chunk.ByteArray
import zio._
import zio.stacktracer.TracingImplicits.disableAutoTrace

import zio.stream.ZStream

import zio.http.Body
import zio.http.Body._
import zio.http.netty.NettyBody.{AsciiStringBody, AsyncBody, UnsafeAsync}

import io.netty.buffer.Unpooled
import io.netty.channel._
import io.netty.handler.codec.http.{DefaultHttpContent, LastHttpContent}

private[netty] object NettyBodyWriter {

  @tailrec
  def writeAndFlush(
    body: Body,
    contentLength: Option[Long],
    ctx: ChannelHandlerContext,
  )(implicit
    trace: Trace,
  ): Option[Task[Unit]] = {

    def writeArray(body: Array[Byte], isLast: Boolean) = {
      val content = new DefaultHttpContent(Unpooled.wrappedBuffer(body))
      if (isLast) {
        // Flushes the last body content and LastHttpContent together to avoid race conditions.
        ctx.write(content)
        ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)
      } else {
        ctx.writeAndFlush(content)
      }
    }

    body match {
      case body: FileBody                  =>
        // We need to stream the file when compression is enabled otherwise the response encoding fails
        // Use body.asStream to respect offset/length for Range requests
        // Pass None for content length to use chunked transfer encoding - the actual Content-Length
        // header is already set by NettyResponseEncoder from body.knownContentLength before this runs.
        // Passing content length here would interfere with response compression which changes the size.
        val stream = body.asStream
        val s      = StreamBody(stream, None, contentType = body.contentType)
        NettyBodyWriter.writeAndFlush(s, None, ctx)
      case AsyncBody(async, _, _, _)       =>
        async(
          new UnsafeAsync {
            override def apply(message: Chunk[Byte], isLast: Boolean): Unit = {
              val arr = message match {
                case b: ByteArray => b.array
                case other        => other.toArray
              }
              writeArray(arr, isLast): Unit
            }

            override def fail(cause: Throwable): Unit =
              ctx.fireExceptionCaught(cause): Unit
          },
        )
        None
      case AsciiStringBody(asciiString, _) =>
        writeArray(asciiString.array(), isLast = true)
        None
      case sb: StringBody                  =>
        writeArray(sb.unsafeAsArray(Unsafe.unsafe), isLast = true)
        None
      case StreamBody(stream, _, _)        =>
        Some(
          // Use the contentLength parameter directly - it comes from headers set by the encoder.
          // Don't fall back to body.knownContentLength as compression may change the actual size.
          contentLength match {
            case Some(length) =>
              stream.chunks
                .runFoldZIO(length) { (remaining, bytes) =>
                  remaining - bytes.size match {
                    case 0L =>
                      NettyFutureExecutor.executed {
                        writeArray(bytes.toArray, isLast = true)
                      }.as(0L)

                    case n =>
                      NettyFutureExecutor.executed {
                        writeArray(bytes.toArray, isLast = false)
                      }.as(n)
                  }
                }
                .flatMap {
                  case 0L        =>
                    ZIO.unit
                  case remaining =>
                    val actualLength = length - remaining
                    ZIO.logWarning(s"Expected Content-Length of $length, but sent $actualLength bytes") *>
                      NettyFutureExecutor.executed {
                        ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)
                      }
                }

            case None =>
              stream.chunks.mapZIO { bytes =>
                NettyFutureExecutor.executed {
                  writeArray(bytes.toArray, isLast = false)
                }
              }.runDrain.zipRight {
                NettyFutureExecutor.executed {
                  ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)
                }
              }
          },
        )
      case ArrayBody(data, _)              =>
        writeArray(data, isLast = true)
        None
      case ChunkBody(data, _)              =>
        writeArray(data.toArray, isLast = true)
        None
      case EmptyBody | ErrorBody(_)        =>
        ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)
        None
    }
  }
}
