defmodule ExCoveralls.Stats do
  @moduledoc """
  Provide calculation logics of coverage stats.
  """
  alias ExCoveralls.Cover
  alias ExCoveralls.Settings

  defmodule Source do
    @moduledoc """
    Stores count information for a file and all source lines.
    """

    defstruct filename: "", coverage: 0, sloc: 0, hits: 0, misses: 0, source: []
  end

  defmodule Line do
    @moduledoc """
    Stores count information and source for a single line.
    """

    defstruct coverage: nil, source: ""
  end

  @doc """
  Report the statistical information for the specified module.
  """
  def report(modules) do
    calculate_stats(modules)
    |> generate_coverage
    |> generate_source_info
    |> skip_files
    |> ExCoveralls.StopWords.filter
    |> ExCoveralls.Ignore.filter
  end

  @doc """
  Calculate the statistical information for the specified list of modules.
  It uses :cover.analyse for getting the information.
  """
  def calculate_stats(modules) do
    Enum.reduce(modules, Map.new, fn(module, dict) ->
      {:ok, lines} = Cover.analyze(module)
      analyze_lines(lines, dict)
    end)
  end

  defp analyze_lines(lines, module_hash) do
    Enum.reduce(lines, module_hash, fn({{module, line}, count}, module_hash) ->
      add_counts(module_hash, module, line, count)
    end)
  end

  defp add_counts(module_hash, module, line, count) do
    path = Cover.module_path(module)
    count_hash = Map.get(module_hash, path, Map.new)
    Map.put(module_hash, path, Map.put(count_hash, line, max(Map.get(count_hash, line, 0), count)))
  end

  @doc """
  Generate coverage, based on the pre-calculated statistic information.
  """
  def generate_coverage(hash) do
    keys = Map.keys(hash)
    Enum.map(keys, fn(file_path) ->
      total = get_source_line_count(file_path)
      {file_path, do_generate_coverage(Map.fetch!(hash, file_path), total, [])}
    end)
  end

  defp do_generate_coverage(_hash, 0, acc),   do: acc
  defp do_generate_coverage(hash, index, acc) do
    count = Map.get(hash, index, nil)

    do_generate_coverage(hash, index - 1, [count | acc])
  end

  @doc """
  Generate objects which stores source-file and coverage stats information.
  """
  def generate_source_info(coverage) do
    Enum.map(coverage, fn({file_path, stats}) ->
      %{
        name: file_path,
        source: read_source(file_path),
        coverage: stats
      }
    end)
  end

  @doc """
  Append the name of the sub app to the source info stats.
  """
  def append_sub_app_name(stats, sub_app_name, apps_path) do
    Enum.map(stats, fn %{name: name} = stat ->
      %{stat | name: "#{apps_path}/#{sub_app_name}/#{name}"}
    end)
  end

  @doc """
  Updates the paths to take into account the subdir and rootdir options
  """
  def update_paths(stats, options) do
    sub_dir_set? = not (options[:subdir] in [nil, ""])
    root_dir_set? = not (options[:rootdir] in [nil, ""])

    cond do
      sub_dir_set? ->
        stats
        |> Enum.map(fn m -> %{m | name: options[:subdir] <> Map.get(m, :name)} end)

      root_dir_set? ->
        stats
        |> Enum.map(fn m -> %{m | name: String.trim_leading(Map.get(m, :name), options[:rootdir])} end)
      true -> stats
    end
  end

  @doc """
  Returns total line counts of the specified source file.
  """
  def get_source_line_count(file_path) do
    read_source(file_path) |> count_lines
  end

  defp count_lines(string) do
    1 + (Regex.scan(~r/\n/i, string) |> length)
  end

  @doc """
  Returns the source file of the specified module.
  """
  def read_module_source(module) do
    Cover.module_path(module) |> read_source
  end

  @doc """
  Wrapper for reading the specified file.
  """
  def read_source(file_path) do
    ExCoveralls.PathReader.expand_path(file_path) |> File.read! |> trim_empty_prefix_and_suffix
  end

  def trim_empty_prefix_and_suffix(string) do
    string = Regex.replace(~r/\n\z/m, string, "")
    Regex.replace(~r/\A\n/m, string, "")
  end

  def skip_files(converage) do
    skip = Settings.get_skip_files
    Enum.reject(converage, fn cov ->
      Enum.any?(skip, &Regex.match?(&1, cov[:name]))
    end)
  end

  @doc """
  Summarizes source coverage details.
  """
  def source(stats, _patterns = nil), do: source(stats)
  def source(stats, _patterns = []),  do: source(stats)
  def source(stats, patterns) do
    Enum.filter(stats, fn(stat) -> String.contains?(stat[:name], patterns) end) |> source
  end

  def source(stats) do
    stats = Enum.sort(stats, fn(x, y) -> x[:name] <= y[:name] end)
    stats |> transform_cov
  end

  defp transform_cov(stats) do
    files = Enum.map(stats, &populate_file/1)
    {relevant, hits, misses} = Enum.reduce(files, {0,0,0}, &reduce_file_counts/2)
    covered = relevant - misses

    %{coverage: get_coverage(relevant, covered),
      sloc: relevant,
      hits: hits,
      misses: misses,
      files: files}
  end

  defp populate_file(stat) do
    coverage = stat[:coverage]
    source = map_source(stat[:source], coverage)
    relevant = Enum.count(coverage, fn e -> e != nil end)
    hits = Enum.reduce(coverage, 0, fn e, acc -> (e || 0) + acc end)
    misses = Enum.count(coverage, fn e -> e == 0 end)
    covered = relevant - misses

    %Source{filename: stat[:name],
      coverage: get_coverage(relevant, covered),
      sloc: relevant,
      hits: hits,
      misses: misses,
      source: source}
  end

  defp reduce_file_counts(%{sloc: sloc, hits: hits, misses: misses}, {s,h,m}) do
    {s+sloc, h+hits, m+misses}
  end

  def get_coverage(relevant, covered) do
    coverage_options = Settings.get_coverage_options()

    approximate_fn = case coverage_options do
      %{"floor_coverage" => false} -> &Float.round(&1, 1)
      _ -> &Float.floor(&1, 1)
    end

    case relevant do
      0 -> Settings.default_coverage_value(coverage_options)
      _ -> approximate_fn.((covered / relevant) * 100)
    end
  end

  defp map_source(source, coverage) do
    source
    |> String.split("\n")
    |> Enum.with_index()
    |> Enum.map(&(populate_source(&1,coverage)))
  end

  defp populate_source({line, i}, coverage) do
    %Line{coverage: Enum.at(coverage, i) , source: line}
  end

  @doc """
  Converts coverage stats to a map, which can be serialized to JSON
  for posting it to Coveralls or writing to excoveralls.json.
  """
  def serialize(stats) do
    Enum.map(stats, &Map.take(&1, [:name, :source, :coverage]))
  end

  @doc """
  Exit the process with a status of 1 if coverage is below the minimum.
  """
  def ensure_minimum_coverage(stats) do
    coverage_options = Settings.get_coverage_options
    minimum_coverage = coverage_options["minimum_coverage"] || 0
    if minimum_coverage > 0, do: check_coverage_threshold(stats, minimum_coverage)
  end

  defp check_coverage_threshold(stats, minimum_coverage) do
    result = source(stats)
    if result.coverage < minimum_coverage do
      message = "FAILED: Expected minimum coverage of #{minimum_coverage}%, got #{result.coverage}%."
      IO.puts IO.ANSI.format([:red, :bright, message])
      exit({:shutdown, 1})
    end
  end

end
