// SPDX-License-Identifier: Apache-2.0

package chiselTests.stage

import chisel3._
import chisel3.stage.ChiselMain
import java.io.File

import chisel3.aop.inspecting.{InspectingAspect, InspectorAspect}
import org.scalatest.GivenWhenThen
import org.scalatest.featurespec.AnyFeatureSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.Inside._
import org.scalatest.EitherValues._

import scala.io.Source
import firrtl.Parser

object ChiselMainSpec {

  /** A module that connects two different types together resulting in an elaboration error */
  class DifferentTypesModule extends RawModule {
    val in = IO(Input(UInt(1.W)))
    val out = IO(Output(SInt(1.W)))
    out := in
  }

  /** A module that connects two of the same types together */
  class SameTypesModule extends MultiIOModule {
    val in = IO(Input(UInt(1.W)))
    val out = IO(Output(UInt(1.W)))
    out := in
  }

  /** A module that fails a requirement */
  class FailingRequirementModule extends RawModule {
    require(false, "the user wrote a failing requirement")
  }

  /** A module that triggers a Builder.error (as opposed to exception) */
  class BuilderErrorModule extends RawModule {
    val w = Wire(UInt(8.W))
    w(3, -1)
  }
}

case class TestClassAspect() extends InspectorAspect[RawModule] ({
  _: RawModule => println("Ran inspectingAspect")
})

case object TestObjectAspect extends InspectorAspect[RawModule] ({
  _: RawModule => println("Ran inspectingAspect")
})

class ChiselMainSpec extends AnyFeatureSpec with GivenWhenThen with Matchers with chiselTests.Utils {

  import ChiselMainSpec._

  class ChiselMainFixture {
    Given("a Chisel stage")
    val stage = ChiselMain
  }

  class TargetDirectoryFixture(dirName: String) {
    val dir = new File(s"test_run_dir/ChiselStageSpec/$dirName")
    val buildDir = new File(dir + "/build")
    dir.mkdirs()
  }

  case class ChiselMainTest(
    args: Array[String],
    generator: Option[Class[_ <: RawModule]] = None,
    files: Seq[String] = Seq.empty,
    stdout: Seq[Either[String, String]] = Seq.empty,
    stderr: Seq[Either[String, String]] = Seq.empty,
    result: Int = 0,
    fileChecks: Map[String, File => Unit] = Map.empty) {
    def testName: String = "args" + args.mkString("_")
    def argsString: String = args.mkString(" ")
  }

  /** A test of ChiselMain that is going to involve catching an exception.
    * @param args command line arguments (excluding --module) to pass in
    * @param generator the module to build (used to generate --module)
    * @param message snippets of text that should appear (Right) or not appear (Left) in the exception message
    * @param stdout snippets of text that should appear (Right) or not appear (Left) in STDOUT
    * @param stderr snippets of text that should appear (Right) or not appear (Left) in STDERR
    * @param stackTrace snippets of text that should appear (Right) or not appear (Left) in the stack trace
    * @tparam the type of exception that should occur
    */
  case class ChiselMainExceptionTest[A <: Throwable](
    args: Array[String],
    generator: Option[Class[_ <: RawModule]] = None,
    message: Seq[Either[String, String]] = Seq.empty,
    stdout: Seq[Either[String, String]] = Seq.empty,
    stderr: Seq[Either[String, String]] = Seq.empty,
    stackTrace: Seq[Either[String, String]] = Seq.empty
  ) {
    def testName: String = "args" + args.mkString("_")
    def argsString: String = args.mkString(" ")
  }

