defmodule TarakanWeb.RepositoryCommitsLive do
  @moduledoc """
  Commit history of a repository's branches, plus a per-commit detail view
  with the full message and patch.

  Every listed commit is reachable from a published branch tip, which is
  exactly the set `RepositoryCode.authorize_public_commit/2` allows in the
  code browser - so each commit links straight into the pinned source view.
  """

  use TarakanWeb, :live_view

  alias Tarakan.Repositories
  alias Tarakan.Repositories.Repository
  alias Tarakan.RepositoryCode
  alias Tarakan.RepositoryCode.Commit
  alias TarakanWeb.RepositoryPaths

  @page_size 50
  @max_diff_lines 5_000
  @commit_sha_pattern ~r/^[0-9a-f]{40}$/

  @impl true
  def mount(params, _session, socket) do
    case repository_from_params(params, socket) do
      %Repository{} = repository ->
        if connected?(socket) do
          Repositories.subscribe()
        end

        {:ok,
         socket
         |> assign(:repository, repository)
         |> assign(:view_state, :loading)
         |> assign(:error_kind, nil)
         |> assign(:limit, @page_size)
         |> assign(:has_more?, false)
         |> assign(:branch_options, [])
         |> assign(:selected_branch, repository.default_branch)
         |> assign(:commit_sha, nil)
         |> assign(:commit, nil)
         |> maybe_load_branch_options()
         |> stream_configure(:commits, dom_id: &"commit-#{&1.sha}")
         |> stream(:commits, [])}

      nil ->
        raise Ecto.NoResultsError, queryable: Repository
    end
  end

  @impl true
  def handle_params(params, _uri, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  @impl true
  def handle_event("retry", _params, socket) do
    {:noreply,
     socket
     |> assign(:view_state, :loading)
     |> assign(:error_kind, nil)
     |> maybe_load()}
  end

  def handle_event("load_more", _params, socket) do
    {:noreply,
     socket
     |> assign(:limit, socket.assigns.limit + @page_size)
     |> assign(:view_state, :loading)
     |> maybe_load_commits()}
  end

  def handle_event("select_branch", %{"branch" => branch}, socket) do
    branch = String.trim(to_string(branch || ""))

    if socket.assigns.live_action == :index and branch != "" do
      {:noreply,
       socket
       |> assign(:selected_branch, branch)
       |> assign(:limit, @page_size)
       |> assign(:view_state, :loading)
       |> maybe_load_commits()}
    else
      {:noreply, socket}
    end
  end

  @impl true
  def handle_async(:commits, {:ok, result}, socket) do
    case result do
      {:ok, %{commits: commits}} ->
        {:noreply,
         socket
         |> assign(:view_state, :ready)
         |> assign(:error_kind, nil)
         |> assign(:has_more?, length(commits) >= socket.assigns.limit)
         |> stream(:commits, commits, reset: true)}

      {:error, reason} ->
        {:noreply,
         socket
         |> assign(:view_state, :error)
         |> assign(:error_kind, reason)
         |> stream(:commits, [], reset: true)}
    end
  end

  def handle_async(:commits, {:exit, _reason}, socket) do
    {:noreply,
     socket
     |> assign(:view_state, :error)
     |> assign(:error_kind, :unavailable)}
  end

  def handle_async(:commit, {:ok, result}, socket) do
    case result do
      {:ok, %Commit{} = commit} ->
        {:noreply,
         socket
         |> assign(:view_state, :ready)
         |> assign(:error_kind, nil)
         |> assign(:commit, commit)
         |> assign(
           :page_title,
           "#{commit.subject} · #{socket.assigns.repository.owner}/#{socket.assigns.repository.name}"
         )}

      {:error, reason} ->
        {:noreply,
         socket
         |> assign(:view_state, :error)
         |> assign(:error_kind, reason)
         |> assign(:commit, nil)}
    end
  end

  def handle_async(:commit, {:exit, _reason}, socket) do
    {:noreply,
     socket
     |> assign(:view_state, :error)
     |> assign(:error_kind, :unavailable)}
  end

  @impl true
  def handle_info(
        {:repository_record_updated, %Repository{id: repository_id}},
        %{assigns: %{repository: %Repository{id: repository_id} = repository}} = socket
      ) do
    if Repositories.get_visible_repository(
         repository.host,
         repository.owner,
         repository.name,
         socket.assigns.current_scope
       ) do
      {:noreply, socket}
    else
      {:noreply, push_navigate(socket, to: "/")}
    end
  end

  def handle_info({event, %Repository{}}, socket)
      when event in [:repository_registered, :repository_record_updated],
      do: {:noreply, socket}

  defp apply_action(socket, :index, _params) do
    socket
    |> assign(
      :page_title,
      "Commits · #{socket.assigns.repository.owner}/#{socket.assigns.repository.name}"
    )
    |> assign(:view_state, :loading)
    |> assign(:error_kind, nil)
    |> assign(:commit_sha, nil)
    |> assign(:commit, nil)
    |> maybe_load_commits()
  end

  defp apply_action(socket, :show, %{"commit_sha" => commit_sha}) do
    if Regex.match?(@commit_sha_pattern, commit_sha) do
      socket
      |> assign(:view_state, :loading)
      |> assign(:error_kind, nil)
      |> assign(:commit_sha, commit_sha)
      |> assign(:commit, nil)
      |> stream(:commits, [], reset: true)
      |> maybe_load_commit()
    else
      socket
      |> assign(:view_state, :error)
      |> assign(:error_kind, :not_found)
      |> assign(:commit_sha, nil)
      |> assign(:commit, nil)
    end
  end

  defp maybe_load(socket) do
    case socket.assigns.live_action do
      :index -> maybe_load_commits(socket)
      :show -> maybe_load_commit(socket)
    end
  end

  defp maybe_load_commits(socket) do
    if connected?(socket) do
      repository = socket.assigns.repository
      limit = socket.assigns.limit
      branch = selected_branch(socket)

      start_async(socket, :commits, fn ->
        RepositoryCode.list_commits(repository, limit, branch: branch)
      end)
    else
      socket
    end
  end

  defp maybe_load_commit(socket) do
    if connected?(socket) and is_binary(socket.assigns.commit_sha) do
      repository = socket.assigns.repository
      commit_sha = socket.assigns.commit_sha

      start_async(socket, :commit, fn -> RepositoryCode.show_commit(repository, commit_sha) end)
    else
      socket
    end
  end

  # The default branch goes through the default-tip resolution; any other
  # picker choice names the branch explicitly.
  defp selected_branch(socket) do
    case socket.assigns.selected_branch do
      branch when is_binary(branch) and branch != "" -> branch
      _other -> nil
    end
  end

  defp maybe_load_branch_options(socket) do
    if connected?(socket) do
      case RepositoryCode.list_branches(socket.assigns.repository) do
        {:ok, branches} -> assign(socket, :branch_options, branches)
        {:error, _reason} -> socket
      end
    else
      socket
    end
  end

  defp repository_from_params(params, socket) do
    with {:ok, host, owner, name} <- normalize_params(params),
         %Repository{} = repository <-
           Repositories.get_visible_repository(host, owner, name, socket.assigns.current_scope) do
      repository
    else
      _not_visible -> nil
    end
  end

  defp normalize_params(%{"host" => slug, "owner" => owner, "name" => name}) do
    with {:ok, host} <- Tarakan.Hosts.host_for_slug(slug) do
      {:ok, host, owner, name}
    end
  end

  # Bare GitHub-style route (/owner/name/commits...). A host-like first
  # segment means the bare pattern swallowed a remote path - a repository
  # literally named "commits" - so shift the params, same corner as
  # "code"/"security".
  defp normalize_params(%{"owner" => owner, "name" => name}) do
    if Tarakan.Hosts.host_segment?(owner) do
      normalize_params(%{"host" => owner, "owner" => name, "name" => "commits"})
    else
      {:ok, Repository.hosted_host(), owner, name}
    end
  end

  defp normalize_params(_params), do: :error

  defp short_sha(sha) when is_binary(sha), do: binary_part(sha, 0, 7)

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

  defp commit_datetime(_other), do: "unknown date"

  defp diff_lines(nil), do: []

  defp diff_lines(patch) do
    patch
    |> String.split("\n")
    |> Enum.take(@max_diff_lines)
    |> Enum.map(&{diff_line_type(&1), &1})
  end

  defp diff_line_over_limit?(nil), do: false
  defp diff_line_over_limit?(patch), do: length(String.split(patch, "\n")) > @max_diff_lines

  defp diff_line_type("+++" <> _rest), do: :meta
  defp diff_line_type("---" <> _rest), do: :meta
  defp diff_line_type("+" <> _rest), do: :add
  defp diff_line_type("-" <> _rest), do: :del
  defp diff_line_type("@@" <> _rest), do: :hunk
  defp diff_line_type(_line), do: :context

  defp diff_line_class(:add), do: "text-quote"
  defp diff_line_class(:del), do: "text-signal"
  defp diff_line_class(:hunk), do: "text-ink-faint"
  defp diff_line_class(:meta), do: "text-ink-faint"
  defp diff_line_class(:context), do: "text-ink"

  defp error_title(:empty_repository), do: "No commits yet"
  defp error_title(:not_found), do: "Commit not found"
  defp error_title(_other), do: "History unavailable"

  defp error_message(:empty_repository),
    do: "Nothing has been pushed to this repository yet."

  defp error_message(:not_found),
    do: "This commit is not reachable from any published branch."

  defp error_message(_other),
    do: "The commit history could not be read right now. Try again in a moment."
end
