// SPDX-License-Identifier: Apache-2.0

package firrtl.jqf

import java.io.{File, FileNotFoundException, IOException}
import java.net.{MalformedURLException, URLClassLoader}
import java.time.Duration
import java.time.format.DateTimeParseException

import edu.berkeley.cs.jqf.fuzz.ei.ExecutionIndexingGuidance
import edu.berkeley.cs.jqf.fuzz.ei.ZestGuidance
import edu.berkeley.cs.jqf.fuzz.junit.GuidedFuzzing
import edu.berkeley.cs.jqf.instrument.InstrumentingClassLoader


case class JQFException(message: String, e: Throwable = null) extends Exception(message)

sealed trait JQFEngine
case object Zeal extends JQFEngine
case object Zest extends JQFEngine

case class JQFFuzzOptions(
  // required
  classpath: Seq[String] = null,
  outputDirectory: File = null,
  testClassName: String = null,
  testMethod: String = null,

  excludes: Seq[String] = Seq.empty,
  includes: Seq[String] = Seq.empty,
  time: Option[String] = None,
  blind: Boolean = false,
  engine: JQFEngine = Zest,
  disableCoverage: Boolean = false,
  inputDirectory: Option[File] = None,
  saveAll: Boolean = false,
  libFuzzerCompatOutput: Boolean = false,
  quiet: Boolean = false,
  exitOnCrash: Boolean = false,
  runTimeout: Option[Int] = None
)

object JQFFuzz {
  final def main(args: Array[String]): Unit = {
    val parser = new scopt.OptionParser[JQFFuzzOptions]("JQF-Fuzz") {
      opt[String]("classpath")
        .required()
        .unbounded()
        .action((x, c) => c.copy(classpath = x.split(":")))
        .text("the classpath to instrument and load the test class from")
      opt[File]("outputDirectory")
        .required()
        .unbounded()
        .action((x, c) => c.copy(outputDirectory = x))
        .text("the directory to output test results")
      opt[String]("testClassName")
        .required()
        .unbounded()
        .action((x, c) => c.copy(testClassName = x))
        .text("the full class path of the test class")
      opt[String]("testMethod")
        .required()
        .unbounded()
        .action((x, c) => c.copy(testMethod = x))
        .text("the method of the test class to run")

      opt[Seq[String]]("excludes")
        .unbounded()
        .action((x, c) => c.copy(excludes = x))
        .text("comma-separated list of FQN prefixes to exclude from coverage instrumentation")
      opt[Seq[String]]("includes")
        .unbounded()
        .action((x, c) => c.copy(includes = x))
        .text("comma-separated list of FQN prefixes to forcibly include, even if they match an exclude")
      opt[String]("time")
        .unbounded()
        .action((x, c) => c.copy(time = Some(x)))
        .text("the duration of time for which to run fuzzing")
      opt[Unit]("blind")
        .unbounded()
        .action((_, c) => c.copy(blind = true))
        .text("whether to generate inputs blindly without taking into account coverage feedback")
      opt[String]("engine")
        .unbounded()
        .action((x, c) => x match {
          case "zest" => c.copy(engine = Zest)
          case "zeal" => c.copy(engine = Zeal)
          case _ =>
            throw new JQFException(s"bad a value '$x' for --engine, must be zest|zeal")
        })
        .text("the fuzzing engine, valid choices are zest|zeal")
      opt[Unit]("disableCoverage")
        .unbounded()
        .action((_, c) => c.copy(disableCoverage = true))
        .text("disable code-coverage instrumentation")
      opt[File]("inputDirectory")
        .unbounded()
        .action((x, c) => c.copy(inputDirectory = Some(x)))
        .text("the name of the input directory containing seed files")
      opt[Unit]("saveAll")
        .unbounded()
        .action((_, c) => c.copy(saveAll = true))
        .text("save ALL inputs generated during fuzzing, even the ones that do not have any unique code coverage")
      opt[Unit]("libFuzzerCompatOutput")
        .unbounded()
        .action((_, c) => c.copy(libFuzzerCompatOutput = true))
        .text("use libFuzzer like output instead of AFL like stats screen")
      opt[Unit]("quiet")
        .unbounded()
        .action((_, c) => c.copy(quiet = true))
        .text("avoid printing fuzzing statistics progress in the console")
      opt[Unit]("exitOnCrash")
        .unbounded()
        .action((_, c) => c.copy(exitOnCrash = true))
        .text("stop fuzzing once a crash is found.")
      opt[Int]("runTimeout")
        .unbounded()
        .action((x, c) => c.copy(runTimeout = Some(x)))
        .text("the timeout for each individual trial, in milliseconds")
    }

    try {
      parser.parse(args, JQFFuzzOptions()) match {
        case Some(opts) => execute(opts)
        case _ => System.exit(1)
      }
      System.gc();
    } catch {
      case e: Throwable =>
        System.gc();
        throw e
    }
  }

