defmodule WhoNeedHelp.Help do
  @moduledoc "Authorization-aware request matching and completion workflow."

  import Ecto.Query
  alias Ecto.Multi
  alias WhoNeedHelp.Accounts
  alias WhoNeedHelp.Accounts.Scope
  alias WhoNeedHelp.Catalog
  alias WhoNeedHelp.Catalog.Category
  alias WhoNeedHelp.Help.{Assignment, HelpRequest}
  alias WhoNeedHelp.Pagination
  alias WhoNeedHelp.Push
  alias WhoNeedHelp.Repo
  alias WhoNeedHelp.Trust
  alias WhoNeedHelp.Trust.Block

  @topic "help:requests"

  def subscribe, do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @topic)

  def subscribe_request(id),
    do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "help:request:#{id}")

  def notify_request_updated(request_id) do
    request = get_request!(request_id)
    broadcast({:request_updated, request})
    :ok
  end

  def list_open_requests(%Scope{user: user}, filters \\ %{}) do
    paginate_open_requests(%Scope{user: user}, filters).entries
  end

  def paginate_open_requests(%Scope{user: user}, filters \\ %{}, options \\ []) do
    now = DateTime.utc_now(:second)
    limit = Pagination.limit(options)
    cursor = Pagination.cursor(options)

    HelpRequest
    |> where(
      [request],
      request.status == :open and request.expires_at > ^now and is_nil(request.hidden_at)
    )
    |> where(
      [request],
      request.requester_id not in subquery(
        from block in Block, where: block.blocker_id == ^user.id, select: block.blocked_id
      )
    )
    |> where(
      [request],
      request.requester_id not in subquery(
        from block in Block, where: block.blocked_id == ^user.id, select: block.blocker_id
      )
    )
    |> filter_open_requests(filters)
    |> after_open_request(cursor)
    |> order_by([request], asc: request.expires_at, asc: request.id)
    |> limit(^(limit + 1))
    |> Repo.all()
    |> preload_request_relations()
    |> Pagination.page(limit, &{&1.expires_at, &1.id})
  end

  def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required")

  def visible_open_request?(%Scope{user: user}, %HelpRequest{} = request, filters \\ %{}) do
    request.status == :open and is_nil(request.hidden_at) and
      DateTime.after?(request.expires_at, DateTime.utc_now(:second)) and
      request_filter_matches?(request, filters) and
      not Trust.blocked_between?(user.id, request.requester_id)
  end

  def list_my_requests(%Scope{user: user}) do
    paginate_my_requests(%Scope{user: user}).entries
  end

  def paginate_my_requests(%Scope{user: user}, options \\ []) do
    limit = Pagination.limit(options)
    cursor = Pagination.cursor(options)

    HelpRequest
    |> where([r], r.requester_id == ^user.id)
    |> before_my_request(cursor)
    |> order_by([request], desc: request.inserted_at, desc: request.id)
    |> limit(^(limit + 1))
    |> Repo.all()
    |> preload_request_relations()
    |> Pagination.page(limit, &{&1.inserted_at, &1.id})
  end

  defp after_open_request(query, nil), do: query

  defp after_open_request(query, {expires_at, id}) do
    where(
      query,
      [request],
      request.expires_at > ^expires_at or
        (request.expires_at == ^expires_at and request.id > ^id)
    )
  end

  defp before_my_request(query, nil), do: query

  defp before_my_request(query, {inserted_at, id}) do
    where(
      query,
      [request],
      request.inserted_at < ^inserted_at or
        (request.inserted_at == ^inserted_at and request.id < ^id)
    )
  end

  def get_request!(id) do
    HelpRequest
    |> Repo.get!(id)
    |> then(&preload_request_relations([&1], social_identities: true))
    |> hd()
  end

  def get_request(%Scope{user: user} = scope, id) do
    with {:ok, id} <- Ecto.UUID.cast(id),
         %HelpRequest{} = request <- Repo.get(HelpRequest, id) do
      request =
        [request]
        |> preload_request_relations(social_identities: true)
        |> hd()

      cond do
        request.requester_id == user.id ->
          {:ok, request}

        Accounts.moderator_authorized?(user) ->
          {:ok, request}

        not is_nil(request.hidden_at) ->
          {:error, :not_found}

        Trust.blocked_between?(user.id, request.requester_id) ->
          {:error, :not_found}

        request.assignment && participant?(scope, request.assignment) ->
          {:ok, request}

        request.status == :open and DateTime.after?(request.expires_at, DateTime.utc_now(:second)) ->
          {:ok, request}

        true ->
          {:error, :not_found}
      end
    else
      _invalid_or_missing -> {:error, :not_found}
    end
  end

  def get_assignment_for_participant(%Scope{} = scope, id) do
    with {:ok, id} <- Ecto.UUID.cast(id),
         %Assignment{} = assignment <-
           Assignment
           |> Repo.get(id)
           |> Repo.preload(:request),
         true <- participant?(scope, assignment) do
      {:ok, assignment}
    else
      false -> {:error, :forbidden}
      nil -> {:error, :not_found}
      :error -> {:error, :not_found}
    end
  end

  def change_request(%HelpRequest{} = request, attrs \\ %{}) do
    request
    |> HelpRequest.create_changeset(attrs)
    |> validate_structured_data()
  end

  def create_request(%Scope{user: user}, attrs) do
    result =
      with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :create_request) do
        Repo.transact(fn ->
          with {:ok, request} <-
                 %HelpRequest{requester_id: user.id}
                 |> HelpRequest.create_changeset(attrs)
                 |> validate_structured_data()
                 |> Repo.insert(),
               {:ok, _audit} <-
                 Trust.audit(user.id, "request.created", "request", request.id, %{
                   "urgency" => to_string(request.urgency),
                   "category_id" => request.category_id
                 }) do
            {:ok, request}
          end
        end)
      end

    with {:ok, request} <- result do
      broadcast({:request_created, get_request!(request.id)})
      {:ok, request}
    end
  end

  def accept_request(%Scope{user: helper}, request_id) do
    result =
      with {:ok, request_id} <- cast_id(request_id),
           {:ok, _limit} <- Trust.authorize_action(Scope.for_user(helper), :accept_request) do
        now = DateTime.utc_now(:second)
        code = handover_code(request_id)

        Repo.transact(fn ->
          request =
            HelpRequest
            |> where([r], r.id == ^request_id)
            |> lock("FOR UPDATE")
            |> Repo.one()

          if request do
            :ok = Trust.lock_user_pair(request.requester_id, helper.id)

            cond do
              request.requester_id == helper.id ->
                {:error, :own_request}

              not is_nil(request.hidden_at) ->
                {:error, :not_open}

              Trust.blocked_between?(request.requester_id, helper.id) ->
                {:error, :blocked}

              request.status != :open ->
                {:error, :not_open}

              DateTime.compare(request.expires_at, now) != :gt ->
                {:error, :expired}

              true ->
                with {:ok, assignment} <-
                       %Assignment{}
                       |> Assignment.changeset(%{
                         request_id: request.id,
                         helper_id: helper.id,
                         accepted_at: now,
                         handover_code_hash: code_hash(code)
                       })
                       |> Repo.insert(),
                     {:ok, _request} <-
                       request |> Ecto.Changeset.change(status: :matched) |> Repo.update(),
                     {:ok, _audit} <-
                       Trust.audit(helper.id, "request.accepted", "assignment", assignment.id, %{
                         "request_id" => request.id
                       }),
                     {:ok, _push_job} <-
                       Push.enqueue_request_accepted(
                         assignment.id,
                         request.id,
                         request.requester_id
                       ) do
                  {:ok, assignment}
                end
            end
          else
            {:error, :not_found}
          end
        end)
      else
        :error -> {:error, :not_found}
      end

    case result do
      {:ok, assignment} ->
        request = get_request!(assignment.request_id)
        broadcast({:request_updated, request})

        {:ok,
         Repo.preload(assignment,
           helper: Accounts.public_user_query(),
           request: []
         )}

      other ->
        other
    end
  end

  def start_assignment(%Scope{user: user}, assignment_id) do
    transition_assignment(user, assignment_id, :start)
  end

  def confirm_completion(%Scope{user: user}, assignment_id) do
    transition_assignment(user, assignment_id, :confirm)
  end

  def verify_handover(%Scope{user: user}, assignment_id, code) do
    with {:ok, assignment_id} <- cast_id(assignment_id),
         true <- valid_handover_code?(code),
         {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :verify_handover) do
      Repo.transact(fn ->
        with %Assignment{} = assignment <- locked_assignment(assignment_id) do
          request = Repo.get!(HelpRequest, assignment.request_id)
          :ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)

          cond do
            user.id != assignment.helper_id ->
              {:error, :forbidden}

            Trust.blocked_between?(assignment.helper_id, request.requester_id) ->
              {:error, :blocked}

            assignment.status not in [:accepted, :in_progress] or
                not is_nil(assignment.handover_verified_at) ->
              {:error, :invalid_transition}

            not Plug.Crypto.secure_compare(code_hash(code), assignment.handover_code_hash) ->
              {:error, :invalid_code}

            true ->
              now = DateTime.utc_now(:second)

              assignment
              |> Assignment.changeset(%{handover_verified_at: now})
              |> maybe_complete(request)
              |> audit_assignment_transition(user.id, "handover.verified", request.id)
          end
        else
          nil -> {:error, :not_found}
        end
      end)
    else
      :error -> {:error, :not_found}
      false -> {:error, :invalid_code}
    end
    |> after_transition()
  end

  def cancel_request(%Scope{user: user}, request_id) do
    with {:ok, request_id} <- cast_id(request_id),
         {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :cancel_request) do
      Repo.transact(fn ->
        request =
          HelpRequest
          |> where([r], r.id == ^request_id)
          |> lock("FOR UPDATE")
          |> Repo.one()

        cond do
          is_nil(request) ->
            {:error, :not_found}

          request.requester_id == user.id and request.status in [:open, :matched] ->
            now = DateTime.utc_now(:second)

            assignment =
              Assignment
              |> where([assignment], assignment.request_id == ^request.id)
              |> lock("FOR UPDATE")
              |> Repo.one()

            with {:ok, request} <-
                   request
                   |> Ecto.Changeset.change(status: :cancelled, cancelled_at: now)
                   |> Repo.update(),
                 {:ok, _assignment} <- cancel_assignment(assignment),
                 {:ok, _audit} <-
                   Trust.audit(user.id, "request.cancelled", "request", request.id) do
              {:ok, request}
            end

          true ->
            {:error, :forbidden}
        end
      end)
    else
      :error -> {:error, :not_found}
    end
    |> case do
      {:ok, request} ->
        WhoNeedHelp.Tracking.cleanup_finished_sessions()
        request = get_request!(request.id)
        broadcast({:request_updated, request})
        {:ok, request}

      other ->
        other
    end
  end

  def withdraw_assignment(%Scope{user: user}, assignment_id) do
    with {:ok, assignment_id} <- cast_id(assignment_id),
         {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :withdraw_assignment) do
      Repo.transact(fn ->
        with %Assignment{} = assignment <- locked_assignment(assignment_id) do
          request = Repo.get!(HelpRequest, assignment.request_id)

          if assignment.helper_id == user.id and
               assignment.status in [:accepted, :in_progress] do
            now = DateTime.utc_now(:second)

            with {:ok, assignment} <-
                   assignment
                   |> Assignment.changeset(%{status: :cancelled})
                   |> Repo.update(),
                 {:ok, _request} <-
                   request
                   |> Ecto.Changeset.change(status: :cancelled, cancelled_at: now)
                   |> Repo.update(),
                 {:ok, _audit} <-
                   Trust.audit(user.id, "assignment.withdrawn", "assignment", assignment.id, %{
                     "request_id" => request.id
                   }) do
              {:ok, assignment}
            end
          else
            {:error, :invalid_transition}
          end
        else
          nil -> {:error, :not_found}
        end
      end)
    else
      :error -> {:error, :not_found}
    end
    |> after_transition()
    |> case do
      {:ok, _assignment} = result ->
        WhoNeedHelp.Tracking.cleanup_finished_sessions()
        result

      other ->
        other
    end
  end

  def participant?(%Scope{user: user}, %Assignment{} = assignment) do
    request =
      case assignment.request do
        %HelpRequest{} = request -> request
        _ -> Repo.get!(HelpRequest, assignment.request_id)
      end

    user.id in [assignment.helper_id, request.requester_id]
  end

  defp preload_request_relations(requests, options \\ []) do
    requests = Repo.preload(requests, [:category, :assignment])

    user_ids =
      requests
      |> Enum.flat_map(fn request ->
        [request.requester_id, request.assignment && request.assignment.helper_id]
      end)
      |> Enum.reject(&is_nil/1)
      |> Enum.uniq()

    users =
      Accounts.public_user_query()
      |> where([user], user.id in ^user_ids)
      |> Repo.all()
      |> maybe_preload_social_identities(options)
      |> Map.new(&{&1.id, &1})

    Enum.map(requests, fn request ->
      assignment =
        case request.assignment do
          %Assignment{} = assignment ->
            %{assignment | helper: Map.fetch!(users, assignment.helper_id)}

          nil ->
            nil
        end

      request
      |> Map.put(:requester, Map.fetch!(users, request.requester_id))
      |> Map.put(:assignment, assignment)
      |> attach_assignment_request()
    end)
  end

  defp maybe_preload_social_identities(users, options) do
    if Keyword.get(options, :social_identities, false) do
      Repo.preload(users, social_identities: Accounts.public_social_identity_query())
    else
      users
    end
  end

  defp attach_assignment_request(%HelpRequest{assignment: %Assignment{} = assignment} = request) do
    %{request | assignment: %{assignment | request: request}}
  end

  defp attach_assignment_request(%HelpRequest{} = request), do: request

  def requester?(%Scope{user: user}, %HelpRequest{requester_id: id}), do: user.id == id
  def helper?(%Scope{user: user}, %Assignment{helper_id: id}), do: user.id == id

  def request_coordinates(%Scope{} = scope, %HelpRequest{} = request) do
    owner = request.requester_id == scope.user.id

    matched_participant =
      not owner and not is_nil(request.assignment) and participant?(scope, request.assignment) and
        request.assignment.status in [:accepted, :in_progress] and
        not Trust.blocked_between?(scope.user.id, request.requester_id)

    if owner or
         (matched_participant and request.location_visibility == :exact_for_active_match) do
      %Geo.Point{coordinates: {lng, lat}} = request.location
      %{latitude: lat, longitude: lng, exact: true}
    else
      HelpRequest.public_coordinates(request)
    end
  end

  def handover_code(request_id) do
    secret = Application.fetch_env!(:who_need_help, :handover_secret)
    digest = :crypto.mac(:hmac, :sha256, secret, request_id)

    digest
    |> :binary.decode_unsigned()
    |> rem(1_000_000)
    |> Integer.to_string()
    |> String.pad_leading(6, "0")
  end

  defp transition_assignment(user, assignment_id, action) do
    with {:ok, assignment_id} <- cast_id(assignment_id),
         {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), action) do
      Repo.transact(fn ->
        with %Assignment{} = assignment <- locked_assignment(assignment_id) do
          request = Repo.get!(HelpRequest, assignment.request_id)
          :ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
          now = DateTime.utc_now(:second)

          if Trust.blocked_between?(assignment.helper_id, request.requester_id) do
            {:error, :blocked}
          else
            case {action, assignment.status, user.id} do
              {:start, :accepted, helper_id} when helper_id == assignment.helper_id ->
                with {:ok, assignment} <-
                       assignment
                       |> Assignment.changeset(%{status: :in_progress, started_at: now})
                       |> Repo.update(),
                     {:ok, _} <-
                       request |> Ecto.Changeset.change(status: :in_progress) |> Repo.update(),
                     {:ok, _audit} <-
                       Trust.audit(
                         user.id,
                         "assignment.started",
                         "assignment",
                         assignment.id,
                         %{"request_id" => request.id}
                       ) do
                  {:ok, assignment}
                end

              {:confirm, status, user_id} when status in [:accepted, :in_progress] ->
                attrs =
                  cond do
                    user_id == assignment.helper_id -> %{helper_confirmed_at: now}
                    user_id == request.requester_id -> %{requester_confirmed_at: now}
                    true -> nil
                  end

                if attrs do
                  assignment
                  |> Assignment.changeset(attrs)
                  |> maybe_complete(request)
                  |> audit_assignment_transition(user.id, "assignment.confirmed", request.id)
                else
                  {:error, :forbidden}
                end

              _ ->
                {:error, :invalid_transition}
            end
          end
        else
          nil -> {:error, :not_found}
        end
      end)
    else
      :error -> {:error, :not_found}
    end
    |> after_transition()
  end

  defp maybe_complete(changeset, request) do
    assignment = Ecto.Changeset.apply_changes(changeset)

    if assignment.handover_verified_at && assignment.requester_confirmed_at &&
         assignment.helper_confirmed_at do
      now = DateTime.utc_now(:second)

      Multi.new()
      |> Multi.update(
        :assignment,
        Ecto.Changeset.change(changeset, status: :completed, completed_at: now)
      )
      |> Multi.update(
        :request,
        Ecto.Changeset.change(request, status: :completed, completed_at: now)
      )
      |> Repo.transaction()
      |> case do
        {:ok, %{assignment: assignment}} -> {:ok, assignment}
        {:error, _step, reason, _changes} -> {:error, reason}
      end
    else
      Repo.update(changeset)
    end
  end

  defp locked_assignment(id) do
    Assignment
    |> where([a], a.id == ^id)
    |> lock("FOR UPDATE")
    |> Repo.one()
  end

  defp valid_handover_code?(code) when is_binary(code),
    do: Regex.match?(~r/^\d{6}$/, code)

  defp valid_handover_code?(_code), do: false

  defp code_hash(code) when is_binary(code), do: :crypto.hash(:sha256, code)

  defp after_transition({:ok, assignment}) do
    request = get_request!(assignment.request_id)
    broadcast({:request_updated, request})
    if assignment.status == :completed, do: Trust.record_completion_signals(assignment)

    {:ok,
     Repo.preload(
       assignment,
       [helper: Accounts.public_user_query(), request: []],
       force: true
     )}
  end

  defp after_transition(other), do: other

  defp audit_assignment_transition({:ok, assignment}, actor_id, action, request_id) do
    with {:ok, _audit} <-
           Trust.audit(actor_id, action, "assignment", assignment.id, %{
             "request_id" => request_id,
             "status" => to_string(assignment.status)
           }),
         {:ok, _completion_audit} <-
           maybe_audit_completion(actor_id, assignment, request_id) do
      {:ok, assignment}
    end
  end

  defp audit_assignment_transition(other, _actor_id, _action, _request_id), do: other

  defp maybe_audit_completion(actor_id, %Assignment{status: :completed} = assignment, request_id) do
    Trust.audit(actor_id, "request.completed", "request", request_id, %{
      "assignment_id" => assignment.id
    })
  end

  defp maybe_audit_completion(_actor_id, _assignment, _request_id), do: {:ok, :not_completed}

  defp cancel_assignment(nil), do: {:ok, :no_assignment}

  defp cancel_assignment(assignment) do
    assignment
    |> Assignment.changeset(%{status: :cancelled})
    |> Repo.update()
  end

  defp broadcast(event) do
    Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, event)

    request =
      case event do
        {_, %HelpRequest{} = request} -> request
      end

    Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "help:request:#{request.id}", event)
  end

  defp filter_open_requests(query, filters) do
    query
    |> maybe_filter(:category_id, filters["category_id"] || filters[:category_id])
    |> maybe_filter(:urgency, filters["urgency"] || filters[:urgency])
  end

  defp request_filter_matches?(request, filters) do
    category_id = filters["category_id"] || filters[:category_id]
    urgency = filters["urgency"] || filters[:urgency]

    (category_id in [nil, ""] or to_string(request.category_id) == to_string(category_id)) and
      (urgency in [nil, ""] or to_string(request.urgency) == to_string(urgency))
  end

  defp maybe_filter(query, _field, value) when value in [nil, ""], do: query

  defp maybe_filter(query, :category_id, value) do
    case Ecto.UUID.cast(value) do
      {:ok, category_id} -> where(query, [request], request.category_id == ^category_id)
      :error -> where(query, [request], false)
    end
  end

  defp maybe_filter(query, :urgency, value) do
    case normalize_urgency(value) do
      {:ok, urgency} -> where(query, [request], request.urgency == ^urgency)
      :error -> where(query, [request], false)
    end
  end

  defp normalize_urgency(value) when value in [:now, "now"], do: {:ok, :now}
  defp normalize_urgency(value) when value in [:today, "today"], do: {:ok, :today}
  defp normalize_urgency(value) when value in [:scheduled, "scheduled"], do: {:ok, :scheduled}
  defp normalize_urgency(_value), do: :error

  defp cast_id(id) do
    case Ecto.UUID.cast(id) do
      {:ok, id} -> {:ok, id}
      :error -> :error
    end
  end

  defp validate_structured_data(changeset) do
    category_id = Ecto.Changeset.get_field(changeset, :category_id)

    case category_id && Repo.get(Category, category_id) do
      %Category{mode: :help, active: true} = category ->
        data = Ecto.Changeset.get_field(changeset, :structured_data)

        case Catalog.validate_structured_data(category, data) do
          {:ok, clean} ->
            Ecto.Changeset.put_change(changeset, :structured_data, clean)

          {:error, errors} ->
            Ecto.Changeset.add_error(
              changeset,
              :structured_data,
              Enum.join(errors, "; ")
            )
        end

      _ ->
        Ecto.Changeset.add_error(changeset, :category_id, "select an active help category")
    end
  end
end
