defmodule Tarakan.SecurityPosture do
  @moduledoc """
  Per-repository security responsiveness, derived from the public record.

  Every other score on Tarakan ranks contributors. This one ranks maintainers:
  how long a repository takes to close a finding once the record says it is
  real, and how much of the backlog is still open.

  Time-to-fix is measured from the moment a finding became credible
  (`verified_at`, falling back to when it was first reported) to `fixed_at`.
  Findings still open contribute to `open_count` and `oldest_open_days` but
  never to the median, so a repository cannot improve its number by ignoring
  reports - the open backlog is reported alongside it for exactly that reason.
  """

  import Ecto.Query, warn: false

  alias Tarakan.AnalyticsCache
  alias Tarakan.Repo
  alias Tarakan.Repositories.Repository
  alias Tarakan.Scans.{CanonicalFinding, FindingRegression}

  @doc """
  Posture for one repository.

  Returns `:median_days_to_fix` (nil until something has been fixed),
  `:fixed_count`, `:open_count`, `:verified_open_count`, `:oldest_open_days`,
  `:regressions_count`, and a coarse `:grade`.
  """
  def repository_posture(%Repository{id: repository_id}) do
    stats =
      Repo.one(
        from finding in CanonicalFinding,
          where: finding.repository_id == ^repository_id,
          select: %{
            median_seconds_to_fix:
              fragment(
                "percentile_cont(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (? - COALESCE(?, ?)))) FILTER (WHERE ? IS NOT NULL)",
                finding.fixed_at,
                finding.verified_at,
                finding.inserted_at,
                finding.fixed_at
              ),
            fixed_count: count(finding.fixed_at),
            open_count:
              fragment("COUNT(*) FILTER (WHERE ? IN ('open', 'verified'))", finding.status),
            verified_open_count:
              fragment("COUNT(*) FILTER (WHERE ? = 'verified')", finding.status),
            oldest_open_seconds:
              fragment(
                "EXTRACT(EPOCH FROM (NOW() - MIN(COALESCE(?, ?)) FILTER (WHERE ? IN ('open', 'verified'))))",
                finding.verified_at,
                finding.inserted_at,
                finding.status
              )
          }
      ) || %{}

    regressions_count =
      Repo.aggregate(
        from(regression in FindingRegression,
          where: regression.repository_id == ^repository_id
        ),
        :count
      )

    median_days = seconds_to_days(stats[:median_seconds_to_fix])

    %{
      median_days_to_fix: median_days,
      fixed_count: stats[:fixed_count] || 0,
      open_count: stats[:open_count] || 0,
      verified_open_count: stats[:verified_open_count] || 0,
      oldest_open_days: seconds_to_days(stats[:oldest_open_seconds]),
      regressions_count: regressions_count,
      grade: grade(median_days, stats[:fixed_count] || 0)
    }
  end

  @doc """
  Repositories ranked by responsiveness, fastest first.

  Only repositories with at least `:min_fixed` fixed findings (default 3) are
  ranked - a single lucky fix is not a track record.
  """
  def leaderboard(opts \\ []) do
    limit = opts |> Keyword.get(:limit, 25) |> min(100) |> max(1)
    min_fixed = Keyword.get(opts, :min_fixed, 3)

    Repo.all(
      from finding in CanonicalFinding,
        join: repository in Repository,
        on: repository.id == finding.repository_id,
        where: repository.listing_status == "listed",
        group_by: [repository.id, repository.host, repository.owner, repository.name],
        having: count(finding.fixed_at) >= ^min_fixed,
        order_by: [
          asc:
            fragment(
              "percentile_cont(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (? - COALESCE(?, ?)))) FILTER (WHERE ? IS NOT NULL)",
              finding.fixed_at,
              finding.verified_at,
              finding.inserted_at,
              finding.fixed_at
            )
        ],
        limit: ^limit,
        select: %{
          repository_id: repository.id,
          host: repository.host,
          owner: repository.owner,
          name: repository.name,
          fixed_count: count(finding.fixed_at),
          median_seconds_to_fix:
            fragment(
              "percentile_cont(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (? - COALESCE(?, ?)))) FILTER (WHERE ? IS NOT NULL)",
              finding.fixed_at,
              finding.verified_at,
              finding.inserted_at,
              finding.fixed_at
            ),
          open_count:
            fragment("COUNT(*) FILTER (WHERE ? IN ('open', 'verified'))", finding.status)
        }
    )
    |> Enum.map(fn row ->
      row
      |> Map.put(:median_days_to_fix, seconds_to_days(row.median_seconds_to_fix))
      |> Map.delete(:median_seconds_to_fix)
    end)
  end

  # Posture runs a percentile aggregate over a repository's whole finding set.
  # That is fine on a page view and not fine on an endpoint anyone can embed in
  # a README, so the public paths go through the shared cache, which also
  # coalesces concurrent misses into a single query.
  @posture_ttl_ms 60_000

  @doc """
  Cached `repository_posture/1` for public, unauthenticated callers.

  Returns `:unavailable` rather than falling back to a direct query when the
  cache refuses the work: under load the fallback would be the amplification
  the cache exists to prevent.
  """
  def cached_repository_posture(%Repository{id: id} = repository) do
    AnalyticsCache.fetch(
      {:repository_posture, id},
      fn -> repository_posture(repository) end,
      ttl_ms: min(AnalyticsCache.configured_ttl(), @posture_ttl_ms),
      on_unavailable: :unavailable
    )
  end

  @doc """
  Cached posture, degrading to an empty one instead of `:unavailable`.

  For render paths that need a map to interpolate and would rather show blanks
  than fail the whole page.
  """
  def cached_repository_posture!(%Repository{} = repository) do
    case cached_repository_posture(repository) do
      :unavailable -> empty_posture()
      posture -> posture
    end
  end

  @doc "A posture with nothing measured, used when the real one is unavailable."
  def empty_posture do
    %{
      median_days_to_fix: nil,
      fixed_count: 0,
      open_count: 0,
      verified_open_count: 0,
      oldest_open_days: nil,
      regressions_count: 0,
      grade: :unrated
    }
  end

  @doc """
  Cached badge for the public endpoint.

  Degrades to a neutral badge rather than querying when the cache refuses the
  work, so an embedded badge cannot be turned into a query amplifier.
  """
  def cached_badge(%Repository{} = repository) do
    case cached_repository_posture(repository) do
      :unavailable -> %{label: "security", message: "unavailable", color: "#5a6a72"}
      posture -> badge_for(posture)
    end
  end

  @doc "Badge label and colour for a repository's posture, computed fresh."
  def badge(%Repository{} = repository) do
    repository |> repository_posture() |> badge_for()
  end

  defp badge_for(posture) do
    {message, color} =
      cond do
        posture.median_days_to_fix ->
          {"#{format_days(posture.median_days_to_fix)} median fix", grade_color(posture.grade)}

        posture.open_count > 0 ->
          {"#{posture.open_count} open", "#b0762a"}

        true ->
          {"no findings", "#5a6a72"}
      end

    %{label: "security", message: message, color: color, posture: posture}
  end

  defp seconds_to_days(nil), do: nil

  defp seconds_to_days(seconds) do
    seconds
    |> to_float()
    |> Kernel./(86_400)
    |> Float.round(1)
    |> max(0.0)
  end

  defp to_float(%Decimal{} = value), do: Decimal.to_float(value)
  defp to_float(value) when is_integer(value), do: value / 1
  defp to_float(value) when is_float(value), do: value

  # Grades describe the median only; a repository with no fixes yet is ungraded
  # rather than badly graded, since there is nothing to measure.
  defp grade(nil, _fixed_count), do: :unrated
  defp grade(_median, 0), do: :unrated
  defp grade(median, _fixed_count) when median <= 7, do: :fast
  defp grade(median, _fixed_count) when median <= 30, do: :steady
  defp grade(median, _fixed_count) when median <= 90, do: :slow
  defp grade(_median, _fixed_count), do: :stale

  defp grade_color(:fast), do: "#3fae5a"
  defp grade_color(:steady), do: "#7aa02c"
  defp grade_color(:slow), do: "#b0762a"
  defp grade_color(:stale), do: "#b3402f"
  defp grade_color(_grade), do: "#5a6a72"

  @doc "Human-readable day count for badges and stat tiles."
  def format_days(nil), do: "-"
  def format_days(days) when days < 1, do: "<1d"
  def format_days(days) when days < 90, do: "#{round(days)}d"
  def format_days(days), do: "#{Float.round(days / 30, 1)}mo"
end
