module M = Metrics
open Stdune
module Metrics = M
module Process = Dune_engine.Process
open Dune_scheduler

module Console = struct
  include Console

  let printf fmt = printf ("[Bench] " ^^ fmt)
end

module Output = struct
  type bench =
    { name : string
    ; metrics : (string * Json.t * string) list
    }

  let json_of_bench { name; metrics } : Json.t =
    let metrics =
      List.map metrics ~f:(fun (name, value, units) ->
        Json.assoc
          [ "name", Json.string name; "value", value; "units", Json.string units ])
    in
    Json.assoc [ "name", Json.string name; "metrics", Json.list metrics ]
  ;;

  type t =
    { config : (string * Json.t) list
    ; version : int
    ; results : bench list
    }

  let to_json { config; version; results } : Json.t =
    let assoc = [ "results", Json.list (List.map results ~f:json_of_bench) ] in
    let assoc = ("version", Json.int version) :: assoc in
    let assoc =
      match config with
      | [] -> assoc
      | _ :: _ -> ("config", Json.assoc config) :: assoc
    in
    Json.assoc assoc
  ;;
end

let git =
  lazy
    (let path = Env.get Env.initial "PATH" |> Option.value_exn |> Bin.parse_path in
     Bin.which ~path "git" |> Option.value_exn)
;;

let dune = Path.of_string (Filename.concat Fpath.initial_cwd Sys.argv.(1))
let output_limit = Dune_engine.Execution_parameters.Action_output_limit.default
let make_stdout () = Process.Io.make_stdout ~output_on_success:Swallow ~output_limit
let make_stderr () = Process.Io.make_stderr ~output_on_success:Swallow ~output_limit

module Package = struct
  type t =
    { org : string
    ; name : string
    }

  let uri { org; name } = sprintf "https://github.com/%s/%s" org name

  let clone t =
    let stdout_to = make_stdout () in
    let stderr_to = make_stderr () in
    let stdin_from = Process.Io.(null In) in
    Process.run
      Strict
      ~display:Quiet
      ~stdout_to
      ~stderr_to
      ~stdin_from
      (Lazy.force git)
      [ "clone"; uri t ]
  ;;
end

let duniverse = [ { Package.org = "ocaml"; name = "dune" } ]

let prepare_workspace () =
  Fiber.parallel_iter duniverse ~f:(fun (pkg : Package.t) ->
    Fpath.rm_rf pkg.name;
    Console.printf "cloning %s/%s" pkg.org pkg.name;
    Fiber.finalize
      (fun () -> Package.clone pkg)
      ~finally:(fun () ->
        Fiber.return @@ Console.printf "finished cloning %s/%s" pkg.org pkg.name))
;;

let dune_build ~name ~sandbox =
  let stdin_from = Process.(Io.null In) in
  let stdout_to = make_stdout () in
  let stderr_to = make_stderr () in
  let gc_dump = Temp.create File ~prefix:"gc_stat" ~suffix:name in
  let open Fiber.O in
  (* Build with timings and gc stats *)
  let+ times =
    Process.run_with_times
      Strict
      dune
      ~display:Quiet
      ~stdin_from
      ~stdout_to
      ~stderr_to
      ([ "build"
       ; "@install"
       ; "--release"
       ; "--cache" (* explicitly disable cache *)
       ; "disabled"
       ; "--dump-gc-stats"
       ; Path.to_string gc_dump
       ]
       @
       match sandbox with
       | `Yes -> [ "--sandbox"; "hardlink" ]
       | `No -> [])
  in
  (* Read the gc stats from the dump file *)
  Dune_lang.Parser.parse_string
    ~mode:Single
    ~fname:(Path.to_string gc_dump)
    (Io.read_file gc_dump)
  |> Dune_lang.Decoder.parse Dune_util.Gc.decode Univ_map.empty
  |> Metrics.make times
;;

let run_bench ~sandbox =
  let open Fiber.O in
  let* clean = dune_build ~name:"clean" ~sandbox in
  let+ zero =
    let rec zero acc n =
      if n = 0
      then Fiber.return (List.rev acc)
      else
        let* time = dune_build ~name:("zero" ^ string_of_int n) ~sandbox in
        zero (time :: acc) (pred n)
    in
    zero [] 5
  in
  clean, zero
;;

