35.1 KiB · text History 4b6fe3b
defmodule OhMyExOhIrcd.IRC.Channel do
  @moduledoc """
  Cluster-wide singleton GenServer per channel name.

  Owns:

    * the authoritative *current* membership including per-member access
      level (`:founder`, `:admin`, `:op`, `:halfop`, `:voice`, `:member`);
    * the channel topic;
    * the persistent founder identity, if the channel is registered
      (see `OhMyExOhIrcd.Channels`).

  Does NOT own message persistence — that's in the per-node
  `MessageLog` CRDT. The Channel writes to the local MessageLog and
  reads from PubSub fan-out (so the same path delivers locally-typed
  messages and CRDT-replayed messages on heal).

  Started lazily by `ensure_started/1`.
  """

  use GenServer, restart: :transient
  require Logger

  alias OhMyExOhIrcd.Cluster.{HordeRegistry, HordeSupervisor}
  alias OhMyExOhIrcd.IRC.MessageLog

  @pubsub OhMyExOhIrcd.PubSub

  @type access :: :founder | :admin | :op | :halfop | :voice | :member

  @access_rank %{
    founder: 5,
    admin: 4,
    op: 3,
    halfop: 2,
    voice: 1,
    member: 0
  }

  # mode letter <-> internal access atom
  @mode_letters %{?q => :founder, ?a => :admin, ?o => :op, ?h => :halfop, ?v => :voice}

  @doc "Map of mode-letter charcode -> access atom (q/a/o/h/v)."
  def mode_letters, do: @mode_letters

  @doc "Single-char prefix for an access level (`@`, `+`, …) or empty string for :member."
  def prefix(:founder), do: "~"
  def prefix(:admin), do: "&"
  def prefix(:op), do: "@"
  def prefix(:halfop), do: "%"
  def prefix(:voice), do: "+"
  def prefix(_), do: ""

  @doc "Mode letter for an access (`q/a/o/h/v`) or `nil` for :member."
  def mode_letter(:founder), do: "q"
  def mode_letter(:admin), do: "a"
  def mode_letter(:op), do: "o"
  def mode_letter(:halfop), do: "h"
  def mode_letter(:voice), do: "v"
  def mode_letter(_), do: nil

  @doc "Numeric rank for an access level — higher means more privileged."
  def rank(level), do: Map.get(@access_rank, level, 0)

  @doc """
  Whether `name` is an acceptable channel name. We require one of the
  RFC channel-type prefixes this server routes (`#` or `&`) — i.e. no
  prefix-less names — a length of 2–50 including the prefix, and no
  spaces, commas, control chars, or `:` in the body. This is the single
  source of truth gating channel creation on JOIN (IRC + web).
  """
  @spec valid_name?(term()) :: boolean()
  def valid_name?(<<prefix, rest::binary>> = name)
      when prefix in [?#, ?&] and byte_size(name) <= 50 and rest != "",
      do: not String.contains?(rest, [" ", ",", ":", "\0", "\a", "\r", "\n", "\t"])

  def valid_name?(_), do: false

  ## API

  def ensure_started(name) do
    name = normalize(name)

    case HordeRegistry.whereis({:channel, name}) do
      pid when is_pid(pid) ->
        {:ok, pid}

      nil ->
        spec = %{
          id: {:channel, name},
          start: {__MODULE__, :start_link, [name]},
          restart: :transient
        }

        case Horde.DynamicSupervisor.start_child(HordeSupervisor, spec) do
          {:ok, pid} -> {:ok, pid}
          {:error, {:already_started, pid}} -> {:ok, pid}
          other -> other
        end
    end
  end

  def start_link(name) do
    GenServer.start_link(__MODULE__, name, name: HordeRegistry.via({:channel, name}))
  end

  @doc """
  Member `user_pid` joins as `nick`. `user_id` is the authenticated
  account id (or `nil` for anonymous) and is used to detect the channel
  founder. `prefix` is the joiner's `nick!user@host` form, used to
  check ban / exception / invite-only gates. `key` is the optional
  second argument to the IRC JOIN command and must match the channel
  key if `+k` is set. Returns `{:error, reason}` if the join is refused
  (`:banned`, `:invite_only`, `:bad_key`, `:channel_full`).
  """
  def join(name, nick, user_pid, user_id \\ nil, prefix \\ nil, key \\ nil),
    do: call(name, {:join, nick, user_pid, user_id, prefix, key})

  def part(name, nick, reason \\ ""), do: call(name, {:part, nick, reason})

  def privmsg(name, sender, text), do: call(name, {:message, sender, text, :privmsg})
  def notice(name, sender, text), do: call(name, {:message, sender, text, :notice})

  @doc "Snapshot of members with their access levels, sorted by rank desc then nick."
  def members(name), do: call(name, :members)

  @doc "Just the nicks (no access info)."
  def member_nicks(name), do: call(name, :member_nicks)

  @doc "Member info `%{access:, nick:}` or `nil`."
  def member_info(name, nick), do: call(name, {:member_info, nick})

  def topic(name), do: call(name, :topic)
  def topic_info(name), do: call(name, :topic_info)
  def set_topic(name, set_by, text), do: call(name, {:set_topic, set_by, text})

  @doc """
  Apply a heterogeneous list of mode changes:

      {:grant, access, target_nick}      # +q/+a/+o/+h/+v
      {:revoke, access, target_nick}     # -q/-a/-o/-h/-v
      {:add_mask, :ban | :except | :invite, mask}     # +b/+e/+I mask
      {:remove_mask, :ban | :except | :invite, mask}  # -b/-e/-I mask
      {:set_flag, :invite_only | :moderated | :no_external | :flood_exempt | :auto_join}
      {:clear_flag, …}

  Returns `{:ok, applied}` listing changes that actually took effect,
  or `{:error, :not_authorized | :not_on_channel | :no_such_member}`.
  """
  def apply_mode_changes(name, granter, changes, opts \\ []),
    do: call(name, {:mode_changes, granter, changes, Keyword.get(opts, :operator?, false)})

  @doc "List masks of a given type. Returns `[%{mask, set_by, set_at}]`."
  def list_masks(name, type) when type in [:ban, :except, :invite],
    do: call(name, {:list_masks, type})

  @doc "Snapshot the current channel-mode flags as a set of atoms."
  def flags(name), do: call(name, :flags)

  @doc "Snapshot `{flags, key, limit}` for the channel."
  def mode_snapshot(name), do: call(name, :mode_snapshot)

  @doc "Record an INVITE for `nick` so they can bypass +i on join."
  def record_invite(name, nick), do: call(name, {:record_invite, nick})

  @doc """
  Tell the running Channel singleton that the channel has just been
  registered to `founder_user_id` (called by ChanServ.REGISTER). The
  Channel flips its in-memory founder, persists current mode state, and
  promotes the founder if they're already in the channel.
  """
  def notify_registered(name, founder_user_id),
    do: call(name, {:notify_registered, founder_user_id})

  @doc "Kick `target` from the channel. `kicker` must be op or higher."
  def kick(name, kicker, target, reason),
    do: call(name, {:kick, kicker, target, reason})

  @doc """
  Privileged: set a member's access level directly, bypassing rank
  checks. Intended for services (ChanServ) and for tests; broadcasts a
  synthetic MODE line attributed to `granter` (defaults to the ChanServ
  nick) so subscribers see the change.
  """
  def service_set_access(name, nick, access, granter \\ "ChanServ"),
    do: call(name, {:service_set_access, nick, access, granter})

  @doc false
  def __seed_access__(name, nick, access),
    do: service_set_access(name, nick, access, "ChanServ")

  def topic_topic(name), do: "channel:" <> normalize(name)

  @doc """
  `%{topic:, member_count:, flags:, key:, limit:}` for a channel that is
  running, or `nil` when it isn't. Unlike the other accessors this never
  starts the channel — the admin console lists live channels, and
  looking one up must not resurrect a channel that was just closed.
  """
  @spec live_summary(String.t()) :: map() | nil
  def live_summary(name) do
    case HordeRegistry.whereis({:channel, normalize(name)}) do
      pid when is_pid(pid) -> GenServer.call(pid, :live_summary, 2_000)
      nil -> nil
    end
  catch
    :exit, _ -> nil
  end

  @doc """
  Stop the channel's singleton if, and only if, it has no members and is
  not registered — for the operator console's "close" control. The check
  and the stop happen inside the channel process, so a JOIN can't slip in
  between them: joins are serialized through the same mailbox, and one
  queued behind this call finds the process gone and starts a fresh
  channel (see `call/3`). Leftover persisted state for the channel (its
  `channel_topics` row) is deleted before the process exits.

  Stopping with `:normal` makes Horde drop the child (it's `:transient`)
  exactly as `Horde.DynamicSupervisor.terminate_child/2` would.

  Never starts a channel: returns `{:error, :not_running}` when there is
  nothing to close. Otherwise `:ok`, `{:error, :in_use}` or
  `{:error, :registered}`.
  """
  @spec close_if_empty(String.t()) :: :ok | {:error, :in_use | :registered | :not_running}
  def close_if_empty(name) do
    case HordeRegistry.whereis({:channel, normalize(name)}) do
      pid when is_pid(pid) -> GenServer.call(pid, :close_if_empty)
      nil -> {:error, :not_running}
    end
  catch
    :exit, _ -> {:error, :not_running}
  end

  # Calls the pid ensure_started/1 found or started, not the registry name
  # again: Horde's registry is synced between nodes as a CRDT, so right
  # after a channel starts (or moves) on another node the local copy may
  # not have the name yet, and a lookup by name exits with :noproc — which
  # took the caller's IRC connection down with it ("JOIN #jamz" on
  # 2026-10-27, the first day the pods were clustered). Once more if the
  # process went away under the call: a channel being handed to another
  # node exits while we hold its old pid.
  defp call(name, msg, attempts \\ 2) do
    {:ok, pid} = ensure_started(name)
    GenServer.call(pid, msg)
  catch
    :exit, {reason, _} when attempts > 1 and reason in [:noproc, :normal] ->
      call(name, msg, attempts - 1)

    :exit, {{:shutdown, _}, _} when attempts > 1 ->
      call(name, msg, attempts - 1)
  end

  ## Server

  @impl true
  def init(name) do
    Phoenix.PubSub.subscribe(@pubsub, MessageLog.channel_topic(name))

    {topic, set_by, set_at, founder_user_id, modes} = load_persisted(name)

    base = %{
      name: name,
      members: %{},
      topic: topic,
      topic_set_by: set_by,
      topic_set_at: set_at,
      founder_user_id: founder_user_id,
      # Boolean channel modes. We don't track +t explicitly — topic
      # auth is rank-based when the channel is registered.
      flags: MapSet.new(),
      # Downcased nicks that have a pending INVITE (consumed on JOIN).
      invited: MapSet.new(),
      # `+k` channel key — nil if unset.
      key: nil,
      # `+l` user limit — nil if unset.
      limit: nil,
      # Ban / except / invite masks: each a list of `{mask, set_by, set_at}`,
      # kept newest-first for predictable RPL_BANLIST ordering.
      ban_masks: [],
      except_masks: [],
      invite_masks: [],
      # Cached per-user access loaded from `channel_memberships`: `%{user_id => access}`.
      # Anonymous users (no account) aren't in here. Founder is implicit via
      # founder_user_id, not stored here.
      persisted_access: OhMyExOhIrcd.Channels.load_member_access(name)
    }

    {:ok, apply_loaded_modes(base, modes)}
  end

  defp apply_loaded_modes(state, nil), do: state

  defp apply_loaded_modes(state, modes) do
    %{
      state
      | key: modes.key,
        limit: modes.limit,
        flags: modes.flags,
        ban_masks: modes.ban_masks,
        except_masks: modes.except_masks,
        invite_masks: modes.invite_masks
    }
  end

  defp load_persisted(name) do
    rec = OhMyExOhIrcd.Channels.get(name)
    topic_rec = OhMyExOhIrcd.Channels.get_topic(name)

    founder_id = rec && rec.founder_user_id
    modes = rec && OhMyExOhIrcd.Channels.load_modes(name)

    # Topic source of truth is `channel_topics` (covers unregistered
    # channels too). Fall back to the registered channel's legacy
    # `channels.topic` for rows written before this table existed.
    {topic, set_by, set_at} = persisted_topic(topic_rec, rec, founder_id)

    {topic, set_by, set_at, founder_id, modes}
  rescue
    _ -> {"", "", 0, nil, nil}
  end

  defp persisted_topic(nil, nil, _founder_id), do: {"", "", 0}

  defp persisted_topic(nil, rec, founder_id) do
    legacy_set_at =
      case rec.updated_at do
        %DateTime{} = dt -> DateTime.to_unix(dt)
        _ -> 0
      end

    {rec.topic || "", if(founder_id, do: "ChanServ", else: ""), legacy_set_at}
  end

  defp persisted_topic(topic_rec, _rec, _founder_id),
    do: {topic_rec.topic || "", topic_rec.set_by || "", topic_rec.set_at || 0}

  # Same pid already joined: a no-op. A rejoin under a new pid skips the
  # entry checks; a fresh join has to pass all of them.
  defp join_check(%{pid: pid}, pid, _prefix, _key, _supplied_key, _state), do: :already_joined

  defp join_check(nil, _pid, prefix, key, supplied_key, state) do
    cond do
      prefix != nil and banned?(prefix, state) ->
        {:error, :banned}

      needs_invite?(state, key) ->
        {:error, :invite_only}

      state.key != nil and supplied_key != state.key ->
        {:error, :bad_key}

      state.limit != nil and map_size(state.members) >= state.limit ->
        {:error, :channel_full}

      true ->
        :ok
    end
  end

  defp join_check(_existing, _pid, _prefix, _key, _supplied_key, _state), do: :ok

  defp needs_invite?(state, key),
    do: MapSet.member?(state.flags, :invite_only) and not MapSet.member?(state.invited, key)

  defp admit_member(state, existing, nick, key, pid, user_id, prefix) do
    if existing, do: Process.demonitor(existing.ref, [:flush])
    ref = Process.monitor(pid)
    access = join_access(existing, user_id, state)
    {user, host} = parse_prefix(prefix, nick)

    member = %{
      pid: pid,
      ref: ref,
      access: access,
      nick: nick,
      user_id: user_id,
      user: user,
      host: host
    }

    members = Map.put(state.members, key, member)
    # Consume any pending invite.
    invited = MapSet.delete(state.invited, key)

    Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
      :irc_presence,
      {:join, member_prefix(member), state.name}
    })

    # Restore the access prefix on rejoin with a synthetic MODE line.
    if existing == nil and access not in [:member] do
      letter = mode_letter(access) || "v"

      Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
        :irc_presence,
        {:mode, service_prefix(), state.name, "+" <> letter, [nick]}
      })
    end

    %{state | members: members, invited: invited}
  end

  defp member_access(_state, nil), do: nil

  defp member_access(state, nick) do
    case Map.get(state.members, downcase(nick)) do
      %{access: a} -> a
      _ -> nil
    end
  end

  defp join_access(existing, user_id, state) do
    cond do
      existing ->
        existing.access

      user_id != nil and user_id == state.founder_user_id ->
        :founder

      user_id != nil and Map.has_key?(state.persisted_access, user_id) ->
        Map.get(state.persisted_access, user_id)

      true ->
        :member
    end
  end

  ## Persistence helper for per-user prefix access (channel_memberships).
  ## Updates the cached `persisted_access` map AND writes to the database
  ## (no-op for unregistered channels and anonymous users).
  defp persist_member_access(state, nil, _access), do: state

  defp persist_member_access(state, user_id, :member) do
    OhMyExOhIrcd.Channels.clear_member_access(state.name, user_id)
    %{state | persisted_access: Map.delete(state.persisted_access, user_id)}
  end

  defp persist_member_access(state, user_id, access) do
    # Don't persist founder via this path — it's stored on channels.founder_user_id.
    if access == :founder do
      state
    else
      OhMyExOhIrcd.Channels.set_member_access(state.name, user_id, access)
      %{state | persisted_access: Map.put(state.persisted_access, user_id, access)}
    end
  end

  ## Persistence helper — saves channel-level modes when the channel is
  ## registered. No-op for unregistered channels.
  defp maybe_persist_modes(state) do
    if state.founder_user_id do
      snapshot = %{
        key: state.key,
        limit: state.limit,
        flags: state.flags,
        ban_masks: state.ban_masks,
        except_masks: state.except_masks,
        invite_masks: state.invite_masks
      }

      OhMyExOhIrcd.Channels.persist_modes(state.name, snapshot)
    end

    state
  end

  @impl true
  def handle_call({:join, nick, pid, user_id, prefix, supplied_key}, _from, state) do
    key = downcase(nick)
    existing = Map.get(state.members, key)

    case join_check(existing, pid, prefix, key, supplied_key, state) do
      :already_joined ->
        {:reply, :ok, state}

      {:error, _reason} = err ->
        {:reply, err, state}

      :ok ->
        {:reply, :ok, admit_member(state, existing, nick, key, pid, user_id, prefix)}
    end
  end

  def handle_call({:part, nick, reason}, _from, state) do
    key = downcase(nick)
    {info, members} = Map.pop(state.members, key)
    if info, do: Process.demonitor(info.ref, [:flush])
    sender = if info, do: member_prefix(info), else: "#{nick}!#{nick}@unknown"

    Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
      :irc_presence,
      {:part, sender, state.name, reason}
    })

    {:reply, :ok, %{state | members: members}}
  end

  def handle_call({:message, sender, text, kind}, _from, state) do
    sender_nick = sender |> String.split("!", parts: 2) |> List.first()
    sender_access = member_access(state, sender_nick)

    cond do
      # +n: only members can speak.
      MapSet.member?(state.flags, :no_external) and sender_access == nil ->
        {:reply, {:error, :no_external}, state}

      # +m: only voice+ can speak.
      MapSet.member?(state.flags, :moderated) and
          (sender_access == nil or rank(sender_access) < rank(:voice)) ->
        {:reply, {:error, :moderated}, state}

      true ->
        MessageLog.append(state.name, sender, text, kind)
        {:reply, :ok, state}
    end
  end

  def handle_call({:list_masks, type}, _from, state) do
    list =
      case type do
        :ban -> state.ban_masks
        :except -> state.except_masks
        :invite -> state.invite_masks
      end

    {:reply, list, state}
  end

  def handle_call(:flags, _from, state), do: {:reply, state.flags, state}

  def handle_call(:mode_snapshot, _from, state),
    do: {:reply, %{flags: state.flags, key: state.key, limit: state.limit}, state}

  def handle_call(:live_summary, _from, state) do
    summary = %{
      topic: state.topic,
      member_count: map_size(state.members),
      flags: state.flags,
      key: state.key,
      limit: state.limit
    }

    {:reply, summary, state}
  end

  def handle_call({:record_invite, nick}, _from, state) do
    {:reply, :ok, %{state | invited: MapSet.put(state.invited, downcase(nick))}}
  end

  def handle_call({:notify_registered, founder_user_id}, _from, state) do
    state = %{state | founder_user_id: founder_user_id}
    # Capture current state so any modes set before registration survive.
    state = maybe_persist_modes(state)
    {:reply, :ok, state}
  end

  def handle_call(:close_if_empty, _from, state) do
    cond do
      map_size(state.members) > 0 ->
        {:reply, {:error, :in_use}, state}

      state.founder_user_id != nil ->
        {:reply, {:error, :registered}, state}

      true ->
        OhMyExOhIrcd.Channels.delete_unregistered_leftovers(state.name)
        {:stop, :normal, :ok, state}
    end
  end

  def handle_call(:members, _from, state) do
    list =
      state.members
      |> Map.values()
      |> Enum.sort_by(fn m -> {-rank(m.access), String.downcase(m.nick)} end)
      |> Enum.map(fn m ->
        %{
          nick: m.nick,
          access: m.access,
          user: Map.get(m, :user, m.nick),
          host: Map.get(m, :host, "unknown")
        }
      end)

    {:reply, list, state}
  end

  def handle_call(:member_nicks, _from, state),
    do: {:reply, Enum.map(state.members, fn {_, m} -> m.nick end), state}

  def handle_call({:member_info, nick}, _from, state) do
    case Map.get(state.members, downcase(nick)) do
      nil -> {:reply, nil, state}
      m -> {:reply, %{nick: m.nick, access: m.access}, state}
    end
  end

  def handle_call(:topic, _from, state), do: {:reply, state.topic, state}

  def handle_call(:topic_info, _from, state),
    do:
      {:reply, %{text: state.topic, set_by: state.topic_set_by, set_at: state.topic_set_at},
       state}

  def handle_call({:set_topic, set_by, text}, _from, state) do
    # Topic edit requires at least halfop on registered channels; on
    # un-registered channels any member can set it (RFC default).
    granter_rank =
      case Map.get(state.members, downcase(set_by)) do
        %{access: a} -> rank(a)
        _ -> 0
      end

    if state.founder_user_id != nil and granter_rank < rank(:halfop) do
      {:reply, {:error, :not_authorized}, state}
    else
      now = System.system_time(:second)

      state = %{
        state
        | topic: text,
          topic_set_by: set_by,
          topic_set_at: now
      }

      OhMyExOhIrcd.Channels.put_topic(state.name, set_by, text, now)

      Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
        :irc_presence,
        {:topic, sender_prefix(state, set_by), state.name, text}
      })

      {:reply, :ok, state}
    end
  end

  def handle_call({:mode_changes, granter, changes, operator?}, _from, state) do
    case Map.get(state.members, downcase(granter)) do
      nil ->
        {:reply, {:error, :not_on_channel}, state}

      %{access: granter_access} ->
        do_apply_modes(state, granter, granter_access, changes, operator?)
    end
  end

  def handle_call({:service_set_access, nick, access, granter}, _from, state) do
    case Map.get(state.members, downcase(nick)) do
      nil ->
        {:reply, {:error, :no_such_member}, state}

      member ->
        members = Map.put(state.members, downcase(nick), %{member | access: access})
        state = persist_member_access(%{state | members: members}, member.user_id, access)

        letter = mode_letter(access)
        sign = if access == :member, do: "-", else: "+"

        flag_letter = if access == :member, do: mode_letter(member.access) || "v", else: letter

        if flag_letter do
          Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
            :irc_presence,
            {:mode, sender_prefix(state, granter), state.name, sign <> flag_letter, [member.nick]}
          })
        end

        {:reply, :ok, state}
    end
  end

  def handle_call({:kick, kicker, target, reason}, _from, state) do
    case Map.get(state.members, downcase(kicker)) do
      nil ->
        {:reply, {:error, :not_on_channel}, state}

      %{access: kicker_access} = kicker_member ->
        case Map.get(state.members, downcase(target)) do
          nil ->
            {:reply, {:error, :no_such_member}, state}

          %{access: target_access, pid: pid, ref: ref, nick: tnick} ->
            cond do
              rank(kicker_access) < rank(:op) ->
                {:reply, {:error, :not_authorized}, state}

              kicker_access != :founder and rank(kicker_access) <= rank(target_access) ->
                {:reply, {:error, :not_authorized}, state}

              true ->
                Process.demonitor(ref, [:flush])
                members = Map.delete(state.members, downcase(target))

                Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
                  :irc_presence,
                  {:kick, member_prefix(kicker_member), state.name, tnick, reason}
                })

                send(pid, {:irc_kicked, state.name})
                {:reply, :ok, %{state | members: members}}
            end
        end
    end
  end

  defp do_apply_modes(state, granter, granter_access, changes, operator?) do
    granter_rank = rank(granter_access)

    {state, applied, error} =
      Enum.reduce_while(changes, {state, [], nil}, fn change, {st, acc, _err} ->
        case apply_one(st, granter, granter_access, granter_rank, operator?, change) do
          {:ok, st2, taken} -> {:cont, {st2, [taken | acc], nil}}
          {:noop, st2} -> {:cont, {st2, acc, nil}}
          {:error, reason} -> {:halt, {st, acc, reason}}
        end
      end)

    case {error, applied} do
      {nil, []} -> {:reply, {:ok, []}, state}
      {nil, _} -> finalize_mode_changes(state, granter, Enum.reverse(applied))
      {reason, _} -> {:reply, {:error, reason}, state}
    end
  end

  # Prefix grants (q/a/o/h/v).
  defp apply_one(state, granter, granter_access, granter_rank, _operator?, {op, access, target})
       when op in [:grant, :revoke] and is_atom(access) do
    case Map.get(state.members, downcase(target)) do
      nil ->
        {:error, :no_such_member}

      %{access: cur_access} = member ->
        target_rank = rank(cur_access)
        self? = downcase(target) == downcase(granter)
        founder? = granter_access == :founder

        cond do
          founder? -> apply_change(state, member, op, access)
          granter_rank < rank(access) -> {:error, :not_authorized}
          granter_rank <= target_rank and not self? -> {:error, :not_authorized}
          true -> apply_change(state, member, op, access)
        end
    end
  end

  # List modes (b/e/I), boolean flags (i/m/n/f), key (+k/-k), and limit
  # (+l/-l). All require halfop+ (founder bypass) — *except* two:
  #   * `+f` (flood_exempt) is founder-only, since it disables a built-in
  #     protection.
  #   * `+A` (auto_join) is IRC-operator-only (`operator?` vouched by the
  #     connection), since it force-joins every connecting user; channel
  #     rank is irrelevant for it.
  defp apply_one(state, granter, granter_access, granter_rank, operator?, change)
       when elem(change, 0) in [
              :add_mask,
              :remove_mask,
              :set_flag,
              :clear_flag,
              :set_key,
              :clear_key,
              :set_limit,
              :clear_limit
            ] do
    cond do
      auto_join_change?(change) ->
        if operator?,
          do: do_apply_non_prefix(state, granter, change),
          else: {:error, :not_authorized}

      flood_exempt_change?(change) and granter_access != :founder ->
        {:error, :not_authorized}

      granter_access == :founder or granter_rank >= rank(:halfop) ->
        do_apply_non_prefix(state, granter, change)

      true ->
        {:error, :not_authorized}
    end
  end

  defp flood_exempt_change?({:set_flag, :flood_exempt}), do: true
  defp flood_exempt_change?({:clear_flag, :flood_exempt}), do: true
  defp flood_exempt_change?(_), do: false

  defp auto_join_change?({:set_flag, :auto_join}), do: true
  defp auto_join_change?({:clear_flag, :auto_join}), do: true
  defp auto_join_change?(_), do: false

  defp apply_change(state, member, :grant, access), do: do_grant(state, member, access)
  defp apply_change(state, member, :revoke, access), do: do_revoke(state, member, access)

  defp do_apply_non_prefix(state, granter, {:add_mask, type, mask}) do
    key = mask_field(type)
    list = Map.fetch!(state, key)

    if Enum.any?(list, fn entry -> entry.mask == mask end) do
      {:noop, state}
    else
      entry = %{
        mask: mask,
        set_by: mask_setter(granter),
        set_at: System.system_time(:second)
      }

      {:ok, Map.put(state, key, [entry | list]), {:add_mask, type, mask}}
    end
  end

  defp do_apply_non_prefix(state, _granter, {:remove_mask, type, mask}) do
    key = mask_field(type)
    list = Map.fetch!(state, key)

    case Enum.find(list, fn entry -> entry.mask == mask end) do
      nil ->
        {:noop, state}

      _ ->
        new_list = Enum.reject(list, fn entry -> entry.mask == mask end)
        {:ok, Map.put(state, key, new_list), {:remove_mask, type, mask}}
    end
  end

  defp do_apply_non_prefix(state, _granter, {:set_flag, flag}) do
    if MapSet.member?(state.flags, flag) do
      {:noop, state}
    else
      {:ok, %{state | flags: MapSet.put(state.flags, flag)}, {:set_flag, flag}}
    end
  end

  defp do_apply_non_prefix(state, _granter, {:clear_flag, flag}) do
    if MapSet.member?(state.flags, flag) do
      {:ok, %{state | flags: MapSet.delete(state.flags, flag)}, {:clear_flag, flag}}
    else
      {:noop, state}
    end
  end

  defp do_apply_non_prefix(state, _granter, {:set_key, key}) do
    if state.key == key do
      {:noop, state}
    else
      {:ok, %{state | key: key}, {:set_key, key}}
    end
  end

  # `-k` per RFC takes an argument (the current key) — we accept the supplied
  # value to keep parsers happy but don't actually check it.
  defp do_apply_non_prefix(state, _granter, {:clear_key, _arg}) do
    if state.key == nil do
      {:noop, state}
    else
      {:ok, %{state | key: nil}, {:clear_key, "*"}}
    end
  end

  defp do_apply_non_prefix(state, _granter, {:set_limit, limit})
       when is_integer(limit) and limit > 0 do
    if state.limit == limit do
      {:noop, state}
    else
      {:ok, %{state | limit: limit}, {:set_limit, limit}}
    end
  end

  defp do_apply_non_prefix(state, _granter, {:set_limit, _bad}), do: {:noop, state}

  defp do_apply_non_prefix(state, _granter, {:clear_limit}) do
    if state.limit == nil do
      {:noop, state}
    else
      {:ok, %{state | limit: nil}, {:clear_limit}}
    end
  end

  # The `set_by` field in 367/348/346 numerics is conventionally the
  # nick of the granter (or `nick!user@host` if you have it). Callers
  # pass the bare nick; if anything else turns up we fall back to `*`.
  defp mask_setter(nil), do: "*"
  defp mask_setter(""), do: "*"
  defp mask_setter(s) when is_binary(s), do: s
  defp mask_setter(_), do: "*"

  defp mask_field(:ban), do: :ban_masks
  defp mask_field(:except), do: :except_masks
  defp mask_field(:invite), do: :invite_masks

  ## Ban / exception matching: standard IRC glob (`*` any, `?` one) against
  ## the joiner's `nick!user@host` prefix.

  defp banned?(prefix, state) do
    bans = Enum.any?(state.ban_masks, fn %{mask: m} -> match_glob?(prefix, m) end)

    excepted? =
      Enum.any?(state.except_masks, fn %{mask: m} -> match_glob?(prefix, m) end)

    bans and not excepted?
  end

  defp match_glob?(string, mask) do
    pattern =
      mask
      |> Regex.escape()
      |> String.replace("\\*", ".*")
      |> String.replace("\\?", ".")

    Regex.match?(~r/\A#{pattern}\z/i, string)
  end

  defp do_grant(state, member, access) do
    if member.access == access do
      {:noop, state}
    else
      new = %{member | access: access}
      members = Map.put(state.members, downcase(member.nick), new)
      state = persist_member_access(%{state | members: members}, member.user_id, access)
      {:ok, state, {:grant, access, member.nick}}
    end
  end

  defp do_revoke(state, member, access) do
    if member.access == access or rank(member.access) <= rank(access) do
      new = %{member | access: :member}
      members = Map.put(state.members, downcase(member.nick), new)
      state = persist_member_access(%{state | members: members}, member.user_id, :member)
      {:ok, state, {:revoke, access, member.nick}}
    else
      {:noop, state}
    end
  end

  defp finalize_mode_changes(state, granter, applied) do
    {flags, targets} = render_modes(applied)

    Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
      :irc_presence,
      {:mode, sender_prefix(state, granter), state.name, flags, targets}
    })

    # Persist if any of the applied changes were channel-level (not just
    # per-member prefix grants, which are transient).
    state =
      if Enum.any?(applied, &channel_level_change?/1) do
        maybe_persist_modes(state)
      else
        state
      end

    {:reply, {:ok, applied}, state}
  end

  defp channel_level_change?({op, _, _}) when op in [:grant, :revoke], do: false
  defp channel_level_change?(_), do: true

  defp render_modes(applied) do
    Enum.reduce(applied, {"", []}, fn change, {flags, targets} ->
      {sign, letter, arg} = render_change(change)
      {flags <> sign <> letter, targets ++ List.wrap(arg)}
    end)
  end

  defp render_change({:grant, access, nick}), do: {"+", mode_letter(access), nick}
  defp render_change({:revoke, access, nick}), do: {"-", mode_letter(access), nick}
  defp render_change({:add_mask, type, mask}), do: {"+", mask_letter(type), mask}
  defp render_change({:remove_mask, type, mask}), do: {"-", mask_letter(type), mask}
  defp render_change({:set_flag, flag}), do: {"+", flag_letter(flag), nil}
  defp render_change({:clear_flag, flag}), do: {"-", flag_letter(flag), nil}
  defp render_change({:set_key, key}), do: {"+", "k", key}
  defp render_change({:clear_key, arg}), do: {"-", "k", arg}
  defp render_change({:set_limit, n}), do: {"+", "l", Integer.to_string(n)}
  defp render_change({:clear_limit}), do: {"-", "l", nil}

  defp mask_letter(:ban), do: "b"
  defp mask_letter(:except), do: "e"
  defp mask_letter(:invite), do: "I"

  @doc false
  def flag_letter(:invite_only), do: "i"
  def flag_letter(:moderated), do: "m"
  def flag_letter(:no_external), do: "n"
  def flag_letter(:flood_exempt), do: "f"
  def flag_letter(:auto_join), do: "A"

  @doc false
  def flag_from_letter(?i), do: :invite_only
  def flag_from_letter(?m), do: :moderated
  def flag_from_letter(?n), do: :no_external
  def flag_from_letter(?f), do: :flood_exempt
  def flag_from_letter(?A), do: :auto_join
  def flag_from_letter(_), do: nil

  @impl true
  def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
    {key, member} =
      Enum.find(state.members, {nil, nil}, fn
        {_k, %{pid: ^pid}} -> true
        _ -> false
      end)

    if member do
      Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), {
        :irc_presence,
        {:quit, member_prefix(member), state.name, "Connection closed"}
      })

      {:noreply, %{state | members: Map.delete(state.members, key)}}
    else
      {:noreply, state}
    end
  end

  def handle_info({:irc_privmsg, _}, state), do: {:noreply, state}
  def handle_info({:irc_presence, _}, state), do: {:noreply, state}
  def handle_info(_, state), do: {:noreply, state}

  defp normalize(name), do: String.downcase(name)
  defp downcase(s) when is_binary(s), do: String.downcase(s)

  # Extract `{user, host}` from a `nick!user@host` prefix string. Falls
  # back to sensible defaults so callers never end up writing `*` in
  # WHO replies (which trips clients like irssi that assert host != nil).
  defp parse_prefix(nil, nick), do: {nick, "unknown"}
  defp parse_prefix("", nick), do: {nick, "unknown"}

  defp parse_prefix(prefix, nick) do
    case String.split(prefix, "!", parts: 2) do
      [_n, rest] ->
        case String.split(rest, "@", parts: 2) do
          [u, h] -> {u, h}
          [u] -> {u, "unknown"}
        end

      _ ->
        {nick, "unknown"}
    end
  end

  # Full `nick!user@host` prefix for a member. Presence broadcasts
  # (JOIN/PART/QUIT/KICK/…) must carry this full form — clients like
  # irssi take the host from the message prefix to populate their
  # nicklists and assert it's present (`nicklist_set_host: host != NULL`).
  defp member_prefix(m),
    do: "#{m.nick}!#{Map.get(m, :user) || m.nick}@#{Map.get(m, :host) || "unknown"}"

  # Same, but by nick lookup — for senders that may not be plain members
  # (services, or a setter that raced a part). Falls back to a synthetic
  # but well-formed prefix so the host token is never empty on the wire.
  defp sender_prefix(state, nick) do
    case Map.get(state.members, downcase(nick)) do
      nil ->
        if downcase(nick) == downcase(OhMyExOhIrcd.IRC.ChanServ.nick()) do
          service_prefix()
        else
          "#{nick}!#{nick}@unknown"
        end

      m ->
        member_prefix(m)
    end
  end

  defp service_prefix do
    "#{OhMyExOhIrcd.IRC.ChanServ.nick()}!service@#{OhMyExOhIrcd.IRC.ChanServ.host()}"
  end
end