defmodule TarakanWeb.FindingLive.Show do
  use TarakanWeb, :live_view

  alias Tarakan.Discussion
  alias Tarakan.FindingMemory
  alias Tarakan.Policy
  alias Tarakan.Reputation
  alias Tarakan.Scans
  alias Tarakan.Scans.Finding
  alias Tarakan.Work

  @impl true
  def mount(%{"public_id" => public_id}, _session, socket) do
    {scan, finding} = fetch_finding!(socket, public_id)

    if connected?(socket) do
      Scans.subscribe(scan.repository_id)
      Discussion.subscribe(finding.id)
      Reputation.subscribe()
    end

    public_url = TarakanWeb.Endpoint.url() <> ~p"/findings/#{finding.public_id}"

    {:ok,
     socket
     |> assign(:page_title, finding.title)
     |> assign(:meta_description, meta_description(scan, finding))
     |> assign(:canonical_path, ~p"/findings/#{finding.public_id}")
     |> assign(:public_url, public_url)
     |> assign(
       :open_bounties,
       Tarakan.Market.open_bounties_for_target(:finding, finding.canonical_finding_id)
     )
     |> assign(:og_type, "article")
     |> assign(:first_finder, FindingMemory.first_finder(finding.canonical_finding_id))
     |> assign(:json_ld, finding_json_ld(scan, finding))
     |> assign(:comment_body, "")
     |> assign(:reply_to, nil)
     |> assign(:can_vote, can_vote?(socket))
     |> assign_record(scan, finding)
     |> load_comments()
     |> load_votes()}
  end

  @impl true
  def handle_event(
        "record_finding_verdict",
        %{"verdict" => verdict} = params,
        %{assigns: %{current_scope: %{account: account}}} = socket
      )
      when not is_nil(account) do
    attrs = %{
      "commit_sha" => socket.assigns.scan.commit_sha,
      "verdict" => verdict,
      "provenance" => "human",
      "notes" => params["notes"]
    }

    case FindingMemory.record_check(
           socket.assigns.current_scope,
           socket.assigns.repository,
           socket.assigns.finding.canonical_finding.public_id,
           attrs
         ) do
      {:ok, _check, _canonical} ->
        {:noreply, socket |> refresh_record() |> load_votes()}

      {:error, :conflict_of_interest} ->
        {:noreply, put_flash(socket, :error, "You cannot verify a finding you submitted.")}

      {:error, :unauthorized} ->
        {:noreply, put_flash(socket, :error, "You are not authorized to verify this finding.")}

      {:error, %Ecto.Changeset{errors: [{_field, {message, _meta}} | _]}} ->
        {:noreply, put_flash(socket, :error, "Finding check not recorded: #{message}.")}

      {:error, _reason} ->
        {:noreply, put_flash(socket, :error, "Finding check could not be recorded.")}
    end
  end

  def handle_event("record_finding_verdict", _params, socket) do
    {:noreply, redirect(socket, to: ~p"/accounts/log-in")}
  end

  def handle_event(
        "record_vendor_notification",
        %{"vendor_notification" => %{"notified_on" => notified_on}},
        %{assigns: %{current_scope: %{account: account}}} = socket
      )
      when not is_nil(account) do
    case parse_notified_on(notified_on) do
      {:ok, notified_at} ->
        case FindingMemory.record_vendor_notification(
               socket.assigns.current_scope,
               socket.assigns.repository,
               socket.assigns.finding.canonical_finding.public_id,
               %{"vendor_notified_at" => notified_at}
             ) do
          {:ok, _canonical} ->
            Scans.broadcast_refresh(socket.assigns.scan)
            {:noreply, refresh_record(socket)}

          {:error, :unauthorized} ->
            {:noreply,
             put_flash(socket, :error, "You are not authorized to record vendor notification.")}

          {:error, _reason} ->
            {:noreply, put_flash(socket, :error, "Vendor notification could not be recorded.")}
        end

      :error ->
        {:noreply, put_flash(socket, :error, "Enter a valid notification date.")}
    end
  end

  def handle_event("open_finding_job", %{"kind" => kind}, socket) do
    finding = socket.assigns.finding.canonical_finding

    case Work.open_finding_job(socket.assigns.current_scope, finding, kind) do
      {:ok, :skipped_duplicate} ->
        {:noreply, put_flash(socket, :info, "That job is already open on this finding.")}

      {:ok, task} ->
        {:noreply,
         socket
         |> put_flash(:info, "Job opened.")
         |> push_navigate(to: ~p"/jobs/#{task.id}")}

      {:error, :unauthorized} ->
        {:noreply, put_flash(socket, :error, "Not authorized to open jobs here.")}

      {:error, _reason} ->
        {:noreply, put_flash(socket, :error, "Could not open that job.")}
    end
  end

  def handle_event("record_vendor_notification", _params, socket) do
    {:noreply, redirect(socket, to: ~p"/accounts/log-in")}
  end

  def handle_event("reply_to", %{"parent" => parent_id}, socket) do
    {:noreply, assign(socket, :reply_to, parent_id)}
  end

  def handle_event("cancel_reply", _params, socket) do
    {:noreply, assign(socket, :reply_to, nil)}
  end

  def handle_event(
        "post_comment",
        %{"body" => body} = params,
        %{assigns: %{current_scope: %{account: account}}} = socket
      )
      when not is_nil(account) do
    attrs = %{"body" => body, "parent_id" => params["parent_id"]}

    case Discussion.create_comment(socket.assigns.current_scope, socket.assigns.finding, attrs) do
      {:ok, _comment} ->
        {:noreply,
         socket
         |> assign(:comment_body, "")
         |> assign(:reply_to, nil)
         |> load_comments()}

      {:error, :too_deep} ->
        {:noreply, put_flash(socket, :error, "This thread is too deeply nested to reply to.")}

      {:error, reason} when reason in [:invalid_parent, :not_found] ->
        {:noreply, put_flash(socket, :error, "That comment is no longer available.")}

      {:error, :unauthorized} ->
        {:noreply,
         put_flash(socket, :error, "Your account cannot post to the discussion right now.")}

      {:error, %Ecto.Changeset{}} ->
        {:noreply, put_flash(socket, :error, "Write a comment before posting.")}
    end
  end

  def handle_event("post_comment", _params, socket) do
    {:noreply, redirect(socket, to: ~p"/accounts/log-in")}
  end

  def handle_event(
        "remove_comment",
        %{"id" => id, "reason" => reason},
        %{assigns: %{current_scope: %{account: account}}} = socket
      )
      when not is_nil(account) do
    with {:ok, comment} <- Discussion.get_comment(id),
         {:ok, _removed} <-
           Discussion.remove_comment(socket.assigns.current_scope, comment, %{
             "removed_reason" => reason
           }) do
      {:noreply, load_comments(socket)}
    else
      _error ->
        {:noreply, put_flash(socket, :error, "The comment could not be removed.")}
    end
  end

  def handle_event(
        "vote",
        %{"type" => subject_type, "id" => subject_id, "vote" => value},
        %{assigns: %{current_scope: %{account: account}}} = socket
      )
      when not is_nil(account) do
    with {:ok, subject_id} <- normalize_id(subject_id),
         {:ok, value} <- normalize_vote(value) do
      case Reputation.cast_vote(
             socket.assigns.current_scope,
             subject_type,
             subject_id,
             value
           ) do
        {:ok, _summary} ->
          {:noreply, socket |> load_votes() |> load_comments()}

        {:error, :own_content} ->
          {:noreply, put_flash(socket, :error, "You cannot vote on your own contribution.")}

        {:error, _reason} ->
          {:noreply, put_flash(socket, :error, "Your vote could not be recorded.")}
      end
    else
      _invalid -> {:noreply, put_flash(socket, :error, "Your vote could not be recorded.")}
    end
  end

  def handle_event("vote", _params, socket) do
    {:noreply, redirect(socket, to: ~p"/accounts/log-in")}
  end

  @impl true
  def handle_info({:scan_updated, %{id: scan_id}}, %{assigns: %{scan: %{id: scan_id}}} = socket) do
    {:noreply, refresh_record(socket)}
  end

  def handle_info({event, _comment}, socket)
      when event in [:comment_posted, :comment_removed] do
    {:noreply, socket |> load_comments() |> load_votes()}
  end

  def handle_info({:vote_changed, _type, _id}, socket) do
    {:noreply, load_votes(socket)}
  end

  def handle_info(_event, socket), do: {:noreply, socket}

  defp load_comments(socket) do
    comments = Discussion.list_comments(socket.assigns.current_scope, socket.assigns.finding)

    socket
    |> assign(:comments, comments)
    |> assign(:comment_count, Enum.count(flatten_comments(comments)))
    |> assign(
      :can_moderate_comments,
      Discussion.can_moderate?(socket.assigns.current_scope, socket.assigns.finding)
    )
  end

  defp load_votes(socket) do
    account_id = current_account_id(socket)

    comment_ids =
      socket.assigns |> Map.get(:comments, []) |> flatten_comments() |> Enum.map(& &1.id)

    socket
    |> assign(
      :finding_votes,
      Reputation.vote_summary(
        "canonical_finding",
        socket.assigns.finding.canonical_finding.id,
        account_id
      )
    )
    |> assign(:comment_votes, Reputation.vote_summaries("comment", comment_ids, account_id))
  end

  defp current_account_id(%{assigns: %{current_scope: %{account: %{id: id}}}}), do: id
  defp current_account_id(_socket), do: nil

  # phx-value params are client-controlled; never feed them to String.to_integer/1.
  defp normalize_id(id) when is_integer(id) and id > 0, do: {:ok, id}

  defp normalize_id(id) when is_binary(id) do
    case Integer.parse(id) do
      {parsed, ""} when parsed > 0 -> {:ok, parsed}
      _other -> {:error, :invalid_id}
    end
  end

  defp normalize_id(_id), do: {:error, :invalid_id}

  defp normalize_vote(value) when is_integer(value), do: {:ok, value}

  defp normalize_vote(value) when is_binary(value) do
    case Integer.parse(value) do
      {parsed, ""} -> {:ok, parsed}
      _other -> {:error, :invalid_vote}
    end
  end

  defp normalize_vote(_value), do: {:error, :invalid_vote}

  defp can_vote?(socket), do: not is_nil(current_account_id(socket))

  defp flatten_comments(comments) do
    Enum.flat_map(comments, fn comment -> [comment | flatten_comments(comment.replies)] end)
  end

  defp refresh_record(socket) do
    {scan, finding} = fetch_finding!(socket, socket.assigns.finding.public_id)
    assign_record(socket, scan, finding)
  end

  defp assign_record(socket, scan, finding) do
    socket
    |> assign(:scan, scan)
    |> assign(:finding, finding)
    |> assign(:repository, scan.repository)
    |> assign(
      :finding_checks,
      FindingMemory.list_checks(finding.canonical_finding.id, scan.commit_sha)
    )
    |> assign(
      :finding_trust,
      FindingMemory.trust_summary(finding.canonical_finding, scan.commit_sha)
    )
    |> assign(
      :can_check,
      FindingMemory.can_check?(
        socket.assigns.current_scope,
        scan.repository,
        finding.canonical_finding.public_id,
        scan.commit_sha
      )
    )
    |> assign(
      :can_record_vendor_notification,
      FindingMemory.can_record_vendor_notification?(
        socket.assigns.current_scope,
        scan.repository
      )
    )
    |> assign(:vendor_form, vendor_form(finding.canonical_finding))
    |> assign(:review_stake, Reputation.review_stake(scan))
    |> assign(
      :can_open_finding_job,
      Policy.allowed?(socket.assigns.current_scope, :propose_task, scan.repository)
    )
  end

  defp fetch_finding!(socket, public_id) do
    case Scans.get_finding(socket.assigns.current_scope, public_id) do
      # Attach the resolved scan so Discussion can read the repository id
      # without another query.
      {:ok, {scan, finding}} -> {scan, %{finding | scan: scan}}
      {:error, :not_found} -> raise Ecto.NoResultsError, queryable: Finding
    end
  end

  # Search snippets truncate around 160 characters; lead with the facts that
  # identify the finding before the free-text description.
  defp meta_description(scan, finding) do
    prefix =
      "#{String.capitalize(finding.severity)} in " <>
        "#{scan.repository.owner}/#{scan.repository.name} (#{finding.file_path}): "

    truncate(prefix <> String.replace(finding.description, ~r/\s+/, " "), 160)
  end

  defp finding_json_ld(scan, finding) do
    url = TarakanWeb.Endpoint.url() <> ~p"/findings/#{finding.public_id}"
    canonical = finding.canonical_finding

    %{
      "@context" => "https://schema.org",
      "@type" => "TechArticle",
      "headline" => finding.title,
      "description" => meta_description(scan, finding),
      "url" => url,
      "datePublished" => DateTime.to_iso8601(finding.inserted_at),
      "dateModified" => DateTime.to_iso8601(finding.updated_at),
      "author" => %{
        "@type" => "Organization",
        "name" => "Tarakan public security record"
      },
      "about" => %{
        "@type" => "SoftwareSourceCode",
        "name" => "#{scan.repository.owner}/#{scan.repository.name}",
        "codeRepository" => scan.repository.canonical_url
      },
      "keywords" =>
        Enum.join(
          [
            finding.severity,
            "security finding",
            scan.repository.owner,
            scan.repository.name,
            "open source"
          ],
          ", "
        )
    }
    |> maybe_put_json_ld("cwe", canonical && canonical.cwe_id)
    |> maybe_put_json_ld("cve", canonical && canonical.cve_id)
  end

  defp maybe_put_json_ld(map, _key, value) when value in [nil, false, ""], do: map
  defp maybe_put_json_ld(map, key, value), do: Map.put(map, key, value)

  # An empty date clears the recorded vendor notification; anything else must
  # be an ISO calendar date, stored at midnight UTC.
  defp parse_notified_on(value) when value in [nil, ""], do: {:ok, nil}

  defp parse_notified_on(value) when is_binary(value) do
    case Date.from_iso8601(value) do
      {:ok, date} -> {:ok, DateTime.new!(date, ~T[00:00:00], "Etc/UTC")}
      {:error, _reason} -> :error
    end
  end

  defp vendor_form(canonical) do
    notified_on =
      case canonical.vendor_notified_at do
        %DateTime{} = datetime -> datetime |> DateTime.to_date() |> Date.to_iso8601()
        _other -> ""
      end

    to_form(%{"notified_on" => notified_on}, as: :vendor_notification)
  end

  defp truncate(text, max) when byte_size(text) <= max, do: text

  defp truncate(text, max) do
    String.slice(text, 0, max - 1) <> "…"
  end

  defp record_time(%DateTime{} = datetime) do
    Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
  end

  defp stake_label(:at_risk), do: "at risk (awaiting review)"
  defp stake_label(:returned), do: "returned (verified)"
  defp stake_label(:slashed), do: "slashed (refuted)"

  defp finding_lines(%{line_start: nil}), do: ""
  defp finding_lines(%{line_start: line, line_end: line}), do: ":#{line}"

  defp finding_lines(%{line_start: line_start, line_end: line_end}),
    do: ":#{line_start}-#{line_end}"
end
