(*
 * Copyright (c) 2013-2020 Thomas Gazagnaire <thomas@gazagnaire.org>
 * Copyright (c) 2013-2020 Anil Madhavapeddy <anil@recoil.org>
 * Copyright (c) 2015-2020 Gabriel Radanne <drupyog@zoho.com>
 *
 * Permission to use, copy, modify, and distribute this software for any
 * purpose with or without fee is hereby granted, provided that the above
 * copyright notice and this permission notice appear in all copies.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 *)

open Action.Syntax
open DSL

let src = Logs.Src.create "functoria.tool" ~doc:"functoria library"

module Log = (val Logs.src_log src : Logs.LOG)

module type S = sig
  val name : string
  val version : string
  val packages : package list
  val create : job impl list -> job impl
end

let check_version ~name ~version data =
  let ( let* ) = Result.bind in
  let extract_version v =
    if String.for_all (function '0' .. '9' | '.' -> true | _ -> false) v then
      try Ok (Scanf.sscanf v "%u.%u.%u" (fun ma mi pa -> (ma, mi, pa))) with
      | Scanf.Scan_failure _ | End_of_file -> (
          try Ok (Scanf.sscanf v "%u.%u" (fun ma mi -> (ma, mi, 0))) with
          | Scanf.Scan_failure _ | End_of_file -> (
              try Ok (Scanf.sscanf v "%u" (fun ma -> (ma, 0, 0)))
              with Scanf.Scan_failure _ | Failure _ | End_of_file ->
                Error ("couldn't extract version (%u) from " ^ v))
          | Failure f ->
              Error ("couldn't extract version (%u.%u) from " ^ v ^ ": " ^ f))
      | Failure f ->
          Error ("couldn't extract version (%u.%u.%u) from " ^ v ^ ": " ^ f)
    else Error "only digits and . allowed in version"
  in
  if String.equal version ("%%" ^ "VERSION%%") then (
    Log.warn (fun m ->
        m "Skipping version check, since our_version is not watermarked");
    Ok ())
  else
    match extract_version version with
    | Error msg ->
        Log.warn (fun m ->
            m
              "Skipping version check, since our_version (%S) fails to parse: \
               %s"
              version msg);
        Ok ()
    | Ok version' ->
        let first_str = "(* " ^ name ^ " " in
        let fl = String.length first_str in
        if
          fl < String.length data
          && String.equal (String.sub data 0 fl) first_str
        then
          let* lower_version, upper_version =
            let vs =
              String.split_on_char ' '
                (String.sub data fl (String.length data - fl))
            in
            let rec go lower upper = function
              | "&" :: tl -> go lower upper tl
              | ">=" :: v :: tl ->
                  if lower = None then go (Some v) upper tl
                  else Error "Bad comment, multiple >= constraints"
              | "<" :: v :: tl ->
                  if upper = None then go lower (Some v) tl
                  else Error "Bad comment, multiple < constraints"
              | "*)" :: _ -> Ok (lower, upper)
              | "" :: tl -> go lower upper tl
              | _ ->
                  Error
                    (Fmt.str
                       "Unknown first line, must be (* %s [>= a.b.c] [&] [< \
                        d.e.f] *)"
                       name)
            in
            go None None vs
          in
          let cmp ~eq (ma, mi, pa) (ma', mi', pa') =
            ma > ma'
            || (ma = ma' && mi > mi')
            || (ma = ma' && mi = mi' && pa > pa')
            || (ma = ma' && mi = mi' && pa = pa' && eq)
          in
          let* () =
            match lower_version with
            | None -> Ok ()
            | Some v ->
                let* v' = extract_version v in
                if cmp ~eq:true version' v' then Ok ()
                else
                  Error
                    (Fmt.str
                       "Version mismatch: required is %s >= %s, but %s is \
                        installed. Please upgrade your installation (opam \
                        update; opam install '%s>=%s')"
                       name v version name v)
          in
          match upper_version with
          | None -> Ok ()
          | Some v ->
              let* v' = extract_version v in
              if cmp ~eq:false v' version' then Ok ()
              else
                Error
                  (Fmt.str
                     "Version mismatch: required is %s < %s, but %s is \
                      installed. Please downgrade your installation (opam \
                      update; opam install '%s<%s')"
                     name v version name v)
        else Ok ()

module Make (P : S) = struct
  module Filegen = Filegen.Make (P)

  let build_dir t = Fpath.parent t.Cli.config_file
  let context_file t = Context_cache.file ~name:P.name t

  let add_context_file t argv =
    match t.Cli.context_file with
    | Some _ -> Action.ok argv
    | None ->
        let file = context_file t in
        let+ is_file = Action.is_file file in
        if is_file then
          Array.append argv [| "--context-file"; Fpath.to_string file |]
        else (* should only happen when doing configure --help *) argv

  let run_cmd ?ppf ?err_ppf command =
    let err = match err_ppf with None -> None | Some f -> Some (`Fmt f) in
    let out = match ppf with None -> None | Some f -> Some (`Fmt f) in
    Action.run_cmd ?err ?out command

  let re_exec_cli t argv =
    let* argv = add_context_file t argv in
    let args = Bos.Cmd.of_list (List.tl (Array.to_list argv)) in
    let config_exe =
      Fpath.(v "_build" / "default" // build_dir t / "config.exe")
    in
    let command = Bos.Cmd.(v (p config_exe) %% args) in
    Action.run_cmd_cli command

  (* Generate the base dune and dune-project files *)
  let generate_base_dune t =
    let dune_config_path = Fpath.(build_dir t / "dune.config") in
    Log.info (fun m -> m "Generating: %a (base)" Fpath.pp dune_config_path);
    let dune_config =
      Dune.base ~config_ml_file:t.Cli.config_file ~packages:P.packages
    in
    let dune_config = Fmt.str "%a\n%!" Dune.pp dune_config in
    let* () = Filegen.write dune_config_path dune_config in
    let dune_path = Fpath.(build_dir t / "dune") in
    let dune = Fmt.str "(include dune.config)" in
    Filegen.write dune_path dune

  let dune_workspace_path t =
    Fpath.(build_dir t / P.name / "dune-workspace.config")

  let generate_base_dune_workspace t =
    let dune_workspace_path = dune_workspace_path t in
    Log.info (fun m -> m "Generating: %a (base)" Fpath.pp dune_workspace_path);
    let dune = Dune.base_workspace in
    let dune = Fmt.str "%a\n%!" Dune.pp dune in
    Filegen.write dune_workspace_path dune

  let generate_base_dune_project () =
    let dune_project_path = Fpath.(v "dune-project") in
    Log.info (fun m -> m "Generating: %a (base)" Fpath.pp dune_project_path);
    let dune = Dune.v Dune.base_project in
    let dune = Fmt.str "%a\n%!" Dune.pp dune in
    Filegen.write dune_project_path dune

  let build_config_exe t ?ppf ?err_ppf () =
    let dune_workspace_path = dune_workspace_path t in
    let command =
      Bos.Cmd.(
        v "dune"
        % "build"
        % p Fpath.(build_dir t / "config.exe")
        % "--root"
        % "."
        % "--workspace"
        % p dune_workspace_path)
    in
    run_cmd ?ppf ?err_ppf command

  let write_context t argv = Context_cache.write (context_file t) argv
  let remove_context t = Action.rm (context_file t)

  (* Generated a project skeleton and try to compile config.exe. *)
  let generate_project_skeleton ~save_args t ?ppf ?err_ppf argv =
    let* _ = Action.mkdir Fpath.(build_dir t / P.name) in
    let* () = generate_base_dune_workspace t in
    let* () = generate_base_dune_project () in
    let* () = generate_base_dune t in
    let* () = if save_args then write_context t argv else Action.ok () in
    (* try to compile config.exe to detect early compilation errors. *)
    build_config_exe t ?ppf ?err_ppf ()

  let exit_err t = function
    | Ok v -> v
    | Error (`Msg m) ->
        flush_all ();
        if m <> "" then Fmt.epr "%a\n%!" Fmt.(styled (`Fg `Red) string) m;
        if not t.Cli.dry_run then exit 1 else Fmt.epr "(exit 1)\n%!"

  let handle_parse_args_no_config ?help_ppf ?err_ppf (`Msg error) argv =
    let context =
      (* Extract all the keys directly. Useful to pre-resolve the keys
         provided by the specialized DSL. *)
      let base_keys = Engine.keys @@ Impl.abstract @@ P.create [] in
      Cmdliner.Term.(const (fun _ -> Action.ok ()) $ Key.context base_keys)
    in
    let result =
      Cli.eval ?help_ppf ?err_ppf ~name:P.name ~version:P.version
        ~configure:context ~query:context ~describe:context ~clean:context
        ~help:context ~mname:P.name argv
    in
    let ok = Action.ok () in
    let error = Action.error error in
    match result with `Version | `Help | `Ok (Cli.Help _) -> ok | _ -> error

  let with_project_skeleton ~save_args t ?ppf ?err_ppf argv f =
    let file = t.Cli.config_file in
    let* is_file = Action.is_file file in
    if not is_file then
      let msg = Fmt.str "configuration file %a missing" Fpath.pp file in
      handle_parse_args_no_config ?help_ppf:ppf ?err_ppf (`Msg msg) argv
    else
      let* () = generate_project_skeleton ~save_args t ?ppf ?err_ppf argv in
      f ()

  let action_run t a =
    if not t.Cli.dry_run then Action.run a
    else
      let env = Action.env ~files:(`Passtrough (Fpath.v ".")) () in
      let dom = Action.dry_run ~env a in
      List.iter
        (fun line ->
          Fmt.epr "%a %s\n%!" Fmt.(styled (`Fg `Cyan) string) "*" line)
        dom.logs;
      dom.result

  let clean_files ?ppf ?err_ppf args =
    let dune_clean () =
      let* var = Action.get_var "INSIDE_FUNCTORIA_TESTS" in
      match var with
      | Some "1" | Some "" -> Action.rm Fpath.(build_dir args / ".merlin")
      | _ -> run_cmd ?ppf ?err_ppf Bos.Cmd.(v "dune" % "clean")
    in
    let rm_gen_files () =
      let* files = Action.ls (Fpath.v ".") (fun _ -> true) in
      let files = List.sort Fpath.compare files in
      let files =
        List.filter_map
          (fun file ->
            if Fpath.parent file <> Fpath.v "./" then None
            else
              let base, ext = Fpath.split_ext file in
              let base = Fpath.basename base in
              match (base, ext) with
              | ("Makefile" | "dune-project" | "dune-workspace"), "" ->
                  Some file
              | _ ->
                  Log.info (fun f -> f "Skipped %a" Fpath.pp file);
                  None)
          files
      in
      let* () = Action.List.iter ~f:Filegen.rm files in
      let* () = remove_context args in
      let* () = Filegen.rm Fpath.(build_dir args / "dune") in
      let* () = Filegen.rm Fpath.(build_dir args / "dune.build") in
      Filegen.rm Fpath.(build_dir args / "dune.config")
    in
    let* () = dune_clean () in
    rm_gen_files ()

  (* App builder configuration *)
  let configure ({ args; _ } : _ Cli.configure_args) ?ppf ?err_ppf argv =
    let file = args.Cli.config_file in
    let* () =
      let* is_file = Action.is_file file in
      if not is_file then
        Action.errorf "configuration file %a missing" Fpath.pp file
      else Action.ok ()
    in
    let* () =
      let* data =
        let cmd = Bos.Cmd.(v "head" % "-1" % p file) in
        Action.run_cmd_out ~err:`Null cmd
      in
      let version =
        let v = P.version in
        if String.length v > 0 && String.get v 0 = 'v' then
          String.sub v 1 (String.length v - 1)
        else v
      in
      Result.fold
        ~ok:(fun () -> Action.ok ())
        ~error:(fun msg -> Action.error msg)
        (check_version ~name:P.name ~version data)
    in
    (* Files to build config.ml *)
    with_project_skeleton ~save_args:true args ?ppf ?err_ppf argv @@ fun () ->
    Log.info (fun f -> f "Set-up config skeleton.");
    (* Launch config.exe: additional generated files for the application. *)
    re_exec_cli args argv

  let try_to_re_exec args ?ppf ?err_ppf argv =
    with_project_skeleton ~save_args:false args ?ppf ?err_ppf argv @@ fun () ->
    re_exec_cli args argv

  let error t = try_to_re_exec t
  let query (t : 'a Cli.query_args) = try_to_re_exec t.args
  let describe (t : 'a Cli.describe_args) = try_to_re_exec t.args
  let help (t : 'a Cli.help_args) = try_to_re_exec t

  let clean args ?ppf ?err_ppf argv =
    let config = args.Cli.config_file in
    let* () =
      let* is_file = Action.is_file config in
      if is_file then try_to_re_exec args ?ppf ?err_ppf argv else Action.ok ()
    in
    clean_files args

  let run args action = action |> action_run args |> exit_err args
  let pp_unit _ _ = ()

  let run_with_argv ?help_ppf ?err_ppf argv =
    let t = Cli.peek ~with_setup:true ~mname:P.name argv in
    match t with
    | `Version ->
        Log.info (fun l -> l "version");
        Fmt.pr "%s\n%!" P.version
    | `Error (Some t, _) ->
        Log.info (fun l -> l "error: %a" (Cli.pp_args pp_unit) t);
        run t @@ error t ?ppf:help_ppf ?err_ppf argv
    | `Error (None, _) ->
        let action =
          handle_parse_args_no_config ?help_ppf ?err_ppf (`Msg "") argv
        in
        let args = Cli.default_args in
        action_run args action |> exit_err args
    | `Ok t -> (
        Log.info (fun l -> l "run: %a" (Cli.pp_action pp_unit) t);
        let run = run (Cli.args t) in
        let ppf = help_ppf in
        match t with
        | Configure t -> run @@ configure t ?ppf ?err_ppf argv
        | Clean t -> run @@ clean t ?ppf ?err_ppf argv
        | Query t -> run @@ query t ?ppf ?err_ppf argv
        | Describe t -> run @@ describe t ?ppf ?err_ppf argv
        | Help t -> run @@ help t ?ppf ?err_ppf argv)

  let run () = run_with_argv Sys.argv
end