  def execute(opts: JQFFuzzOptions): Unit = {
    // Configure classes to instrument
    if (opts.excludes.nonEmpty) {
      System.setProperty("janala.excludes", opts.excludes.mkString(","))
    }
    if (opts.includes.nonEmpty) {
      System.setProperty("janala.includes", opts.includes.mkString(","))
    }

    // Configure Zest Guidance
    if (opts.saveAll) {
      System.setProperty("jqf.ei.SAVE_ALL_INPUTS", "true")
    }
    if (opts.libFuzzerCompatOutput) {
      System.setProperty("jqf.ei.LIBFUZZER_COMPAT_OUTPUT", "true")
    }
    if (opts.quiet) {
      System.setProperty("jqf.ei.QUIET_MODE", "true")
    }
    if (opts.exitOnCrash) {
      System.setProperty("jqf.ei.EXIT_ON_CRASH", "true")
    }
    if (opts.runTimeout.isDefined) {
      System.setProperty("jqf.ei.TIMEOUT", opts.runTimeout.get.toString)
    }

    val duration = opts.time.map { time =>
      try {
        Duration.parse("PT" + time);
      } catch {
        case e: DateTimeParseException =>
          throw new JQFException("Invalid time duration: " + time, e)
      }
    }.getOrElse(null)

    val loader = try {
      val classpath = opts.classpath.toArray
      if (opts.disableCoverage) {
        new URLClassLoader(
          classpath.map(cpe => new File(cpe).toURI().toURL()),
          getClass().getClassLoader())
      } else {
        new InstrumentingClassLoader(
          classpath,
          getClass().getClassLoader())
      }
    } catch {
      case e: MalformedURLException =>
        throw new JQFException("Could not get project classpath", e)
    }

    val guidance = try {
      val resultsDir = opts.outputDirectory
      val targetName = opts.testClassName + "#" + opts.testMethod
      val seedsDirOpt = opts.inputDirectory
      val guidance = (opts.engine, seedsDirOpt) match {
        case (Zest, Some(seedsDir)) =>
          new ZestGuidance(targetName, duration, resultsDir, seedsDir)
        case (Zest, None) =>
          new ZestGuidance(targetName, duration, resultsDir)
        case (Zeal, Some(seedsDir)) =>
          new ExecutionIndexingGuidance(targetName, duration, resultsDir, seedsDir)
        case (Zeal, None) =>
          throw new JQFException("--inputDirectory required when using zeal engine")
      }
      guidance.setBlind(opts.blind)
      guidance
    } catch {
      case e: FileNotFoundException =>
        throw new JQFException("File not found", e)
      case e: IOException =>
        throw new JQFException("I/O error", e)
    }

    val result = try {
      GuidedFuzzing.run(opts.testClassName, opts.testMethod, loader, guidance, System.out)
    } catch {
      case e: ClassNotFoundException =>
        throw new JQFException("could not load test class", e)
      case e: IllegalArgumentException =>
        throw new JQFException("Bad request", e)
      case e: RuntimeException =>
        throw new JQFException("Internal error", e)
    }

    if (!result.wasSuccessful()) {
      throw new JQFException(
        "Fuzzing revealed errors. Use mvn jqf:repro to reproduce failing test case.")
    }
  }
}