  def runStageExpectFiles(p: ChiselMainTest): Unit = {
    Scenario(s"""User runs Chisel Stage with '${p.argsString}'""") {
      val f = new ChiselMainFixture
      val td = new TargetDirectoryFixture(p.testName)

      p.files.foreach( f => new File(td.buildDir + s"/$f").delete() )

      When(s"""the user tries to compile with '${p.argsString}'""")
      val module: Array[String] =
        (if (p.generator.nonEmpty) { Array("--module", p.generator.get.getName) }
         else                      { Array.empty[String]                        })
      f.stage.main(Array("-td", td.buildDir.toString) ++ module ++ p.args)
      val (stdout, stderr, result) =
        grabStdOutErr {
          catchStatus {
            f.stage.main(Array("-td", td.buildDir.toString) ++ module ++ p.args)
          }
        }

      p.stdout.foreach {
        case Right(a) =>
          Then(s"""STDOUT should include "$a"""")
          stdout should include (a)
        case Left(a) =>
          Then(s"""STDOUT should not include "$a"""")
          stdout should not include (a)
      }

      p.stderr.foreach {
        case Right(a) =>
          Then(s"""STDERR should include "$a"""")
          stderr should include (a)
        case Left(a) =>
          Then(s"""STDERR should not include "$a"""")
          stderr should not include (a)
      }

      p.result match {
        case 0 =>
          And(s"the exit code should be 0")
          result shouldBe a [Right[_,_]]
        case a =>
          And(s"the exit code should be $a")
          result shouldBe (Left(a))
      }

      p.files.foreach { f =>
        And(s"file '$f' should be emitted in the target directory")
        val out = new File(td.buildDir + s"/$f")
        out should (exist)
        p.fileChecks.get(f).map(_(out))
      }
    }
  }

  /** Run a ChiselMainExceptionTest and verify that all the properties it spells out hold.
    * @param p the test to run
    * @tparam the type of the exception to catch (you shouldn't have to explicitly provide this)
    */
  def runStageExpectException[A <: Throwable: scala.reflect.ClassTag](p: ChiselMainExceptionTest[A]): Unit = {
    Scenario(s"""User runs Chisel Stage with '${p.argsString}'""") {
      val f = new ChiselMainFixture
      val td = new TargetDirectoryFixture(p.testName)

      When(s"""the user tries to compile with '${p.argsString}'""")
      val module: Array[String] =
        (if (p.generator.nonEmpty) { Array("--module", p.generator.get.getName) }
         else                      { Array.empty[String]                        })
      val (stdout, stderr, result) =
        grabStdOutErr {
          catchStatus {
            intercept[A] {
              f.stage.main(Array("-td", td.buildDir.toString) ++ module ++ p.args)
            }
          }
        }

      Then("the expected exception was thrown")
      result should be a ('right)
      val exception = result.right.get
      info(s"""  - Exception was a "${exception.getClass.getName}"""")

      val message = exception.getMessage
      p.message.foreach {
        case Right(a) =>
          Then(s"""STDOUT should include "$a"""")
          message should include (a)
        case Left(a) =>
          Then(s"""STDOUT should not include "$a"""")
          message should not include (a)
      }

      p.stdout.foreach {
        case Right(a) =>
          Then(s"""STDOUT should include "$a"""")
          stdout should include (a)
        case Left(a) =>
          Then(s"""STDOUT should not include "$a"""")
          stdout should not include (a)
      }

      p.stderr.foreach {
        case Right(a) =>
          Then(s"""STDERR should include "$a"""")
          stderr should include (a)
        case Left(a) =>
          Then(s"""STDERR should not include "$a"""")
          stderr should not include (a)
      }

      val stackTraceString = exception.getStackTrace.mkString("\n")
      p.stackTrace.foreach {
        case Left(a) =>
          And(s"""the stack does not include "$a"""")
          stackTraceString should not include (a)
        case Right(a) =>
          And(s"""the stack trace includes "$a"""")
          stackTraceString should include (a)
      }

    }
  }

  info("As a Chisel user")
  info("I compile a design")
  Feature("show elaborating message") {
    runStageExpectFiles(
      ChiselMainTest(args = Array("-X", "high"),
        generator = Some(classOf[SameTypesModule])
      )
    )
  }

  info("I screw up and compile some bad code")
  Feature("Stack trace trimming of ChiselException") {
    Seq(
      ChiselMainExceptionTest[chisel3.internal.ChiselException](
        args = Array("-X", "low"),
        generator = Some(classOf[DifferentTypesModule]),
        stackTrace = Seq(Left("java"), Right(classOf[DifferentTypesModule].getName))
      ),
      ChiselMainExceptionTest[chisel3.internal.ChiselException](
        args = Array("-X", "low", "--full-stacktrace"),
        generator = Some(classOf[DifferentTypesModule]),
        stackTrace = Seq(Right("java"), Right(classOf[DifferentTypesModule].getName))
      )
    ).foreach(runStageExpectException)
  }
  Feature("Stack trace trimming of user exceptions") {
    Seq(
      ChiselMainExceptionTest[java.lang.IllegalArgumentException](
        args = Array("-X", "low"),
        generator = Some(classOf[FailingRequirementModule]),
        stackTrace = Seq(Right(classOf[FailingRequirementModule].getName), Left("java"))
      ),
      ChiselMainExceptionTest[java.lang.IllegalArgumentException](
        args = Array("-X", "low", "--full-stacktrace"),
        generator = Some(classOf[FailingRequirementModule]),
        stackTrace = Seq(Right(classOf[FailingRequirementModule].getName), Right("java"))
      )
    ).foreach(runStageExpectException)
  }
  Feature("Stack trace trimming and Builder.error errors") {
    Seq(
      ChiselMainExceptionTest[chisel3.internal.ChiselException](
        args = Array("-X", "low"),
        generator = Some(classOf[BuilderErrorModule]),
        message = Seq(Right("Fatal errors during hardware elaboration")),
        stdout = Seq(Right("ChiselMainSpec.scala:43: Invalid bit range (3,-1) in class chiselTests.stage.ChiselMainSpec$BuilderErrorModule"))
      )
    ).foreach(runStageExpectException)
  }

  Feature("Specifying a custom output file") {
    runStageExpectFiles(ChiselMainTest(
      args = Array("--chisel-output-file", "Foo", "--no-run-firrtl"),
      generator = Some(classOf[SameTypesModule]),
      files = Seq("Foo.fir"),
      fileChecks = Map(
        "Foo.fir" -> { file =>
          And("It should be valid FIRRTL")
          Parser.parse(Source.fromFile(file).mkString)
        }
      )
    ))
    runStageExpectFiles(ChiselMainTest(
      args = Array("--chisel-output-file", "Foo.pb", "--no-run-firrtl"),
      generator = Some(classOf[SameTypesModule]),
      files = Seq("Foo.pb"),
      fileChecks = Map(
        "Foo.pb" -> { file =>
          And("It should be valid ProtoBuf")
          firrtl.proto.FromProto.fromFile(file.toString)
        }
      )
    ))
  }

  info("As an aspect writer")
  info("I write an aspect")
  Feature("Running aspects via the command line") {
    Seq(
      ChiselMainTest(args = Array( "-X", "high", "--with-aspect", "chiselTests.stage.TestClassAspect" ),
        generator = Some(classOf[SameTypesModule]),
        stdout = Seq(Right("Ran inspectingAspect"))),
      ChiselMainTest(args = Array( "-X", "high", "--with-aspect", "chiselTests.stage.TestObjectAspect" ),
        generator = Some(classOf[SameTypesModule]),
        stdout = Seq(Right("Ran inspectingAspect")))
    ).foreach(runStageExpectFiles)
  }

}
