/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * license agreements; and to You under the Apache License, version 2.0:
 *
 *   https://www.apache.org/licenses/LICENSE-2.0
 *
 * This file is part of the Apache Pekko project, which was derived from Akka.
 */

/*
 * Copyright (C) since 2016 Lightbend Inc. <https://www.lightbend.com>
 */

package docs.scaladsl

import java.io._
import java.nio.file.{ Files, Path, Paths }
import java.time.Instant
import java.util.Comparator
import org.apache.pekko
import pekko.{ Done, NotUsed }
import pekko.actor.ActorSystem
import pekko.stream.connectors.file.scaladsl.{ Archive, Directory }
import pekko.stream.connectors.file.{ TarArchiveMetadata, TarReaderException }
import pekko.stream.connectors.testkit.scaladsl.LogCapturing
import pekko.stream.scaladsl.{ FileIO, Flow, Sink, Source }
import pekko.testkit.TestKit
import pekko.util.ByteString
import org.scalatest.BeforeAndAfterAll
import org.scalatest.concurrent.{ Eventually, IntegrationPatience, ScalaFutures }
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike

import scala.annotation.nowarn
import scala.collection.immutable
import scala.concurrent.{ ExecutionContext, Future }
import scala.jdk.CollectionConverters._

class TarArchiveSpec
    extends TestKit(ActorSystem("TarArchiveSpec"))
    with AnyWordSpecLike
    with Matchers
    with ScalaFutures
    with BeforeAndAfterAll
    with LogCapturing
    with Eventually
    with IntegrationPatience {

  implicit val ec: ExecutionContext = system.dispatcher

  private val collectByteString: Sink[ByteString, Future[ByteString]] = Sink.fold(ByteString.empty)(_ ++ _)

  "tar writer" should {
    "pass empty stream" in {
      val sources = Source.empty
      val tarFlow = Archive.tar()

      val pekkoTarred: Future[ByteString] =
        sources
          .via(tarFlow)
          .runWith(collectByteString)

      pekkoTarred.futureValue shouldBe ByteString.empty
    }

    "archive file" in {
      val filePath1 = getPathFromResources("pekko.svg")
      val filePath2 = getPathFromResources("foundation.svg")
      val fileStream1: Source[ByteString, Any] = FileIO.fromPath(filePath1)
      val fileStream2: Source[ByteString, Any] = FileIO.fromPath(filePath2)
      val fileContent1 = ByteString(Files.readAllBytes(filePath1))
      val fileContent2 = ByteString(Files.readAllBytes(filePath2))
      val fileSize1 = Files.size(filePath1)
      val fileSize2 = Files.size(filePath2)

      /*
        // #sample-tar
        val fileStream1: Source[ByteString,  Any] = ...
        val fileStream2: Source[ByteString,  Any] = ...
        val fileSize1: Long = ...
        val fileSize2: Long = ...

        // #sample-tar
       */

      val lastModification = Instant.now
      // #sample-tar
      val filesStream = Source(
        List(
          (TarArchiveMetadata.directory("subdir", lastModification), Source.empty),
          (TarArchiveMetadata("subdir", "pekko.svg", fileSize1, lastModification), fileStream1),
          (TarArchiveMetadata("foundation.svg", fileSize2, lastModification), fileStream2)))

      val result = filesStream
        .via(Archive.tar())
        .runWith(FileIO.toPath(Paths.get("result.tar")))
      // #sample-tar

      // #sample-tar-gz
      val resultGz = filesStream
        .via(Archive.tar().via(org.apache.pekko.stream.scaladsl.Compression.gzip))
        .runWith(FileIO.toPath(Paths.get("result.tar.gz")))
      // #sample-tar-gz

      result.futureValue.count shouldBe 14848
      resultGz.futureValue.count should not be 4096

      untar(Paths.get("result.tar").toRealPath(), "xf").foreach(
        _ shouldBe Map(
          "subdir/pekko.svg" -> fileContent1,
          "foundation.svg" -> fileContent2))
      untar(Paths.get("result.tar.gz").toRealPath(), "xfz").foreach(
        _ shouldBe Map(
          "subdir/pekko.svg" -> fileContent1,
          "foundation.svg" -> fileContent2))

      // cleanup
      new File("result.tar").delete()
      new File("result.tar.gz").delete()
    }

    "support long file paths" in {
      val fileName = "folder1-".padTo(40, 'x') + "/" + "folder2-".padTo(40, 'x') + "/" + "file-".padTo(80, 'x') + ".txt"
      val fileBytes = ByteString("test")

      val file = (TarArchiveMetadata(fileName, fileBytes.size), Source.single(fileBytes))
      val filesStream = Source.single(file)
      val result = filesStream
        .via(Archive.tar())
        .runWith(FileIO.toPath(Paths.get("result.tar")))
      result.futureValue.count shouldBe 1024

      untar(Paths.get("result.tar").toRealPath(), "xf").foreach(_ shouldBe Map(fileName -> fileBytes))

      // cleanup
      new File("result.tar").delete()
    }

    "fail if provided size and actual size do not match" in {
      val file = (TarArchiveMetadata("file.txt", 10L), Source.single(ByteString("too long")))
      val filesStream = Source.single(file)
      val result = filesStream.via(Archive.tar()).runWith(Sink.ignore)
      an[IllegalStateException] should be thrownBy (throw result.failed.futureValue)
    }
  }

  "tar reader" should {
    val tenDigits = ByteString("1234567890")
    val metadata1 = TarArchiveMetadata("dir/file1.txt", tenDigits.length)

    val oneFileArchive = {
      Source
        .single(metadata1 -> Source.single(tenDigits))
        .via(Archive.tar())
        .runWith(collectByteString)
    }

    "emit one file" in {
      val tar =
        Source
          .future(oneFileArchive)
          .via(Archive.tarReader())
          .mapAsync(1) {
            case in @ (metadata, source) =>
              source.runWith(collectByteString).map { bs =>
                metadata -> bs
              }
          }
          .runWith(Sink.head)
      val result = tar.futureValue
      result shouldBe metadata1 -> tenDigits
    }

    "document its use" in {
      // #tar-reader
      val bytesSource: Source[ByteString, NotUsed] = // ???
        // #tar-reader
        Source.future(oneFileArchive)
      val target = Files.createTempDirectory("pekko-connectors-tar-")

      // #tar-reader
      val tar =
        bytesSource
          .via(Archive.tarReader())
          .mapAsync(1) {
            case (metadata, source) =>
              val targetFile = target.resolve(metadata.filePath)
              if (metadata.isDirectory) {
                Source
                  .single(targetFile)
                  .via(Directory.mkdirs())
                  .runWith(Sink.ignore)
              } else {
                // create the target directory
                Source
                  .single(targetFile.getParent)
                  .via(Directory.mkdirs())
                  .runWith(Sink.ignore)
                  .map { _ =>
                    // stream the file contents to a local file
                    source.runWith(FileIO.toPath(targetFile))
                  }
              }
          }
          .runWith(Sink.ignore)
      // #tar-reader
      tar.futureValue shouldBe Done
      val file: File = target.resolve("dir/file1.txt").toFile
      eventually {
        file.exists() shouldBe true
      }
    }

    "emit empty file" in {
      val metadataEmpty = TarArchiveMetadata("dir/empty.txt", 0L)
      val tar =
        Source
          .single(metadataEmpty -> Source.single(ByteString.empty))
          .via(Archive.tar())
          .via(Archive.tarReader())
          .mapAsync(1) {
            case (metadata, source) =>
              source.runWith(collectByteString).map { bs =>
                metadata -> bs
              }
          }
          .runWith(Sink.head)
      val result = tar.futureValue
      result shouldBe metadataEmpty -> ByteString.empty
    }

    "emit two files" in {
      val Seq((name1, file1), (name2, file2)) = generateInputFiles(2, 400): @nowarn("msg=match may not be exhaustive")

      val metadata1 = TarArchiveMetadata(name1, file1.length)
      val metadata2 = TarArchiveMetadata(name2, file2.length)
      val tar = Source(
        immutable.Seq(
          metadata1 -> Source.single(file1),
          metadata2 -> Source.single(file2))).via(Archive.tar())
        // emit in short byte strings
        .mapConcat(_.sliding(2, 2).toList)
        .via(Archive.tarReader())
        .mapAsync(1) {
          case (metadata, source) =>
            source.runWith(collectByteString).map { bs =>
              metadata -> bs
            }
        }
        .runWith(Sink.seq)
      val result = tar.futureValue
      result(0) shouldBe metadata1 -> file1
      result(1) shouldBe metadata2 -> file2
    }

    "emit empty and another file" in {
      val metadata1 = TarArchiveMetadata("empty.txt", 0)
      val metadata2 = TarArchiveMetadata("file2.txt", tenDigits.length)
      val tarFile = Source(
        immutable.Seq(
          metadata1 -> Source.single(ByteString.empty),
          metadata2 -> Source.single(tenDigits))).via(Archive.tar())
        // swallow the whole input as one ByteString
        .runWith(collectByteString)

      val tar = Source
        .future(tarFile)
        .via(Archive.tarReader())
        .mapAsync(1) {
          case (metadata, source) =>
            source.runWith(collectByteString).map { bs =>
              metadata -> bs
            }
        }
        .runWith(Sink.seq)
      val result = tar.futureValue
      result(0) shouldBe metadata1 -> ByteString.empty
      result(1) shouldBe metadata2 -> tenDigits
    }

    "fail for incomplete header" in {
      val input = oneFileArchive.map(bs => bs.take(500))
      val tar = Source
        .future(input)
        .via(Archive.tarReader())
        .runWith(Sink.ignore)
      val error = tar.failed.futureValue
      error shouldBe a[TarReaderException]
      error.getMessage shouldBe "incomplete tar header: received 500 bytes, expected 512 bytes"
    }

    "fail for incomplete file" in {
      val input = oneFileArchive.map(bs => bs.take(518))
      val tar = Source
        .future(input)
        .via(Archive.tarReader())
        .mapAsync(1) {
          case (metadata, source) =>
            source.runWith(Sink.ignore)
        }
        .runWith(Sink.ignore)
      val error = tar.failed.futureValue
      error shouldBe a[TarReaderException]
      error.getMessage shouldBe "incomplete tar file contents for [dir/file1.txt] expected 10 bytes, received 6 bytes"
    }

    "fail for incomplete trailer" in {
      val input = oneFileArchive.map(bs => bs.take(535))
      val tar = Source
        .future(input)
        .via(Archive.tarReader())
        .mapAsync(1) {
          case (metadata, source) =>
            source.runWith(Sink.ignore)
        }
        .runWith(Sink.ignore)
      val error = tar.failed.futureValue
      error shouldBe a[TarReaderException]
      error.getMessage shouldBe "incomplete tar file trailer for [dir/file1.txt] expected 502 bytes, received 13 bytes"
    }

    "accept empty input" in {
      val tar = Source
        .single(ByteString.empty)
        .via(Archive.tarReader())
        .runWith(Sink.seq)
      tar.futureValue shouldBe empty
    }

    "extract all files when upstream closes with buffered entries (bug 1407)" in {
      // Bug 1407: TarReaderStage was completing prematurely when the upstream closed
      // while the buffer still contained unprocessed file headers/data.
      // This test simulates the scenario where:
      // 1. A multi-file TAR archive is streamed as a single ByteString
      // 2. The upstream closes immediately after sending all data
      // 3. The buffer contains multiple file entries to process

      val file1Content = ByteString("content1")
      val file2Content = ByteString("content2")
      val file3Content = ByteString("content3")

      val metadata1 = TarArchiveMetadata("file1.txt", file1Content.length)
      val metadata2 = TarArchiveMetadata("file2.txt", file2Content.length)
      val metadata3 = TarArchiveMetadata("file3.txt", file3Content.length)

      // Create a TAR archive with 3 files, emitted as a single ByteString
      // This simulates an S3 stream that closes after sending all data at once
      val multiFileArchive = Source(
        immutable.Seq(
          metadata1 -> Source.single(file1Content),
          metadata2 -> Source.single(file2Content),
          metadata3 -> Source.single(file3Content)
        ))
        .via(Archive.tar())
        .runWith(collectByteString)

      // Process the archive - the upstream will close after the single ByteString
      // Without the fix, only the first file(s) would be extracted
      val tar = Source
        .future(multiFileArchive)
        .via(Archive.tarReader())
        .mapAsync(1) {
          case (metadata, source) =>
            source.runWith(collectByteString).map { bs =>
              metadata -> bs
            }
        }
        .runWith(Sink.seq)

      val result = tar.futureValue

      // All 3 files should be extracted, not just the first one
      (result should have).length(3)
      result(0) shouldBe metadata1 -> file1Content
      result(1) shouldBe metadata2 -> file2Content
      result(2) shouldBe metadata3 -> file3Content
    }

    "fail on missing sub source subscription" in {
      val tar =
        Source
          .future(oneFileArchive)
          .mapConcat(_.sliding(2, 2).toList)
          .via(Archive.tarReader())
          .runWith(Sink.ignore)
      val error = tar.failed.futureValue
      error shouldBe a[TarReaderException]
      error.getMessage shouldBe
      "The tar content source was not subscribed to within 5000 milliseconds, it must be subscribed to to progress tar file reading."
    }
  }

  "tar reader regression" should {
    // Regression test for: Cannot push port (SubSourceOutlet(fileOut)) twice, or before it being pulled
    //
    // Root cause: in CollectFile, onPush() called subPush() unconditionally without checking
    // whether the subSource outlet was ready to accept a push. When upstream data arrived
    // while subSource had not yet been pulled by downstream, the push violated Pekko's
    // back-pressure protocol and crashed.
    //
    // Fix: onPush() now checks subSource.isAvailable. If false, data is buffered and flushed
    // in subSource's onPull() handler once downstream is ready.
    //
    // The crash depends on interpreter-internal timing (whether flowOut is available at the
    // exact moment ReadPastTrailer transitions to the next file), which cannot be controlled
    // deterministically from a unit test. This test instead verifies correct functional
    // behaviour when a two-file archive is delivered in small chunks, exercising all
    // transitions between CollectHeader, CollectFile, and ReadPastTrailer across multiple
    // upstream pushes — the scenario that triggered the crash in production.
    "handle chunked delivery of a two-file archive" in {
      val content1 = ByteString("first file content in tar")
      val content2 = ByteString("second file content in tar")

      val archiveBytes: ByteString =
        Source(List(
          TarArchiveMetadata("file1.txt", content1.length) -> Source.single(content1),
          TarArchiveMetadata("file2.txt", content2.length) -> Source.single(content2)))
          .via(Archive.tar())
          .runWith(collectByteString)
          .futureValue

      // Deliver the archive in 32-byte chunks to exercise all partial-read code paths.
      val result = Source(archiveBytes.grouped(32).toList)
        .via(Archive.tarReader())
        .mapAsync(1) { case (_, subSource: Source[ByteString, NotUsed]) =>
          subSource.runWith(collectByteString)
        }
        .runWith(Sink.seq)

      result.futureValue shouldBe Seq(content1, content2)
    }
  }

  "advanced tar reading" should {
    "allow tar files in tar files to be extracted in a single flow" in {
      val tenDigits = ByteString("1234567890")
      val metadata1 = TarArchiveMetadata("dir/file1.txt", tenDigits.length)

      val nestedArchive = {
        Source
          .single(metadata1 -> Source.single(tenDigits))
          .via(Archive.tar())
          .runWith(collectByteString)
      }
      val outerArchive: Future[ByteString] =
        Source
          .future(nestedArchive)
          .map(bs => TarArchiveMetadata("nested.tar", bs.size) -> Source.single(bs))
          .via(Archive.tar())
          .runWith(collectByteString)

      val res = Source
        .future(outerArchive)
        .mapConcat(_.sliding(100, 100).toList)
        .via(untar())
        .map(_.filePathName)
        .runWith(Sink.seq)

      res.futureValue shouldBe Seq("nested.tar", "file1.txt")
    }

    def untar(): Flow[ByteString, TarArchiveMetadata, NotUsed] = {
      Archive
        .tarReader()
        .log("untar")
        .mapAsync(1) {
          case (metadata, source) if metadata.filePath.endsWith(".tar") =>
            val contents: Source[TarArchiveMetadata, NotUsed] = Source.single(metadata).concat(source.via(untar()))
            Future.successful(contents)
          case (metadata, source) =>
            source
              .runWith(Sink.ignore)
              .map { _ =>
                Source.single(metadata)
              }
        }
        .flatMapConcat(identity)
        .log("untarred")
    }
  }

  private def getPathFromResources(fileName: String): Path =
    Paths.get(getClass.getClassLoader.getResource(fileName).toURI)

  private def generateInputFiles(numberOfFiles: Int, lengthOfFile: Int): immutable.Seq[(String, ByteString)] = {
    val r = new scala.util.Random(31)
    (1 to numberOfFiles)
      .map(number => s"file-$number" -> ByteString.fromArray(r.nextString(lengthOfFile).getBytes))
  }

  // returns None if tar executable is not available on PATH
  private def untar(tarPath: Path, args: String): Option[Map[String, ByteString]] = {
    if (ExecutableUtils.isOnPath("tar")) {
      val tmpDir = Files.createTempDirectory("ArchiveSpec")
      try {
        ExecutableUtils.run("tar", Seq(args, tarPath.toString, "-C", tmpDir.toString), tmpDir).futureValue
        Some(
          Files
            .walk(tmpDir)
            .sorted(Comparator.reverseOrder())
            .iterator()
            .asScala
            .filter(path => Files.isRegularFile(path))
            .map(path => tmpDir.relativize(path).toString -> ByteString(Files.readAllBytes(path)))
            .toMap)
      } finally {
        Files.walk(tmpDir).sorted(Comparator.reverseOrder()).iterator().asScala.foreach(p => Files.delete(p))
      }
    } else None
  }

  override def afterAll(): Unit = {
    super.afterAll()
    TestKit.shutdownActorSystem(system)
  }
}