type ('float, 'int) bench_results =
  { size : int
  ; clean : ('float, 'int) Metrics.t
  ; zero : ('float, 'int) Metrics.t list
  }

let tag_results { size; clean; zero } =
  let tag data = Metrics.map ~f:Json.float ~g:Json.int data in
  let list_tag data =
    List.map data ~f:tag |> Metrics.unzip |> Metrics.map ~f:Json.list ~g:Json.list
  in
  Json.int size, tag clean, list_tag zero
;;

(** Display all clean and null builds with a few exceptions:

    - fragments - not consistent between builds
    - stack_size - not very useful
    - forced_collections - only available in OCaml >= 4.12 *)
let display_clean_and_zero_with_sandboxing
      ({ elapsed_time
       ; user_cpu_time
       ; system_cpu_time
       ; minor_words
       ; promoted_words
       ; major_words
       ; minor_collections
       ; major_collections
       ; heap_words
       ; heap_chunks
       ; live_words
       ; live_blocks
       ; free_words
       ; free_blocks
       ; largest_free
       ; fragments = _
       ; compactions
       ; top_heap_words
       ; stack_size = _
       } :
        _ Metrics.t)
      (zero : _ Metrics.t)
  =
  let display what units clean zero =
    { Output.name = what
    ; metrics = [ "[Clean] " ^ what, clean, units; "[Null] " ^ what, zero, units ]
    }
  in
  [ display "Build Time" "Seconds" elapsed_time zero.elapsed_time
  ; display "Minor Words" "Approx. Words" minor_words zero.minor_words
  ; display "Promoted Words" "Approx. Words" promoted_words zero.promoted_words
  ; display "Major Words" "Approx. Words" major_words zero.major_words
  ; display "Minor Collections" "Collections" minor_collections zero.minor_collections
  ; display "Major Collections" "Collections" major_collections zero.major_collections
  ; display "Heap Words" "Words" heap_words zero.heap_words
  ; display "Heap Chunks" "Chunks" heap_chunks zero.heap_chunks
  ; display "Live Words" "Words" live_words zero.live_words
  ; display "Live Blocks" "Blocks" live_blocks zero.live_blocks
  ; display "Free Words" "Words" free_words zero.free_words
  ; display "Free Blocks" "Blocks" free_blocks zero.free_blocks
  ; display "Largest Free" "Words" largest_free zero.largest_free
  ; display "Compactions" "Compactions" compactions zero.compactions
  ; display "Top Heap Words" "Words" top_heap_words zero.top_heap_words
  ; display "User CPU Time" "Seconds" user_cpu_time zero.user_cpu_time
  ; display "System CPU Time" "Seconds" system_cpu_time zero.system_cpu_time
  ]
;;

let format_results bench_results =
  (* tagging data for json conversion *)
  let size, clean, zero = tag_results bench_results in
  (* bench results *)
  [ { Output.name = "Misc"; metrics = [ "Size of _boot/dune.exe", size, "Bytes" ] } ]
  @ display_clean_and_zero_with_sandboxing clean zero
;;

let () =
  Log.init No_log_file;
  let dir = Temp.create Dir ~prefix:"dune" ~suffix:"bench" in
  Sys.chdir (Path.to_string dir);
  Path.as_external dir |> Option.value_exn |> Path.set_root;
  Path.Build.set_build_dir (Path.Outside_build_dir.of_string "_build");
  let config =
    Dune_engine.Clflags.display := Quiet;
    { Scheduler.Config.concurrency = 10
    ; print_ctrl_c_warning = false
    ; watch_exclusions = []
    }
  in
  let size =
    let stat : Unix.stats = Unix.stat (Path.to_string dune) in
    stat.st_size
  in
  let results =
    Scheduler.Run.go config ~on_event:(fun _ -> ())
    @@ fun () ->
    let open Fiber.O in
    (* Prepare the workspace *)
    let* () = prepare_workspace () in
    (* Build the clean and null builds *)
    Console.printf "Building clean and null builds";
    let+ clean, zero = run_bench ~sandbox:`No in
    Console.printf "Finished building clean and null builds";
    (* Return the bench results *)
    format_results { size; clean; zero }
  in
  let version = 4 in
  let output = { Output.config = []; version; results } in
  print_string (Json.to_string (Output.to_json output));
  flush stdout
;;
