(**************************************************************************)
(*                                                                        *)
(*                                 OCaml                                  *)
(*                                                                        *)
(*             Sebastien Hinderer, projet Gallium, INRIA Paris            *)
(*                                                                        *)
(*   Copyright 2016 Institut National de Recherche en Informatique et     *)
(*     en Automatique.                                                    *)
(*                                                                        *)
(*   All rights reserved.  This file is distributed under the terms of    *)
(*   the GNU Lesser General Public License version 2.1, with the          *)
(*   special exception on linking described in the file LICENSE.          *)
(*                                                                        *)
(**************************************************************************)

(* File comparison tools *)

open Ocamltest_stdlib

type result =
  | Same
  | Different
  | Unexpected_output
  | Error of string * int

type ignore = {bytes: int; lines: int}
type tool =
  |  External of {
                   tool_name : string;
                   tool_flags : string;
                   result_of_exitcode : string -> int -> result
                }
  | Internal of ignore

let cmp_result_of_exitcode commandline = function
  | 0 -> Same
  | 1 -> Different
  | exit_code -> (Error (commandline, exit_code))

let make_cmp_tool ~ignore =
  Internal ignore

let make_comparison_tool ?(result_of_exitcode = cmp_result_of_exitcode)
                         name flags =
  External
    {
      tool_name = name;
      tool_flags = flags;
      result_of_exitcode
    }

let default_comparison_tool = make_cmp_tool ~ignore:{bytes=0;lines=0}

type filetype = Binary | Text

type files = {
  filetype : filetype;
  reference_filename : string;
  output_filename : string;
}

let last_is_cr s =
  let l = String.length s in
  l > 0 && s.[l - 1] = '\r'

(* Returns last character of an input file. Fails for an empty file. *)
let last_char ic =
  seek_in ic (in_channel_length ic - 1);
  input_char ic

(* [line_seq_of_in_channel ~normalise ic first_line] constructs a sequence of
   the lines of [ic] where [first_line] is the already read first line of [ic].
   Strings include the line terminator and CRLF is normalised to LF if
   [normalise] is [true]. The sequence raises [Exit] if normalise is [true] and
   a terminated line is encountered which does not end CRLF. The final line of
   the sequence only includes a terminator if it is present in the file (and a
   terminating CR is never normalised if not strictly followed by LF). *)
let line_seq_of_in_channel ~normalise ic =
  let normalise =
    if normalise then
      fun s ->
        if last_is_cr s then
          String.sub s 0 (String.length s - 1)
        else
          raise Exit
    else
      Fun.id
  in
    let rec read_line last () =
      (* Read the next line to determine if the last line ended with LF *)
      match input_line ic with
      | line ->
          Seq.Cons (normalise last ^ "\n", read_line line)
      | exception End_of_file ->
          (* EOF reached - seek the last character to determine if the final
             line ends in LF *)
          let last =
            if last_char ic = '\n' then
              normalise last ^ "\n"
            else
              last
          in
            Seq.Cons (last, Seq.empty)
    in
      read_line

let compare_text_files ignored_lines file1 file2 =
  Sys.with_input_file ~bin:true file2 @@ fun ic2 ->
    (* Get the first non-dropped line of file2 and determine if could be
       CRLF-normalised (it can't be in any of the dropped lines didn't end
       CRLF. *)
    let (crlf_endings2, line2, reached_end_file2) =
      let rec loop crlf_endings2 k =
        match input_line ic2 with
        | line ->
            let crlf_endings2 = crlf_endings2 && last_is_cr line in
            if k = 0 then
              (crlf_endings2, line, false)
            else
              loop crlf_endings2 (pred k)
        | exception End_of_file ->
            (false, "", true)
      in
        loop true ignored_lines
    in
      Sys.with_input_file ~bin:true file1 @@ fun ic1 ->
        if reached_end_file2 then
          (* We reached the end of file2 while ignoring lines, so only an empty
             file can be identical, as in the binary comparison case. *)
          if in_channel_length ic1 = 0 then
            Same
          else
            Different
        else
          (* file2 has at least one non-ignored line *)
          match input_line ic1 with
          | exception End_of_file -> Different
          | line1 ->
              let crlf_endings1 = last_is_cr line1 in
              (* If both files appear to have CRLF endings, then there's no need
                 to attempt to normalise either. *)
              let seq1 =
                let normalise = crlf_endings1 && not crlf_endings2 in
                line_seq_of_in_channel ~normalise ic1 line1 in
              let seq2 =
                let normalise = crlf_endings2 && not crlf_endings1 in
                line_seq_of_in_channel ~normalise ic2 line2 in
              try
                if Seq.equal seq1 seq2 then
                  Same
                else
                  raise Exit
              with Exit ->
                (* Either the lines weren't equal, or the file which was being
                   normalised suddenly had a line which didn't end CRLF. In this
                   case, the files must differ since only one file is ever being
                   normalised, so the earlier lines differed too. *)
                Different

(* Version of Stdlib.really_input which stops at EOF, rather than raising
   an exception. *)
let really_input_up_to ic =
  let block_size = 8192 in
  let buf = Bytes.create block_size in
  let rec read pos =
    let bytes_read = input ic buf pos (block_size - pos) in
    let new_pos = pos + bytes_read in
    if bytes_read = 0 || new_pos = block_size then
      new_pos
    else
      read new_pos
  in
  let bytes_read = read 0 in
  if bytes_read = block_size then
    buf
  else
    Bytes.sub buf 0 bytes_read

let compare_binary_files bytes_to_ignore file1 file2 =
  Sys.with_input_file ~bin:true file1 @@ fun ic1 ->
  Sys.with_input_file ~bin:true file2 @@ fun ic2 ->
  seek_in ic1 bytes_to_ignore;
  seek_in ic2 bytes_to_ignore;
  let rec compare () =
    let block1 = really_input_up_to ic1 in
    let block2 = really_input_up_to ic2 in
    if block1 = block2 then
      if Bytes.length block1 > 0 then
        compare ()
      else
        Same
    else
      Different
  in
  compare ()

let compare_files ?(tool = default_comparison_tool) files =
  match tool with
  | External {tool_name; tool_flags; result_of_exitcode} ->
      let commandline = String.concat " "
      [
        tool_name;
        tool_flags;
        files.reference_filename;
        files.output_filename
      ] in
      let settings = Run_command.settings_of_commandline
        ~stdout_fname:Filename.null ~stderr_fname:Filename.null commandline in
      let status = Run_command.run settings in
      result_of_exitcode commandline status
  | Internal ignore ->
      match files.filetype with
        | Text ->
            (* bytes_to_ignore is silently ignored for text files *)
            compare_text_files ignore.lines
              files.reference_filename files.output_filename
        | Binary ->
            compare_binary_files ignore.bytes
                                 files.reference_filename files.output_filename

let check_file ?(tool = default_comparison_tool) files =
  if Sys.file_exists files.reference_filename
  then compare_files ~tool:tool files
  else begin
    if Sys.file_is_empty files.output_filename
    then Same
    else Unexpected_output
  end

let diff files =
  let temporary_file = Filename.temp_file "ocamltest" "diff" in
  let diff = Ocamltest_config.diff in
  let diff_flags = String.words Ocamltest_config.diff_flags in
  let diff_files = [files.reference_filename; files.output_filename] in
  let diff_commandline =
    Filename.quote_command diff ~stdout:temporary_file
      (diff_flags @ diff_files)
  in
  let result =
    match Sys.command diff_commandline with
    | 0 -> Ok "Inconsistent LF/CRLF line-endings"
    | 2 -> Stdlib.Error "diff"
    | _ -> Ok (Sys.string_of_file temporary_file)
  in
  Sys.force_remove temporary_file;
  result

let promote {filetype; reference_filename; output_filename} ignore_conf =
  match filetype, ignore_conf with
  | Text, {lines = skip_lines; _} ->
      Sys.with_output_file reference_filename @@ fun reference ->
      Sys.with_input_file output_filename @@ fun output ->
      for _ = 1 to skip_lines do
        try ignore (input_line output) with End_of_file -> ()
      done;
      Sys.copy_chan output reference
  | Binary, {bytes = skip_bytes; _} ->
      Sys.with_output_file ~bin:true reference_filename @@ fun reference ->
      Sys.with_input_file ~bin:true output_filename @@ fun output ->
      seek_in output skip_bytes;
      Sys.copy_chan output reference
