81.7 KiB · text History 4b6fe3b
defmodule OhMyExOhIrcd.IRC.Connection do
  @moduledoc """
  Per-client ThousandIsland handler. Owns:

    * the connection's nick/user state (pre-registration → registered);
    * the set of channels it has joined;
    * a Horde.Registry entry under `{:user, nick}` so DMs from elsewhere
      can find this connection.

  Receives `{:irc_privmsg, entry}` and `{:irc_presence, …}` messages via
  Phoenix.PubSub once the connection has joined channels. Anything that
  needs to be delivered to the *client* on the wire flows through here.
  """

  use ThousandIsland.Handler
  require Logger

  alias OhMyExOhIrcd.Cluster.HordeRegistry
  alias OhMyExOhIrcd.IRC.{Auth, Channel, Listener, Message, MessageLog}

  @pubsub OhMyExOhIrcd.PubSub

  defmodule State do
    @moduledoc false
    defstruct nick: nil,
              user: nil,
              realname: nil,
              # Display host shown in the `nick!user@host` prefix and WHOIS:
              # a hashed-IP cloak, or an oper-assigned vhost when set.
              host: nil,
              # Real client IP. Kept separate from `host` so cloaking is a
              # display concern only — K-line / O-line matching and the
              # oper-only WHOIS 338 use this, never the cloak.
              real_host: nil,
              # Cloak base domain (from listener `:cloak_host`); nil disables
              # cloaking and `host` falls back to the real IP.
              cloak_host: nil,
              registered?: false,
              channels: MapSet.new(),
              # Set by the user with USER; preserved across nick changes.
              server_name: nil,
              network_name: nil,
              buffer: "",
              # Auth state — populated by SASL/PASS, checked at NICK/USER completion.
              account: nil,
              # SASL handshake state: nil | :awaiting_payload (after AUTHENTICATE PLAIN, before payload).
              sasl: nil,
              # Set true once the client has sent `CAP LS/REQ`; we then wait for `CAP END`
              # before completing registration even if NICK+USER arrived first.
              cap_negotiating?: false,
              # If true, NICK+USER alone is enough to complete registration; SASL/PASS
              # is still honoured if provided. Read from Listener config at accept time.
              allow_anonymous: false,
              # Welcome MOTD lines from config.
              motd: [],
              # AWAY state — nil if present, else the away reason.
              away: nil,
              # Unix-second timestamp of last message from the client; used for WHOIS idle.
              last_activity: 0,
              connected_at: 0,
              # Keepalive: we send `PING :<token>` after `ping_interval_ms` of inactivity
              # and expect a PONG within `ping_timeout_ms`. While `ping_token` is non-nil
              # we are awaiting that PONG.
              ping_interval_ms: 120_000,
              ping_timeout_ms: 60_000,
              ping_token: nil,
              ping_timer: nil,
              # Flood control — leaky bucket measured in "seconds of penalty".
              # Each parsed command adds `penalty_for(command)`; the bucket
              # drains at 1 sec / sec wall time. When the bucket exceeds
              # `flood_threshold_seconds` we drop with ERROR :Excess Flood.
              flood_threshold_seconds: 20.0,
              flood_penalty_default: 1.0,
              flood_penalty: 0.0,
              flood_last_at_ms: 0,
              # Channels (downcased) where this user is exempt from flood
              # control on PRIVMSG/NOTICE (channel mode `+f`, founder-set).
              # Refreshed from `Channel.flags/1` on JOIN and updated on
              # presence MODE broadcasts.
              flood_exempt_channels: MapSet.new(),
              # IRC operator status — set by successful OPER, cleared when
              # the user-mode `-o` is set (not implemented yet).
              operator?: false,
              # `+w` user-mode: receive WALLOPS broadcasts.
              wallops?: false,
              # The client's QUIT reason, kept for the disconnect sighting.
              quit_reason: nil
  end

  alias OhMyExOhIrcd.IRC.Stats

  @impl ThousandIsland.Handler
  def handle_connection(socket, opts) do
    {:ok, {ip, _port}} = ThousandIsland.Socket.peername(socket)
    peer_host = ip |> :inet.ntoa() |> List.to_string()
    cloak_host = Map.get(opts, :cloak_host)

    now = System.system_time(:second)

    state = %State{
      host: OhMyExOhIrcd.IRC.Cloak.host(peer_host, cloak_host),
      real_host: peer_host,
      cloak_host: cloak_host,
      server_name: opts.server_name,
      network_name: opts.network_name,
      allow_anonymous: Map.get(opts, :allow_anonymous, false),
      motd: Map.get(opts, :motd, []),
      last_activity: now,
      connected_at: now,
      ping_interval_ms: Map.get(opts, :ping_interval_ms, 120_000),
      ping_timeout_ms: Map.get(opts, :ping_timeout_ms, 60_000),
      flood_threshold_seconds: Map.get(opts, :flood_threshold_seconds, 20) * 1.0,
      flood_penalty_default: Map.get(opts, :flood_penalty_default, 1.0) * 1.0,
      flood_last_at_ms: System.monotonic_time(:millisecond)
    }

    Phoenix.PubSub.subscribe(@pubsub, OhMyExOhIrcd.Cluster.NodeListener.topic())

    # If `proxy_protocol: true` is set on the listener, we expect Traefik /
    # HAProxy / etc. to inject a PROXY v1/v2 header as the first bytes of
    # every connection. Replace `state.host` with the real client IP and
    # stash any extra bytes back into the data buffer so handle_data sees
    # them on its first invocation.
    if Map.get(opts, :proxy_protocol, false) do
      handle_proxy_header(socket, state)
    else
      {:continue, state}
    end
  end

  defp handle_proxy_header(socket, state) do
    require Logger

    case OhMyExOhIrcd.IRC.Proxy.read_header(socket, 5_000) do
      {:ok, real_ip, leftover} ->
        {:continue,
         %{
           state
           | real_host: real_ip,
             host: OhMyExOhIrcd.IRC.Cloak.host(real_ip, state.cloak_host),
             buffer: leftover
         }}

      :no_header ->
        # Direct TCP connect with no PROXY header — likely a port scan
        # or a k8s probe that bypassed the proxy. Drop quietly.
        {:close, state}

      {:error, :closed} ->
        # Peer closed before sending anything; expected churn from
        # health checks and load-balancer probes.
        {:close, state}

      {:error, :timeout} ->
        # Peer connected but sent nothing within the deadline. Same as :closed —
        # uninteresting, just drop.
        {:close, state}

      {:error, reason} ->
        # Truncated / malformed header — worth surfacing because it
        # usually means our proxy and listener disagree on the version.
        Logger.warning("PROXY header parse failed: #{inspect(reason)}; dropping")
        {:close, state}
    end
  end

  @impl ThousandIsland.Handler
  def handle_data(data, socket, state) do
    {lines, rest} = split_lines(state.buffer <> data)
    state = %{state | buffer: rest}

    {state, dropped?} =
      Enum.reduce_while(lines, {state, false}, fn line, {st, _} ->
        case process_line(line, socket, st) do
          {:cont, st2} -> {:cont, {st2, false}}
          {:drop, st2} -> {:halt, {st2, true}}
        end
      end)

    if dropped?, do: {:close, state}, else: {:continue, state}
  end

  @impl GenServer
  def handle_info({:irc_privmsg, entry}, {socket, state}) do
    cmd = if Map.get(entry, :kind, :privmsg) == :notice, do: "NOTICE", else: "PRIVMSG"

    cond do
      # PRIVMSG into a channel we're subscribed to
      channel_target?(entry.target) and MapSet.member?(state.channels, downcase(entry.target)) ->
        # Don't echo our own outbound channel messages back to ourselves
        # (clients add them locally). We match on the bare nick prefix because
        # the sender field is the full "nick!user@host" form.
        unless owns_sender?(state, entry.sender) do
          send_msg(socket, Message.build(cmd, [entry.target, entry.text], entry.sender))
        end

      # DM to us
      not channel_target?(entry.target) and downcase(entry.target) == downcase(state.nick) ->
        send_msg(socket, Message.build(cmd, [entry.target, entry.text], entry.sender))

      true ->
        :ok
    end

    {:noreply, {socket, state}}
  end

  # Presence senders arrive as the full `nick!user@host` prefix and are
  # relayed verbatim — clients (irssi in particular) need the host part to
  # maintain their nicklists. Self-comparisons strip the nick back out.
  def handle_info({:irc_presence, {:join, sender, channel}}, {socket, state}) do
    if MapSet.member?(state.channels, downcase(channel)) and prefix_nick(sender) != state.nick do
      send_msg(socket, Message.build("JOIN", [channel], sender))
    end

    {:noreply, {socket, state}}
  end

  def handle_info({:irc_presence, {:part, sender, channel, reason}}, {socket, state}) do
    if MapSet.member?(state.channels, downcase(channel)) and prefix_nick(sender) != state.nick do
      params = if reason == "", do: [channel], else: [channel, reason]
      send_msg(socket, Message.build("PART", params, sender))
    end

    {:noreply, {socket, state}}
  end

  def handle_info({:irc_presence, {:topic, set_by, channel, text}}, {socket, state}) do
    if MapSet.member?(state.channels, downcase(channel)) and prefix_nick(set_by) != state.nick do
      send_msg(socket, Message.build("TOPIC", [channel, text], set_by))
    end

    {:noreply, {socket, state}}
  end

  def handle_info({:irc_presence, {:mode, granter, channel, flags, targets}}, {socket, state}) do
    if MapSet.member?(state.channels, downcase(channel)) do
      send_msg(socket, Message.build("MODE", [channel, flags | targets], granter))
    end

    {:noreply, {socket, update_flood_exempt(state, channel, flags)}}
  end

  def handle_info({:irc_presence, {:kick, kicker, channel, target, reason}}, {socket, state}) do
    if MapSet.member?(state.channels, downcase(channel)) do
      send_msg(socket, Message.build("KICK", [channel, target, reason], kicker))
    end

    {:noreply, {socket, state}}
  end

  # The kicked user's connection forgets the channel (so future PRIVMSGs
  # aren't accepted) but stays on the socket.
  def handle_info({:irc_kicked, channel}, {socket, state}) do
    Phoenix.PubSub.unsubscribe(@pubsub, MessageLog.channel_topic(channel))

    state = %{
      state
      | channels: MapSet.delete(state.channels, downcase(channel)),
        flood_exempt_channels: MapSet.delete(state.flood_exempt_channels, downcase(channel))
    }

    {:noreply, {socket, state}}
  end

  def handle_info({:irc_invite, inviter, channel}, {socket, state}) do
    send_msg(socket, Message.build("INVITE", [state.nick, channel], inviter))
    {:noreply, {socket, state}}
  end

  # An oper set (or cleared, with nil) our vhost via CHGHOST/SETHOST or the
  # web admin. Update the display host immediately, persist it to our
  # account if we're logged in, and tell us about it. The new host applies
  # to every prefix we emit from here on; existing channel nicklists refresh
  # on the next JOIN (we don't advertise the IRCv3 `chghost` cap).
  def handle_info({:irc_sethost, new_host, by}, {socket, state}) do
    display = new_host || OhMyExOhIrcd.IRC.Cloak.host(state.real_host, state.cloak_host)

    state =
      case state.account do
        %{} = account ->
          case OhMyExOhIrcd.Accounts.set_vhost(account, new_host) do
            {:ok, updated} -> %{state | account: updated}
            _ -> state
          end

        _ ->
          state
      end

    state = %{state | host: display}

    send_msg(
      socket,
      Message.build(
        "NOTICE",
        [state.nick || "*", "Your host is now #{display} (set by #{by})"],
        state.server_name
      )
    )

    {:noreply, {socket, state}}
  end

  # WALLOPS: visible to opers and to `+w` users. We dedup at receive
  # time — if the same conn is on both `opers` and `wallops` PubSub
  # topics, we'd otherwise see it twice.
  def handle_info({:irc_wallops, sender, text}, {socket, state}) do
    send_msg(socket, Message.build("WALLOPS", [text], sender))
    {:noreply, {socket, state}}
  end

  # GLOBOPS: opers only. Conventionally rendered by clients as a NOTICE
  # from the sending oper into the server-window.
  def handle_info({:irc_globops, sender, text}, {socket, state}) do
    if state.operator? do
      send_msg(socket, Message.build("NOTICE", [state.nick, "[GLOBOPS] " <> text], sender))
    end

    {:noreply, {socket, state}}
  end

  # Global staff notice. Rendered as a regular NOTICE to the user's nick
  # so clients place it in the server window (irssi-style
  # "[Global Notice] …" labelling is up to the sender to include).
  def handle_info({:irc_global_notice, sender, text}, {socket, state}) do
    send_msg(socket, Message.build("NOTICE", [state.nick || "*", text], sender))
    {:noreply, {socket, state}}
  end

  # IRC operator's KILL hit us. Conventional behaviour: send the
  # `ERROR :Killed by ...` line and shut down the write half so the
  # client sees a clean close.
  def handle_info({:irc_killed, killer, reason}, {socket, state}) do
    send_msg(
      socket,
      Message.build("ERROR", ["Closing link: Killed by #{killer}: #{reason}"])
    )

    ThousandIsland.Socket.shutdown(socket, :write)
    {:noreply, {socket, state}}
  end

  def handle_info({:irc_presence, {:quit, sender, channel, reason}}, {socket, state}) do
    if MapSet.member?(state.channels, downcase(channel)) and prefix_nick(sender) != state.nick do
      send_msg(socket, Message.build("QUIT", [reason], sender))
    end

    {:noreply, {socket, state}}
  end

  # Cluster topology changed — Channel singletons may have moved. Re-JOIN every
  # channel we're in so the (possibly new) Channel pid has us in its members
  # list. Channel.join is idempotent, so this is a safe no-op when nothing moved.
  def handle_info({:node_down, _node}, {socket, state}) do
    if state.registered? do
      user_id = state.account && state.account.id
      pfx = prefix(state)
      Enum.each(state.channels, &Channel.join(&1, state.nick, self(), user_id, pfx))
    end

    {:noreply, {socket, state}}
  end

  def handle_info({:node_up, _node}, {socket, state}) do
    if state.registered? do
      user_id = state.account && state.account.id
      pfx = prefix(state)
      Enum.each(state.channels, &Channel.join(&1, state.nick, self(), user_id, pfx))
    end

    {:noreply, {socket, state}}
  end

  # Idle check — fires `ping_interval_ms` after the last reschedule. If the
  # client has been quiet that whole time, send a PING and arm the timeout
  # check. Otherwise reschedule for the remaining gap.
  def handle_info(:check_idle, {socket, state}) do
    idle_ms = (System.system_time(:second) - state.last_activity) * 1000

    if idle_ms >= state.ping_interval_ms do
      token = Integer.to_string(System.unique_integer([:positive]))
      send_msg(socket, Message.build("PING", [token], state.server_name))
      ref = Process.send_after(self(), :check_pong, state.ping_timeout_ms)
      {:noreply, {socket, %{state | ping_token: token, ping_timer: ref}}}
    else
      remaining = state.ping_interval_ms - idle_ms
      ref = Process.send_after(self(), :check_idle, max(remaining, 1_000))
      {:noreply, {socket, %{state | ping_timer: ref}}}
    end
  end

  # Pong deadline. If the client replied (or sent anything else) since we
  # armed this check, ping_token has been cleared — go back to idle checking.
  # Otherwise close the connection with ERROR Ping timeout.
  def handle_info(:check_pong, {socket, state}) do
    if state.ping_token == nil do
      {:noreply, {socket, schedule_idle_check(state)}}
    else
      send_msg(
        socket,
        Message.build("ERROR", ["Closing link: Ping timeout (#{state.host})"])
      )

      ThousandIsland.Socket.shutdown(socket, :write)
      {:noreply, {socket, state}}
    end
  end

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

  # Cross-node WHOIS query. The Horde-registered Connection on the
  # target's node receives this call and returns a snapshot.
  @impl GenServer
  def handle_call(:whois_info, _from, {socket, state}) do
    {:reply, local_whois_info(state), {socket, state}}
  end

  defp local_whois_info(state) do
    %{
      nick: state.nick,
      user: state.user || state.nick,
      host: state.host,
      real_host: state.real_host,
      realname: state.realname || state.nick,
      channels: MapSet.to_list(state.channels),
      idle: max(System.system_time(:second) - state.last_activity, 0),
      signon: state.connected_at,
      away: state.away,
      account: state.account && state.account.email,
      operator?: state.operator?
    }
  end

  @impl ThousandIsland.Handler
  def handle_close(_socket, state) do
    if state.ping_timer, do: Process.cancel_timer(state.ping_timer)

    if state.registered? do
      record_sighting(state, %{quit_reason: state.quit_reason || "Connection closed"})

      # Leave every joined channel cleanly.
      Enum.each(state.channels, fn ch -> Channel.part(ch, state.nick, "Connection closed") end)

      case HordeRegistry.whereis({:user, downcase(state.nick)}) do
        pid when pid == self() ->
          Horde.Registry.unregister(HordeRegistry, {:user, downcase(state.nick)})

        _ ->
          :ok
      end
    end

    :ok
  end

  ## Line parsing

  defp split_lines(buf), do: do_split(buf, [])

  defp do_split(buf, acc) do
    case :binary.split(buf, ["\r\n", "\n"]) do
      [line, rest] -> do_split(rest, [line | acc])
      [_only] -> {Enum.reverse(acc), buf}
    end
  end

  defp process_line(line, socket, state) do
    case Message.parse(line) do
      {:ok, msg} ->
        # Any received line is proof-of-life: bump activity and clear any
        # pending PING token so :check_pong won't drop us.
        state = %{state | last_activity: System.system_time(:second), ping_token: nil}

        case charge_flood(state, msg) do
          {:ok, state} ->
            {:cont, dispatch(msg, socket, state)}

          {:flood, state} ->
            send_msg(socket, Message.build("ERROR", ["Closing link: Excess Flood"]))
            ThousandIsland.Socket.shutdown(socket, :write)
            {:drop, state}
        end

      {:error, :empty} ->
        {:cont, state}
    end
  end

  # Leaky-bucket flood control. Drains `(now - last) / 1000` seconds of
  # accumulated penalty, then adds the per-command penalty. Returns
  # `{:flood, state}` if the bucket overflows.
  #
  # Skips charging entirely when the message is a PRIVMSG/NOTICE to a
  # channel the user has cached as `+f` (flood-exempt). This lets a
  # founder dedicate a channel as a message broker without their
  # publishers getting throttled.
  defp charge_flood(state, %Message{} = msg) do
    if flood_exempt_message?(state, msg) do
      {:ok, state}
    else
      do_charge(state, msg.command)
    end
  end

  defp flood_exempt_message?(state, %Message{command: cmd, params: [target | _]})
       when cmd in ["PRIVMSG", "NOTICE"] do
    channel_target?(target) and MapSet.member?(state.flood_exempt_channels, downcase(target))
  end

  defp flood_exempt_message?(_state, _msg), do: false

  defp do_charge(state, command) do
    now_ms = System.monotonic_time(:millisecond)
    drained_sec = (now_ms - state.flood_last_at_ms) / 1000.0
    base = max(state.flood_penalty - drained_sec, 0.0)
    penalty = base + penalty_for(command, state.flood_penalty_default)
    state = %{state | flood_penalty: penalty, flood_last_at_ms: now_ms}

    if penalty > state.flood_threshold_seconds do
      {:flood, state}
    else
      {:ok, state}
    end
  end

  # Per-command flood penalties in seconds. "Cheap" handshake / keepalive
  # commands get a discount; "expensive" state-changing commands cost more.
  # Unlisted commands fall back to the configured default.
  defp penalty_for("PING", _default), do: 0.5
  defp penalty_for("PONG", _default), do: 0.5
  defp penalty_for("CAP", _default), do: 0.5
  defp penalty_for("AUTHENTICATE", _default), do: 0.5
  defp penalty_for("QUIT", _default), do: 0.5

  defp penalty_for("JOIN", _default), do: 1.5
  defp penalty_for("PART", _default), do: 1.5
  defp penalty_for("NICK", _default), do: 1.5
  defp penalty_for("KICK", _default), do: 1.5
  defp penalty_for("MODE", _default), do: 1.5
  defp penalty_for("LIST", _default), do: 3.0
  defp penalty_for("WHOIS", _default), do: 1.5
  defp penalty_for("INVITE", _default), do: 1.5
  defp penalty_for("TOPIC", _default), do: 1.5

  defp penalty_for(_command, default), do: default

  ## Command dispatch

  defp dispatch(%Message{command: "PING", params: params}, socket, state) do
    token = List.first(params) || state.server_name
    send_msg(socket, Message.build("PONG", [state.server_name, token], state.server_name))
    state
  end

  # Any PONG (matching token or not) is proof of life; clear the
  # pending-token flag so :check_pong won't kill us.
  defp dispatch(%Message{command: "PONG"}, _socket, state), do: %{state | ping_token: nil}

  # IRCv3 capability negotiation. We only advertise `sasl` so clients
  # like irssi/weechat will run their SASL handshake before completing
  # registration.
  defp dispatch(%Message{command: "CAP", params: ["LS" | _]}, socket, state) do
    send_msg(socket, Message.build("CAP", [state.nick || "*", "LS", "sasl"], state.server_name))
    %{state | cap_negotiating?: true}
  end

  defp dispatch(%Message{command: "CAP", params: ["LIST" | _]}, socket, state) do
    ack = if state.account, do: "sasl", else: ""
    send_msg(socket, Message.build("CAP", [state.nick || "*", "LIST", ack], state.server_name))
    state
  end

  defp dispatch(%Message{command: "CAP", params: ["REQ", reqs | _]}, socket, state) do
    requested = String.split(reqs, " ", trim: true)

    {ack, nak} =
      Enum.split_with(requested, fn cap -> cap == "sasl" or cap == "-sasl" end)

    cond do
      nak != [] ->
        send_msg(
          socket,
          Message.build("CAP", [state.nick || "*", "NAK", reqs], state.server_name)
        )

      ack != [] ->
        send_msg(
          socket,
          Message.build("CAP", [state.nick || "*", "ACK", Enum.join(ack, " ")], state.server_name)
        )

      true ->
        :ok
    end

    %{state | cap_negotiating?: true}
  end

  defp dispatch(%Message{command: "CAP", params: ["END" | _]}, socket, state) do
    state = %{state | cap_negotiating?: false}
    maybe_complete_registration(state, socket)
  end

  defp dispatch(%Message{command: "CAP"}, _socket, state), do: state

  # SASL PLAIN. Two-step: `AUTHENTICATE PLAIN` then a base64 payload
  # (or `*` to abort).
  defp dispatch(%Message{command: "AUTHENTICATE", params: ["PLAIN" | _]}, socket, state) do
    send_msg(socket, Message.build("AUTHENTICATE", ["+"]))
    %{state | sasl: :awaiting_payload}
  end

  defp dispatch(%Message{command: "AUTHENTICATE", params: ["*" | _]}, socket, state) do
    send_numeric(socket, state, "906", [state.nick || "*", "SASL authentication aborted"])
    %{state | sasl: nil}
  end

  defp dispatch(
         %Message{command: "AUTHENTICATE", params: [payload | _]},
         socket,
         %State{sasl: :awaiting_payload} = state
       ) do
    case Auth.verify_sasl_plain(payload) do
      {:ok, user} ->
        send_numeric(socket, state, "900", [
          state.nick || "*",
          "#{state.nick || "*"}!#{user.email}@#{state.host}",
          user.email,
          "You are now logged in as #{user.email}"
        ])

        send_numeric(socket, state, "903", [state.nick || "*", "SASL authentication successful"])
        %{state | account: user, sasl: nil}

      {:error, error} ->
        Auth.log_failure("SASL PLAIN", error, state.real_host || state.host)
        send_numeric(socket, state, "904", [state.nick || "*", "SASL authentication failed"])
        %{state | sasl: nil}
    end
  end

  # An unsupported mechanism (AUTHENTICATE EXTERNAL, SCRAM-…), or a payload
  # with no AUTHENTICATE PLAIN before it. Only a mechanism-shaped first
  # parameter is logged; anything else could be a credential.
  defp dispatch(%Message{command: "AUTHENTICATE", params: params}, socket, state) do
    reason =
      case params do
        [mech | _] when byte_size(mech) <= 20 ->
          if mech =~ ~r/^[A-Z0-9_-]+$/,
            do: "unsupported mechanism #{mech}",
            else: "payload without AUTHENTICATE PLAIN"

        _ ->
          "payload without AUTHENTICATE PLAIN"
      end

    Auth.log_failure("SASL", reason, state.real_host || state.host)
    send_numeric(socket, state, "904", [state.nick || "*", "SASL authentication failed"])
    %{state | sasl: nil}
  end

  defp dispatch(%Message{command: "PASS", params: [arg | _]}, socket, state) do
    case Auth.verify_pass(arg) do
      {:ok, user} ->
        %{state | account: user}

      {:error, error} ->
        Auth.log_failure("PASS", error, state.real_host || state.host)
        send_numeric(socket, state, "464", [state.nick || "*", "Password incorrect"])
        state
    end
  end

  defp dispatch(%Message{command: "NICK", params: [nick | _]}, socket, state) do
    cond do
      not valid_nick?(nick) ->
        send_numeric(socket, state, "432", [nick || "*", "Erroneous nickname"])
        state

      Horde.Registry.lookup(HordeRegistry, {:user, downcase(nick)}) != [] ->
        send_numeric(socket, state, "433", [state.nick || "*", nick, "Nickname is already in use"])

        state

      state.registered? ->
        # NICK change after registration. The prefix is the *old* identity
        # (`old!user@host`); clients use it to update their nicklists.
        old = state.nick
        old_prefix = prefix(state)
        :ok = swap_user_registration(old, nick)

        for ch <- state.channels do
          Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(ch), {
            :irc_presence,
            {:nick_change, old_prefix, nick, ch}
          })
        end

        send_msg(socket, Message.build("NICK", [nick], old_prefix))

        # An anonymous user's history is per nick: the old one was seen
        # until now too.
        if is_nil(state.account), do: record_sighting(state)
        state = %{state | nick: nick}
        record_sighting(state)
        state

      true ->
        state = %{state | nick: nick}
        maybe_complete_registration(state, socket)
    end
  end

  defp dispatch(%Message{command: "USER", params: [user, _, _, realname | _]}, socket, state) do
    state = %{state | user: user, realname: realname}
    maybe_complete_registration(state, socket)
  end

  defp dispatch(%Message{command: "QUIT", params: params}, socket, state) do
    reason = List.first(params) || "Quit"

    for ch <- state.channels do
      Channel.part(ch, state.nick, reason)
    end

    send_msg(socket, Message.build("ERROR", ["Closing link: #{state.host} (#{reason})"]))
    ThousandIsland.Socket.shutdown(socket, :write)
    %{state | quit_reason: reason}
  end

  defp dispatch(%Message{command: "JOIN", params: [chans | rest]}, socket, state) do
    # Optional second param is a comma-separated list of keys, lined up
    # by position with the channel list.
    keys =
      case rest do
        [keys_str | _] -> String.split(keys_str, ",", trim: false)
        [] -> []
      end

    channels = String.split(chans, ",", trim: true)

    channels
    |> Enum.with_index()
    |> Enum.reduce(state, fn {ch, idx}, st ->
      key = Enum.at(keys, idx)
      key = if key in [nil, ""], do: nil, else: key
      do_join(ch, key, socket, st)
    end)
  end

  defp dispatch(%Message{command: "PART", params: [chans | rest]}, socket, state) do
    reason = List.first(rest) || ""

    chans
    |> String.split(",", trim: true)
    |> Enum.reduce(state, fn ch, st -> do_part(ch, reason, socket, st) end)
  end

  defp dispatch(%Message{command: "PRIVMSG", params: [target, text | _]}, socket, state) do
    cond do
      not state.registered? ->
        send_numeric(socket, state, "451", ["*", "You have not registered"])
        state

      channel_target?(target) ->
        privmsg_channel(target, text, socket, state)
        state

      true ->
        privmsg_user(target, text, socket, state)
        state
    end
  end

  defp dispatch(%Message{command: "NAMES", params: [chans | _]}, socket, state) do
    for ch <- String.split(chans, ",", trim: true), do: send_names(ch, socket, state)
    state
  end

  defp dispatch(%Message{command: "MOTD"}, socket, state) do
    send_motd(socket, state)
    state
  end

  defp dispatch(%Message{command: "LUSERS"}, socket, state) do
    send_lusers(socket, state)
    state
  end

  defp dispatch(%Message{command: "LIST", params: params}, socket, state) do
    filter =
      case params do
        [list | _] -> String.split(list, ",", trim: true) |> Enum.map(&downcase/1)
        [] -> nil
      end

    send_numeric(socket, state, "321", [state.nick || "*", "Channel", "Users Name"])

    Stats.channel_names()
    |> Enum.filter(fn ch -> filter == nil or downcase(ch) in filter end)
    |> Enum.each(fn ch ->
      members = safe_members(ch)
      topic = safe_topic(ch)

      send_numeric(socket, state, "322", [
        state.nick || "*",
        ch,
        Integer.to_string(length(members)),
        topic
      ])
    end)

    send_numeric(socket, state, "323", [state.nick || "*", "End of /LIST"])
    state
  end

  defp dispatch(%Message{command: "TOPIC", params: [channel]}, socket, state) do
    send_topic(channel, socket, state)
    state
  end

  defp dispatch(%Message{command: "TOPIC", params: [channel, new_topic | _]}, socket, state) do
    cond do
      not state.registered? ->
        send_numeric(socket, state, "451", ["*", "You have not registered"])
        state

      not MapSet.member?(state.channels, downcase(channel)) ->
        send_numeric(socket, state, "442", [state.nick, channel, "You're not on that channel"])
        state

      true ->
        Channel.set_topic(channel, state.nick, new_topic)
        # Channel broadcasts the topic; our own connection will see it via PubSub,
        # but we sent the SET so echo back to ourselves with our own prefix.
        send_msg(socket, Message.build("TOPIC", [channel, new_topic], prefix(state)))
        state
    end
  end

  defp dispatch(%Message{command: "WHOIS", params: [target | _]}, socket, state) do
    target = target |> String.split(",", trim: true) |> List.first()
    send_whois(target, socket, state)
    state
  end

  defp dispatch(%Message{command: "WHO", params: [target | _]}, socket, state) do
    send_who(target, socket, state)
    state
  end

  defp dispatch(%Message{command: "MODE", params: [target | rest]}, socket, state) do
    cond do
      channel_target?(target) ->
        handle_channel_mode(target, rest, socket, state)
        state

      downcase(target) == downcase(state.nick) ->
        handle_user_mode(rest, socket, state)

      true ->
        send_numeric(socket, state, "502", [state.nick, "Can't change mode for other users"])
        state
    end
  end

  defp dispatch(%Message{command: "KICK", params: [channel, target | rest]}, socket, state) do
    reason = List.first(rest) || state.nick || "Kicked"

    cond do
      not state.registered? ->
        send_numeric(socket, state, "451", ["*", "You have not registered"])

      not MapSet.member?(state.channels, downcase(channel)) ->
        send_numeric(socket, state, "442", [state.nick, channel, "You're not on that channel"])

      true ->
        kick(channel, target, reason, socket, state)
    end

    state
  end

  defp dispatch(%Message{command: "INVITE", params: [nick, channel | _]}, socket, state) do
    cond do
      not state.registered? ->
        send_numeric(socket, state, "451", ["*", "You have not registered"])

      not MapSet.member?(state.channels, downcase(channel)) ->
        send_numeric(socket, state, "442", [state.nick, channel, "You're not on that channel"])

      true ->
        invite(nick, channel, socket, state)
    end

    state
  end

  defp dispatch(%Message{command: "NOTICE", params: [target, text | _]}, _socket, state) do
    # NOTICE is identical to PRIVMSG except clients must never auto-reply to it.
    # We deliver it through the same paths; receivers render it as a NOTICE
    # based on `entry.kind`. Per RFC, errors generated by NOTICE are silently
    # dropped (no numeric replies sent to the originator).
    cond do
      not state.registered? ->
        state

      channel_target?(target) ->
        if MapSet.member?(state.channels, downcase(target)) do
          # Errors from NOTICE are silently dropped per RFC.
          _ = Channel.notice(target, prefix(state), text)
        end

        state

      true ->
        case HordeRegistry.whereis({:user, downcase(target)}) do
          pid when is_pid(pid) ->
            send(
              pid,
              {:irc_privmsg, %{sender: prefix(state), target: target, text: text, kind: :notice}}
            )

          nil ->
            :ok
        end

        state
    end
  end

  defp dispatch(%Message{command: "AWAY", params: []}, socket, state) do
    send_numeric(socket, state, "305", [
      state.nick || "*",
      "You are no longer marked as being away"
    ])

    %{state | away: nil}
  end

  defp dispatch(%Message{command: "AWAY", params: [reason | _]}, socket, state) do
    if reason == "" do
      send_numeric(socket, state, "305", [
        state.nick || "*",
        "You are no longer marked as being away"
      ])

      %{state | away: nil}
    else
      send_numeric(socket, state, "306", [state.nick || "*", "You have been marked as being away"])

      %{state | away: reason}
    end
  end

  defp dispatch(%Message{command: "USERHOST", params: nicks}, socket, state) do
    replies =
      nicks
      |> Enum.flat_map(&String.split(&1, " ", trim: true))
      |> Enum.take(5)
      |> Enum.flat_map(fn nick ->
        case HordeRegistry.whereis({:user, downcase(nick)}) do
          pid when is_pid(pid) ->
            try do
              info =
                if pid == self(),
                  do: local_whois_info(state),
                  else: GenServer.call(pid, :whois_info, 1_000)

              away_flag = if info.away, do: "-", else: "+"
              ["#{info.nick}=#{away_flag}#{info.user}@#{info.host}"]
            catch
              _, _ -> []
            end

          nil ->
            []
        end
      end)

    send_numeric(socket, state, "302", [state.nick || "*", Enum.join(replies, " ")])
    state
  end

  defp dispatch(%Message{command: "ISON", params: nicks}, socket, state) do
    online =
      nicks
      |> Enum.flat_map(&String.split(&1, " ", trim: true))
      |> Enum.filter(fn nick ->
        HordeRegistry.whereis({:user, downcase(nick)}) != nil
      end)

    send_numeric(socket, state, "303", [state.nick || "*", Enum.join(online, " ")])
    state
  end

  # OPER bareword: promote the existing SASL/PASS-authenticated session
  # if the linked account has its `is_oper` flag set. This is the
  # ergonomic path — managing opers is then just toggling a column on a
  # Phoenix user, no bcrypt hashes hand-copied into config.
  defp dispatch(%Message{command: "OPER", params: []}, socket, state) do
    cond do
      not state.registered? ->
        send_numeric(socket, state, "451", ["*", "You have not registered"])
        state

      account_is_oper?(state.account) ->
        grant_oper(socket, state)

      true ->
        send_numeric(socket, state, "464", [
          state.nick,
          "Password incorrect (or your account isn't flagged as an oper)"
        ])

        state
    end
  end

  # OPER name password. Two ways to succeed:
  #
  #   1. DB account flag — if you've already SASL/PASS-authenticated as
  #      an `is_oper` account, the typed password is irrelevant (the
  #      login is the proof of identity). This is what makes the common
  #      client `/oper` work: irssi/weechat/etc. *always* send a name +
  #      password and never a bare OPER, so without this fallback the
  #      DB-flag path would be unreachable from a normal client.
  #   2. Static `:opers` config map — the classic O-line flow. No DB
  #      round-trip, so it doubles as bootstrap / disaster recovery.
  defp dispatch(%Message{command: "OPER", params: [name, password | _]}, socket, state) do
    cond do
      not state.registered? ->
        send_numeric(socket, state, "451", ["*", "You have not registered"])
        state

      account_is_oper?(state.account) ->
        grant_oper(socket, state)

      true ->
        user_at_host = "#{state.user || state.nick}@#{state.real_host}"

        case OhMyExOhIrcd.IRC.Opers.verify(name, password, user_at_host) do
          :ok ->
            grant_oper(socket, state)

          {:error, :bad_host} ->
            send_numeric(socket, state, "491", [state.nick, "No O-lines for your host"])
            state

          _ ->
            send_numeric(socket, state, "464", [state.nick, "Password incorrect"])
            state
        end
    end
  end

  defp dispatch(%Message{command: "OPER"}, socket, state) do
    send_numeric(socket, state, "461", [state.nick || "*", "OPER", "Not enough parameters"])
    state
  end

  defp dispatch(%Message{command: "KILL", params: [target, reason | _]}, socket, state) do
    cond do
      not state.registered? ->
        send_numeric(socket, state, "451", ["*", "You have not registered"])
        state

      not state.operator? ->
        send_numeric(socket, state, "481", [
          state.nick || "*",
          "Permission Denied - You're not an IRC operator"
        ])

        state

      true ->
        case HordeRegistry.whereis({:user, downcase(target)}) do
          pid when is_pid(pid) ->
            send(pid, {:irc_killed, state.nick, reason})
            state

          nil ->
            send_numeric(socket, state, "401", [state.nick, target, "No such nick/channel"])
            state
        end
    end
  end

  defp dispatch(%Message{command: "KILL"}, socket, state) do
    send_numeric(socket, state, "461", [state.nick || "*", "KILL", "Not enough parameters"])
    state
  end

  defp dispatch(%Message{command: "WALLOPS", params: [text | _]}, socket, state) do
    if state.operator? do
      OhMyExOhIrcd.IRC.Broadcast.wallops(state.nick, text)
      state
    else
      send_numeric(socket, state, "481", [
        state.nick || "*",
        "Permission Denied - You're not an IRC operator"
      ])

      state
    end
  end

  defp dispatch(%Message{command: "WALLOPS"}, socket, state) do
    send_numeric(socket, state, "461", [state.nick || "*", "WALLOPS", "Not enough parameters"])
    state
  end

  defp dispatch(%Message{command: "GLOBOPS", params: [text | _]}, socket, state) do
    if state.operator? do
      OhMyExOhIrcd.IRC.Broadcast.globops(state.nick, text)
      state
    else
      send_numeric(socket, state, "481", [
        state.nick || "*",
        "Permission Denied - You're not an IRC operator"
      ])

      state
    end
  end

  defp dispatch(%Message{command: "GLOBOPS"}, socket, state) do
    send_numeric(socket, state, "461", [state.nick || "*", "GLOBOPS", "Not enough parameters"])
    state
  end

  # Non-standard but common: `GNOTICE :<text>` from an oper broadcasts
  # to every connected user (the irssi-style staff "Global Notice").
  # Receivers see a NOTICE from the sender to themselves; what surfaces
  # as `[Global Notice]` in IRC clients is just convention — many opers
  # prefix their message with that string by hand.
  defp dispatch(%Message{command: "GNOTICE", params: [text | _]}, socket, state) do
    if state.operator? do
      OhMyExOhIrcd.IRC.Broadcast.global_notice(state.nick, "[Global Notice] " <> text)
      state
    else
      send_numeric(socket, state, "481", [
        state.nick || "*",
        "Permission Denied - You're not an IRC operator"
      ])

      state
    end
  end

  defp dispatch(%Message{command: "GNOTICE"}, socket, state) do
    send_numeric(socket, state, "461", [state.nick || "*", "GNOTICE", "Not enough parameters"])
    state
  end

  # KLINE / GLINE — same backing store in this implementation (everything
  # is cluster-wide via Postgres). Syntax: `KLINE [duration] <mask> :reason`
  # where `duration` is an optional integer in minutes.
  defp dispatch(%Message{command: cmd, params: params}, socket, state)
       when cmd in ["KLINE", "GLINE"] do
    cond do
      not state.operator? ->
        send_numeric(socket, state, "481", [
          state.nick || "*",
          "Permission Denied - You're not an IRC operator"
        ])

      params == [] ->
        send_numeric(socket, state, "461", [state.nick || "*", cmd, "Not enough parameters"])

      true ->
        {duration, mask, reason} = parse_kline_args(params)

        opts = [
          set_by: state.nick,
          reason: reason,
          duration_seconds: if(duration, do: duration * 60)
        ]

        case OhMyExOhIrcd.IRC.Klines.add(mask, state.nick, opts) do
          {:ok, _} ->
            OhMyExOhIrcd.IRC.Broadcast.globops(
              state.nick,
              "#{cmd} added: #{mask} (#{reason})"
            )

            disconnect_matching(mask, state.nick, reason)

          {:error, %Ecto.Changeset{} = cs} ->
            send_numeric(socket, state, "501", [
              state.nick,
              "Bad K-line: " <> Enum.map_join(cs.errors, "; ", fn {f, {m, _}} -> "#{f} #{m}" end)
            ])
        end
    end

    state
  end

  defp dispatch(%Message{command: cmd, params: [mask | _]}, socket, state)
       when cmd in ["UNKLINE", "UNGLINE"] do
    if state.operator? do
      case OhMyExOhIrcd.IRC.Klines.remove(mask) do
        0 ->
          send_numeric(socket, state, "501", [state.nick, "No K-line matching #{mask}"])

        n ->
          OhMyExOhIrcd.IRC.Broadcast.globops(
            state.nick,
            "#{cmd} removed: #{mask} (#{n} row#{if n == 1, do: "", else: "s"})"
          )
      end
    else
      send_numeric(socket, state, "481", [
        state.nick || "*",
        "Permission Denied - You're not an IRC operator"
      ])
    end

    state
  end

  defp dispatch(%Message{command: cmd}, socket, state)
       when cmd in ["UNKLINE", "UNGLINE"] do
    send_numeric(socket, state, "461", [state.nick || "*", cmd, "Not enough parameters"])
    state
  end

  # Oper-only: mint a single-use registration invite token in-band, so
  # staff don't need shell access to `mix invites.create` /
  # `OhMyExOhIrcd.Release.create_invite`. Syntax:
  #
  #     MKINVITE                 — never-expiring, no note
  #     MKINVITE <days>          — expires in <days> days
  #     MKINVITE <days> :<note>  — with a note
  #
  # A note requires a leading day count (the IRC grammar can't otherwise
  # tell a bare note from a day count). Replies are server NOTICEs to the
  # oper carrying the token + signup URL.
  defp dispatch(%Message{command: "MKINVITE", params: params}, socket, state) do
    if state.operator? do
      create_invite_command(params, socket, state)
    else
      send_numeric(socket, state, "481", [
        state.nick || "*",
        "Permission Denied - You're not an IRC operator"
      ])
    end

    state
  end

  # Oper-only: list active (unconsumed) invite tokens.
  defp dispatch(%Message{command: "LSINVITE"}, socket, state) do
    if state.operator? do
      list_invites_command(socket, state)
    else
      send_numeric(socket, state, "481", [
        state.nick || "*",
        "Permission Denied - You're not an IRC operator"
      ])
    end

    state
  end

  # Oper-only vhost control.
  #
  #   CHGHOST <nick> <host>   — set someone's host
  #   CHGHOST <nick> *        — clear it (revert to cloak)
  #   SETHOST <host>          — shorthand for CHGHOST <self> <host>
  #   SETHOST *               — clear your own
  #
  # For logged-in targets the host is also persisted to their account, so
  # it survives reconnects. The change is delivered to the target's session
  # via `{:irc_sethost, ...}`, so it works cross-node.
  defp dispatch(%Message{command: "CHGHOST", params: [nick, host | _]}, socket, state) do
    set_vhost_command(nick, host, socket, state)
    state
  end

  defp dispatch(%Message{command: "CHGHOST"}, socket, state) do
    send_numeric(socket, state, "461", [state.nick || "*", "CHGHOST", "Not enough parameters"])
    state
  end

  defp dispatch(%Message{command: "SETHOST", params: [host | _]}, socket, state) do
    set_vhost_command(state.nick, host, socket, state)
    state
  end

  defp dispatch(%Message{command: "SETHOST"}, socket, state) do
    send_numeric(socket, state, "461", [state.nick || "*", "SETHOST", "Not enough parameters"])
    state
  end

  defp dispatch(%Message{command: cmd}, socket, state) do
    send_numeric(socket, state, "421", [state.nick || "*", cmd, "Unknown command"])
    state
  end

  ## Helpers

  defp create_invite_command(params, socket, state) do
    case parse_invite_days(params) do
      :error ->
        send_server_notice(
          socket,
          state,
          "Usage: MKINVITE [days] [:note] — days must be a positive integer"
        )

      {:ok, days} ->
        opts = [note: invite_note(params)]
        opts = if days, do: Keyword.put(opts, :expires_in_seconds, days * 24 * 3600), else: opts
        opts = if state.account, do: Keyword.put(opts, :created_by, state.account), else: opts

        # Guard the whole DB + URL path: an exception here would otherwise
        # crash the ThousandIsland handler and drop the oper's connection.
        # We'd much rather surface the error as a NOTICE and log it.
        guard_invite_command(socket, state, "MKINVITE", fn ->
          do_create_invite(opts, socket, state)
        end)
    end
  end

  defp do_create_invite(opts, socket, state) do
    case OhMyExOhIrcd.Accounts.create_invite(opts) do
      {:ok, invite} ->
        url = signup_base_url() <> "/users/register?invite=#{invite.token}"
        send_server_notice(socket, state, "Invite token: #{invite.token}")
        send_server_notice(socket, state, "Signup URL:   #{url}")

        if invite.expires_at,
          do: send_server_notice(socket, state, "Expires:      #{invite.expires_at}")

      {:error, cs} ->
        send_server_notice(socket, state, "Could not create invite: #{inspect(cs.errors)}")
    end
  end

  defp list_invites_command(socket, state) do
    guard_invite_command(socket, state, "LSINVITE", fn ->
      case OhMyExOhIrcd.Accounts.list_active_invites() do
        [] ->
          send_server_notice(socket, state, "No active invites.")

        invites ->
          send_server_notice(socket, state, "Active invites (#{length(invites)}):")

          Enum.each(invites, &send_invite_line(&1, socket, state))
      end
    end)
  end

  defp send_invite_line(invite, socket, state) do
    note = if invite.note in [nil, ""], do: "", else: " — #{invite.note}"

    send_server_notice(
      socket,
      state,
      "  #{invite.token}  expires=#{invite.expires_at || "never"}#{note}"
    )
  end

  # Run an invite command body, converting any crash into a logged error
  # plus an error NOTICE — never a dropped connection.
  defp guard_invite_command(socket, state, label, fun) do
    fun.()
  rescue
    e ->
      Logger.error("#{label} failed: #{Exception.format(:error, e, __STACKTRACE__)}")

      send_server_notice(
        socket,
        state,
        "#{label} failed: #{Exception.message(e)} (see server log)"
      )
  catch
    kind, reason ->
      Logger.error("#{label} failed: #{inspect(kind)} #{inspect(reason)}")
      send_server_notice(socket, state, "#{label} failed (see server log)")
  end

  # Public signup base URL, derived from the Endpoint's `:url` config
  # (host = PHX_HOST in prod) rather than the live `Endpoint.url/0`, so it
  # doesn't depend on endpoint runtime state and matches the behaviour of
  # `OhMyExOhIrcd.Release.create_invite/1`.
  defp signup_base_url do
    url =
      Application.get_env(:oh_my_ex_oh_ircd, OhMyExOhIrcdWeb.Endpoint, [])
      |> Keyword.get(:url, [])

    scheme = Keyword.get(url, :scheme, "http")
    host = Keyword.get(url, :host, "localhost")
    default = if scheme == "https", do: 443, else: 80
    port = Keyword.get(url, :port, default)
    if port == default, do: "#{scheme}://#{host}", else: "#{scheme}://#{host}:#{port}"
  end

  # `MKINVITE` argument parsing. No args → no expiry. A leading token, if
  # present, must be a positive integer day count; anything else is a
  # usage error.
  defp parse_invite_days([]), do: {:ok, nil}

  defp parse_invite_days([days | _]) do
    case Integer.parse(days) do
      {n, ""} when n > 0 -> {:ok, n}
      _ -> :error
    end
  end

  # The note is the trailing param after the day count, if any.
  defp invite_note([_days, note | _]), do: note
  defp invite_note(_), do: ""

  # Shared body for CHGHOST/SETHOST. Oper-gated; validates the host (unless
  # clearing with `*`/`-`), then pushes it onto the target's live session.
  defp set_vhost_command(nick, host, socket, state) do
    new_host = if host in ["*", "-", ""], do: nil, else: host

    cond do
      not state.operator? ->
        send_numeric(socket, state, "481", [
          state.nick || "*",
          "Permission Denied - You're not an IRC operator"
        ])

      new_host && not OhMyExOhIrcd.IRC.Vhost.valid_host?(new_host) ->
        send_server_notice(socket, state, "Invalid host: #{host}")

      true ->
        set_live_vhost(nick, new_host, socket, state)
    end
  end

  defp set_live_vhost(nick, new_host, socket, state) do
    case OhMyExOhIrcd.IRC.Vhost.set_live(nick, new_host, state.nick) do
      :ok ->
        send_server_notice(
          socket,
          state,
          "Set host for #{nick} to #{new_host || "(cloak)"}"
        )

      {:error, :no_such_nick} ->
        send_numeric(socket, state, "401", [state.nick || "*", nick, "No such nick/channel"])
    end
  end

  # A NOTICE from the server pseudo-host to the requesting oper.
  defp send_server_notice(socket, state, text) do
    server = state.server_name || Listener.server_name()
    send_msg(socket, Message.build("NOTICE", [state.nick || "*", text], server))
  end

  # Force-join the freshly-registered user to every `+A` (auto-join)
  # channel. Reuses do_join, so a channel that's also +i/+k/+l/banned
  # will refuse the join just as a manual JOIN would — auto-join is for
  # otherwise-open network channels (e.g. a welcome channel). DB hiccups
  # during connect must never block registration, hence the rescue.
  defp auto_join_channels(socket, state) do
    OhMyExOhIrcd.Channels.auto_join_names()
    |> Enum.reduce(state, fn ch, st -> do_join(ch, nil, socket, st) end)
  rescue
    _ -> state
  end

  defp do_join(channel, key, socket, state) do
    cond do
      not state.registered? ->
        send_numeric(socket, state, "451", ["*", "You have not registered"])
        state

      not Channel.valid_name?(channel) ->
        send_numeric(socket, state, "403", [state.nick || "*", channel, "Invalid channel name"])
        state

      true ->
        {:ok, _pid} = Channel.ensure_started(channel)
        user_id = state.account && state.account.id

        # Subscribe *before* joining so any presence broadcasts the join
        # triggers (synthetic +q for founders, JOIN line for others) land
        # in our mailbox.
        Phoenix.PubSub.subscribe(@pubsub, MessageLog.channel_topic(channel))

        case Channel.join(channel, state.nick, self(), user_id, prefix(state), key) do
          :ok ->
            state = %{state | channels: MapSet.put(state.channels, downcase(channel))}
            state = refresh_flood_exempt(state, channel)
            send_msg(socket, Message.build("JOIN", [channel], prefix(state)))
            send_join_topic(channel, socket, state)
            send_names(channel, socket, state)

            replay_backlog(channel, socket)
            state

          {:error, reason} ->
            Phoenix.PubSub.unsubscribe(@pubsub, MessageLog.channel_topic(channel))
            send_join_refusal(reason, channel, socket, state)
            state
        end
    end
  end

  defp replay_backlog(channel, socket) do
    MessageLog.entries(channel)
    |> Enum.each(fn entry ->
      cmd = if Map.get(entry, :kind, :privmsg) == :notice, do: "NOTICE", else: "PRIVMSG"
      send_msg(socket, Message.build(cmd, [channel, entry.text], entry.sender))
    end)
  end

  # Look up the channel's current flags and (re)cache the `+f` exemption
  # locally. Called on JOIN so a returning user re-acquires the bit.
  defp refresh_flood_exempt(state, channel) do
    exempt? =
      try do
        Channel.flags(channel) |> MapSet.member?(:flood_exempt)
      catch
        _, _ -> false
      end

    key = downcase(channel)

    set =
      if exempt? do
        MapSet.put(state.flood_exempt_channels, key)
      else
        MapSet.delete(state.flood_exempt_channels, key)
      end

    %{state | flood_exempt_channels: set}
  end

  # Parse a presence-mode flag string (e.g. "+ntf", "-f", "+i-f") and
  # toggle our cached flood-exempt bit accordingly. Anything other than
  # `+f`/`-f` is ignored.
  defp update_flood_exempt(state, channel, flag_string) do
    {action, _} =
      flag_string
      |> String.to_charlist()
      |> Enum.reduce({:noop, :grant}, fn
        ?+, {acc, _} -> {acc, :grant}
        ?-, {acc, _} -> {acc, :revoke}
        ?f, {_, :grant} -> {:add, :grant}
        ?f, {_, :revoke} -> {:remove, :revoke}
        _, acc -> acc
      end)

    key = downcase(channel)

    case action do
      :add ->
        %{state | flood_exempt_channels: MapSet.put(state.flood_exempt_channels, key)}

      :remove ->
        %{state | flood_exempt_channels: MapSet.delete(state.flood_exempt_channels, key)}

      :noop ->
        state
    end
  end

  defp send_join_refusal(:banned, channel, socket, state),
    do: send_numeric(socket, state, "474", [state.nick, channel, "Cannot join channel (+b)"])

  defp send_join_refusal(:invite_only, channel, socket, state),
    do: send_numeric(socket, state, "473", [state.nick, channel, "Cannot join channel (+i)"])

  defp send_join_refusal(:bad_key, channel, socket, state),
    do: send_numeric(socket, state, "475", [state.nick, channel, "Cannot join channel (+k)"])

  defp send_join_refusal(:channel_full, channel, socket, state),
    do: send_numeric(socket, state, "471", [state.nick, channel, "Cannot join channel (+l)"])

  defp do_part(channel, reason, socket, state) do
    if MapSet.member?(state.channels, downcase(channel)) do
      :ok = Channel.part(channel, state.nick, reason)
      Phoenix.PubSub.unsubscribe(@pubsub, MessageLog.channel_topic(channel))
      params = if reason == "", do: [channel], else: [channel, reason]
      send_msg(socket, Message.build("PART", params, prefix(state)))

      %{
        state
        | channels: MapSet.delete(state.channels, downcase(channel)),
          flood_exempt_channels: MapSet.delete(state.flood_exempt_channels, downcase(channel))
      }
    else
      send_numeric(socket, state, "442", [state.nick, channel, "You're not on that channel"])
      state
    end
  end

  defp kick(channel, target, reason, socket, state) do
    case Channel.kick(channel, state.nick, target, reason) do
      :ok ->
        :ok

      {:error, :not_authorized} ->
        send_numeric(socket, state, "482", [
          state.nick,
          channel,
          "You're not channel operator"
        ])

      {:error, :no_such_member} ->
        send_numeric(socket, state, "441", [
          state.nick,
          target,
          channel,
          "They aren't on that channel"
        ])

      {:error, :not_on_channel} ->
        send_numeric(socket, state, "442", [state.nick, channel, "You're not on that channel"])
    end
  end

  defp invite(nick, channel, socket, state) do
    case Channel.member_info(channel, state.nick) do
      %{access: a} ->
        if Channel.rank(a) >= Channel.rank(:halfop) do
          case HordeRegistry.whereis({:user, downcase(nick)}) do
            pid when is_pid(pid) ->
              Channel.record_invite(channel, nick)
              send(pid, {:irc_invite, state.nick, channel})
              send_numeric(socket, state, "341", [state.nick, nick, channel])

            nil ->
              send_numeric(socket, state, "401", [state.nick, nick, "No such nick/channel"])
          end
        else
          send_numeric(socket, state, "482", [
            state.nick,
            channel,
            "You're not channel operator"
          ])
        end

      nil ->
        send_numeric(socket, state, "442", [state.nick, channel, "You're not on that channel"])
    end
  end

  defp privmsg_channel(target, text, socket, state) do
    if MapSet.member?(state.channels, downcase(target)) do
      case Channel.privmsg(target, prefix(state), text) do
        :ok ->
          :ok

        {:error, :moderated} ->
          send_numeric(socket, state, "404", [
            state.nick,
            target,
            "Cannot send to channel (+m)"
          ])

        {:error, :no_external} ->
          send_numeric(socket, state, "404", [
            state.nick,
            target,
            "Cannot send to channel (+n)"
          ])
      end
    else
      send_numeric(socket, state, "404", [state.nick, target, "Cannot send to channel"])
    end
  end

  # PRIVMSG to user — find their connection and deliver via Phoenix.PubSub
  # of a per-user topic. Simpler: route through MessageLog with target=nick.
  defp privmsg_user(target, text, socket, state) do
    case HordeRegistry.whereis({:user, downcase(target)}) do
      pid when is_pid(pid) ->
        send(
          pid,
          {:irc_privmsg, %{sender: prefix(state), target: target, text: text, kind: :privmsg}}
        )

      nil ->
        send_numeric(socket, state, "401", [state.nick, target, "No such nick/channel"])
    end
  end

  defp send_names(channel, socket, state) do
    members =
      try do
        Channel.members(channel)
      catch
        _, _ -> []
      end

    rendered =
      Enum.map_join(members, " ", fn
        %{nick: nick, access: access} -> Channel.prefix(access) <> nick
        nick when is_binary(nick) -> nick
      end)

    send_numeric(socket, state, "353", [state.nick || "*", "=", channel, rendered])
    send_numeric(socket, state, "366", [state.nick || "*", channel, "End of /NAMES list"])
  end

  defp maybe_complete_registration(%State{registered?: true} = state, _socket), do: state

  # Block on CAP negotiation: per IRCv3, registration cannot complete until
  # the client sends CAP END (or never sent CAP at all).
  defp maybe_complete_registration(%State{cap_negotiating?: true} = state, _socket), do: state

  defp maybe_complete_registration(%State{nick: nick, user: user} = state, socket)
       when is_binary(nick) and is_binary(user) do
    if is_nil(state.account) and not state.allow_anonymous do
      send_numeric(socket, state, "464", [
        state.nick || "*",
        "Password required - authenticate via SASL or PASS"
      ])

      state
    else
      case OhMyExOhIrcd.IRC.Klines.match("#{state.user}@#{state.real_host}") do
        %{reason: reason} = _kline ->
          send_msg(
            socket,
            Message.build("ERROR", ["Closing link: K-Lined: " <> (reason || "")])
          )

          ThousandIsland.Socket.shutdown(socket, :write)
          state

        _ ->
          register_user(socket, state, nick)
      end
    end
  end

  defp maybe_complete_registration(state, _socket), do: state

  defp register_user(socket, state, nick) do
    value = %{node: node(), user_id: state.account && state.account.id}

    case Horde.Registry.register(HordeRegistry, {:user, downcase(nick)}, value) do
      {:ok, _} ->
        state = %{state | registered?: true}
        state = apply_account_vhost(state)
        record_sighting(state)
        send_welcome(socket, state)
        OhMyExOhIrcd.IRC.Broadcast.subscribe_all()
        state = schedule_idle_check(state)
        auto_join_channels(socket, state)

      {:error, {:already_registered, _}} ->
        send_numeric(socket, state, "433", [state.nick, nick, "Nickname is already in use"])
        %{state | nick: nil}
    end
  end

  # A logged-in user with a persistent vhost wears it from the moment they
  # register, overriding the hashed-IP cloak. Anonymous users (no account)
  # and accounts without a vhost keep their cloak.
  defp apply_account_vhost(%State{account: %{vhost: vhost}} = state)
       when is_binary(vhost) and vhost != "" do
    %{state | host: vhost}
  end

  defp apply_account_vhost(state), do: state

  # "Seen on this network" history. Handed to Sightings.Writer, so the
  # database is never on this connection's path.
  defp record_sighting(state, extra \\ %{}) do
    %{
      nick: state.nick,
      user_id: state.account && state.account.id,
      host: state.host,
      real_ip: state.real_host
    }
    |> Map.merge(extra)
    |> OhMyExOhIrcd.Sightings.record_async()
  end

  defp swap_user_registration(old, new) do
    Horde.Registry.unregister(HordeRegistry, {:user, downcase(old)})

    case Horde.Registry.register(HordeRegistry, {:user, downcase(new)}, %{node: node()}) do
      {:ok, _} -> :ok
      err -> err
    end
  end

  defp send_welcome(socket, state) do
    send_numeric(socket, state, "001", [
      state.nick,
      "Welcome to the #{state.network_name} IRC Network #{state.nick}"
    ])

    send_numeric(socket, state, "002", [
      state.nick,
      "Your host is #{state.server_name}, running oh_my_ex_oh_ircd"
    ])

    send_numeric(socket, state, "003", [state.nick, "This server was created right now"])

    send_numeric(socket, state, "004", [
      state.nick,
      state.server_name,
      "oh_my_ex_oh_ircd-0.1",
      "o",
      "o"
    ])

    send_numeric(socket, state, "005", [
      state.nick,
      "NETWORK=#{state.network_name}",
      "CHANTYPES=#&",
      "CASEMAPPING=ascii",
      "PREFIX=(qaohv)~&@%+",
      # CHANMODES=A,B,C,D where:
      # A = list modes (always take a parameter) — b/e/I
      # B = setting modes that take a parameter on both set and unset — k
      # C = setting modes that take a parameter only when set — l
      # D = setting modes that take no parameter — A/f/i/m/n/t
      #     (`A` = auto-join, oper-set; force-joins every connecting user)
      "CHANMODES=beI,k,l,Afimnt",
      "STATUSMSG=~&@%+",
      "are supported by this server"
    ])

    send_lusers(socket, state)
    send_motd(socket, state)
  end

  defp send_motd(socket, state) do
    case state.motd do
      [] ->
        send_numeric(socket, state, "422", [state.nick || "*", "MOTD File is missing"])

      lines ->
        send_numeric(socket, state, "375", [
          state.nick || "*",
          "- #{state.server_name} Message of the Day -"
        ])

        Enum.each(lines, fn line ->
          send_numeric(socket, state, "372", [state.nick || "*", "- #{line}"])
        end)

        send_numeric(socket, state, "376", [state.nick || "*", "End of MOTD command"])
    end
  end

  defp send_lusers(socket, state) do
    users = Stats.user_count()
    channels = Stats.channel_count()
    nodes = 1 + length(Node.list())

    send_numeric(socket, state, "251", [
      state.nick || "*",
      "There are #{users} users on #{nodes} servers"
    ])

    send_numeric(socket, state, "254", [
      state.nick || "*",
      Integer.to_string(channels),
      "channels formed"
    ])

    send_numeric(socket, state, "255", [
      state.nick || "*",
      "I have #{users} clients and #{nodes} servers"
    ])

    send_numeric(socket, state, "265", [
      state.nick || "*",
      Integer.to_string(users),
      Integer.to_string(users),
      "Current local users #{users}, max #{users}"
    ])

    send_numeric(socket, state, "266", [
      state.nick || "*",
      Integer.to_string(users),
      Integer.to_string(users),
      "Current global users #{users}, max #{users}"
    ])
  end

  defp send_topic(channel, socket, state) do
    info = safe_topic_info(channel)

    cond do
      info == nil ->
        send_numeric(socket, state, "403", [state.nick || "*", channel, "No such channel"])

      info.text == "" ->
        send_numeric(socket, state, "331", [state.nick || "*", channel, "No topic is set"])

      true ->
        send_numeric(socket, state, "332", [state.nick || "*", channel, info.text])

        if info.set_by != "" do
          send_numeric(socket, state, "333", [
            state.nick || "*",
            channel,
            info.set_by,
            Integer.to_string(info.set_at)
          ])
        end
    end
  end

  defp send_whois(target, socket, state) do
    case HordeRegistry.whereis({:user, downcase(target)}) do
      pid when is_pid(pid) ->
        try do
          info =
            if pid == self() do
              local_whois_info(state)
            else
              GenServer.call(pid, :whois_info, 2_000)
            end

          send_whois_reply(info, socket, state)
        catch
          _, _ ->
            send_numeric(socket, state, "401", [state.nick || "*", target, "No such nick/channel"])
        end

      nil ->
        send_numeric(socket, state, "401", [state.nick || "*", target, "No such nick/channel"])
    end
  end

  defp send_whois_reply(info, socket, state) do
    me = state.nick || "*"

    send_numeric(socket, state, "311", [
      me,
      info.nick,
      info.user,
      info.host,
      "*",
      info.realname
    ])

    send_numeric(socket, state, "312", [me, info.nick, state.server_name, "oh_my_ex_oh_ircd"])

    send_whois_actually(info, socket, state)
    send_whois_extras(info, socket, state)

    send_numeric(socket, state, "317", [
      me,
      info.nick,
      Integer.to_string(info.idle),
      Integer.to_string(info.signon),
      "seconds idle, signon time"
    ])

    send_numeric(socket, state, "318", [me, info.nick, "End of /WHOIS list"])
  end

  # RPL_WHOISACTUALLY — reveal the real IP behind a cloak/vhost, but
  # only to operators. Skipped when the displayed host already is the
  # real host (cloaking off and no vhost).
  defp send_whois_actually(info, socket, state) do
    real_host = Map.get(info, :real_host)

    if state.operator? and is_binary(real_host) and real_host != info.host do
      send_numeric(socket, state, "338", [
        state.nick || "*",
        info.nick,
        real_host,
        "Is actually using host"
      ])
    end
  end

  defp send_whois_extras(info, socket, state) do
    me = state.nick || "*"

    if info.channels != [] do
      send_numeric(socket, state, "319", [me, info.nick, Enum.join(info.channels, " ")])
    end

    if info.away do
      send_numeric(socket, state, "301", [me, info.nick, info.away])
    end

    if info.account do
      send_numeric(socket, state, "330", [me, info.nick, info.account, "is logged in as"])
    end

    if Map.get(info, :operator?) do
      send_numeric(socket, state, "313", [me, info.nick, "is an IRC operator"])
    end
  end

  defp send_who(target, socket, state) do
    if channel_target?(target) do
      target
      |> safe_members()
      |> Enum.each(&send_who_member(&1, target, socket, state))
    else
      send_who_nick(target, socket, state)
    end

    send_numeric(socket, state, "315", [state.nick || "*", target, "End of /WHO list"])
  end

  defp send_who_member(member, target, socket, state) do
    {nick, access, user, host} =
      case member do
        %{nick: n, access: a, user: u, host: h} -> {n, a, u || n, h || "unknown"}
        %{nick: n, access: a} -> {n, a, n, "unknown"}
        n when is_binary(n) -> {n, :member, n, "unknown"}
      end

    # Per RFC 2812, flags is `H|G[*][@%+]` — we append the highest-rank prefix.
    flag = "H" <> Channel.prefix(access)
    # Format: <channel> <user> <host> <server> <nick> <flags> :<hopcount> <realname>
    send_numeric(socket, state, "352", [
      state.nick || "*",
      target,
      user,
      host,
      state.server_name,
      nick,
      flag,
      "0 #{nick}"
    ])
  end

  # WHO on a nick — just one row if we can find them.
  defp send_who_nick(target, socket, state) do
    case HordeRegistry.whereis({:user, downcase(target)}) do
      pid when is_pid(pid) ->
        try do
          info =
            if pid == self(),
              do: local_whois_info(state),
              else: GenServer.call(pid, :whois_info, 1_000)

          flag = if info.away, do: "G", else: "H"

          send_numeric(socket, state, "352", [
            state.nick || "*",
            "*",
            info.user,
            info.host,
            state.server_name,
            info.nick,
            flag,
            "0 #{info.realname}"
          ])
        catch
          _, _ -> :ok
        end

      nil ->
        :ok
    end
  end

  defp safe_members(channel) do
    try do
      case Channel.members(channel) do
        list when is_list(list) -> list
        _ -> []
      end
    catch
      _, _ -> []
    end
  end

  defp safe_topic(channel) do
    case safe_topic_info(channel) do
      %{text: t} -> t
      _ -> ""
    end
  end

  # On JOIN, surface the (possibly just-restored-from-DB) topic to the
  # client via RPL_TOPIC/RPL_TOPICWHOTIME. Sent only when a topic is set —
  # no RPL_331 noise for topicless channels.
  defp send_join_topic(channel, socket, state) do
    case safe_topic_info(channel) do
      %{text: text} = info when is_binary(text) and text != "" ->
        send_numeric(socket, state, "332", [state.nick || "*", channel, text])

        if info.set_by not in [nil, ""] do
          send_numeric(socket, state, "333", [
            state.nick || "*",
            channel,
            info.set_by,
            Integer.to_string(info.set_at || 0)
          ])
        end

      _ ->
        :ok
    end
  end

  defp safe_topic_info(channel) do
    try do
      Channel.topic_info(channel)
    catch
      _, _ -> nil
    end
  end

  # Bare nick from a `nick!user@host` prefix (or a bare nick, unchanged).
  defp prefix_nick(prefix) when is_binary(prefix),
    do: prefix |> String.split("!", parts: 2) |> List.first()

  defp prefix_nick(_), do: nil

  defp owns_sender?(%State{nick: nil}, _sender), do: false

  defp owns_sender?(%State{nick: nick}, sender) when is_binary(sender) do
    case String.split(sender, "!", parts: 2) do
      [^nick | _] -> true
      _ -> false
    end
  end

  defp owns_sender?(_, _), do: false

  # Self-mode (`MODE <yournick> ...`). Only a tiny subset matters:
  # `+w/-w` toggles WALLOPS reception. Everything else just reports the
  # current user-mode string.
  defp handle_user_mode([], socket, state) do
    send_numeric(socket, state, "221", [state.nick, user_mode_string(state)])
    state
  end

  defp handle_user_mode([mode_string | _], socket, state) do
    state =
      mode_string
      |> String.to_charlist()
      |> Enum.reduce({:grant, state}, fn
        ?+, {_, st} ->
          {:grant, st}

        ?-, {_, st} ->
          {:revoke, st}

        ?w, {:grant, st} when st.wallops? == false ->
          OhMyExOhIrcd.IRC.Broadcast.subscribe_wallops()
          {:grant, %{st | wallops?: true}}

        ?w, {:revoke, st} when st.wallops? == true ->
          OhMyExOhIrcd.IRC.Broadcast.unsubscribe_wallops()
          {:revoke, %{st | wallops?: false}}

        _, acc ->
          acc
      end)
      |> elem(1)

    # Echo back the resolved user-mode string (RFC 221 RPL_UMODEIS).
    send_numeric(socket, state, "221", [state.nick, user_mode_string(state)])
    state
  end

  defp user_mode_string(state) do
    "+" <>
      if(state.operator?, do: "o", else: "") <>
      if(state.wallops?, do: "w", else: "")
  end

  defp handle_channel_mode(target, [], socket, state) do
    {flag_str, args} = current_modes(target)
    send_numeric(socket, state, "324", [state.nick || "*", target, flag_str | args])
  end

  defp handle_channel_mode(target, [mode_string | params], socket, state) do
    if state.registered? do
      mode_string
      |> parse_channel_modes(params)
      |> apply_parsed_channel_modes(target, socket, state)
    else
      send_numeric(socket, state, "451", ["*", "You have not registered"])
    end
  end

  defp apply_parsed_channel_modes({:ok, {[], list_views}}, target, socket, state) do
    Enum.each(list_views, &render_mask_list(&1, target, socket, state))
  end

  defp apply_parsed_channel_modes({:ok, {changes, list_views}}, target, socket, state) do
    if MapSet.member?(state.channels, downcase(target)) do
      apply_channel_mode_changes(target, changes, socket, state)
      Enum.each(list_views, &render_mask_list(&1, target, socket, state))
    else
      send_numeric(socket, state, "442", [
        state.nick,
        target,
        "You're not on that channel"
      ])
    end
  end

  defp apply_parsed_channel_modes({:error, {:unknown_mode, letter}}, _target, socket, state) do
    send_numeric(socket, state, "472", [
      state.nick || "*",
      <<letter::utf8>>,
      "is unknown mode"
    ])
  end

  defp apply_channel_mode_changes(target, changes, socket, state) do
    case Channel.apply_mode_changes(target, state.nick, changes, operator?: state.operator?) do
      {:ok, _applied} ->
        :ok

      {:error, :not_authorized} ->
        send_numeric(socket, state, "482", [
          state.nick,
          target,
          "You're not channel operator"
        ])

      {:error, :no_such_member} ->
        send_numeric(socket, state, "401", [state.nick, "*", "No such nick/channel"])

      {:error, :not_on_channel} ->
        send_numeric(socket, state, "442", [
          state.nick,
          target,
          "You're not on that channel"
        ])
    end
  end

  # Parse a mode string like "+b-b+ov-q" plus its parameter list. Returns
  # `{:ok, {changes, list_views}}` where:
  #   * `changes` is the list of `{:grant|:revoke, access, nick}`,
  #     `{:add_mask|:remove_mask, type, mask}`, or `{:set_flag|:clear_flag, flag}`
  #   * `list_views` is the list of mask types the user wants to list
  #     (e.g. `MODE #ch +b` with no arg = "show me the bans").
  defp parse_channel_modes(mode_string, params) do
    do_parse_modes(String.to_charlist(mode_string), :grant, params, [], [])
  end

  defp do_parse_modes([], _op, _params, changes, views),
    do: {:ok, {Enum.reverse(changes), Enum.reverse(views)}}

  defp do_parse_modes([?+ | rest], _op, params, changes, views),
    do: do_parse_modes(rest, :grant, params, changes, views)

  defp do_parse_modes([?- | rest], _op, params, changes, views),
    do: do_parse_modes(rest, :revoke, params, changes, views)

  defp do_parse_modes([letter | rest], op, params, changes, views) do
    cond do
      Map.has_key?(Channel.mode_letters(), letter) ->
        parse_access_mode(letter, rest, op, params, changes, views)

      letter in [?b, ?e, ?I] ->
        parse_mask_mode(letter, rest, op, params, changes, views)

      letter == ?k ->
        parse_key_mode(rest, op, params, changes, views)

      letter == ?l ->
        parse_limit_mode(rest, op, params, changes, views)

      Channel.flag_from_letter(letter) ->
        flag = Channel.flag_from_letter(letter)
        change = if op == :grant, do: {:set_flag, flag}, else: {:clear_flag, flag}
        do_parse_modes(rest, op, params, [change | changes], views)

      true ->
        {:error, {:unknown_mode, letter}}
    end
  end

  defp parse_access_mode(letter, rest, op, params, changes, views) do
    access = Map.fetch!(Channel.mode_letters(), letter)

    case params do
      [target | tail] ->
        do_parse_modes(rest, op, tail, [{op, access, target} | changes], views)

      [] ->
        {:error, {:unknown_mode, letter}}
    end
  end

  defp parse_mask_mode(letter, rest, op, params, changes, views) do
    type = mask_letter_to_type(letter)

    case params do
      [mask | tail] ->
        change = if op == :grant, do: {:add_mask, type, mask}, else: {:remove_mask, type, mask}
        do_parse_modes(rest, op, tail, [change | changes], views)

      [] ->
        do_parse_modes(rest, op, params, changes, [type | views])
    end
  end

  # `+k <key>` always consumes an argument; `-k <arg>` also consumes
  # one per RFC. Missing arg = silently ignore (some clients send
  # `MODE #ch k` to inspect; we surface it via 324).
  defp parse_key_mode(rest, op, [arg | tail], changes, views) do
    change = if op == :grant, do: {:set_key, arg}, else: {:clear_key, arg}
    do_parse_modes(rest, op, tail, [change | changes], views)
  end

  defp parse_key_mode(rest, op, [], changes, views),
    do: do_parse_modes(rest, op, [], changes, views)

  # `+l N` consumes an int. `-l` doesn't consume.
  defp parse_limit_mode(rest, :grant, [arg | tail], changes, views) do
    case Integer.parse(arg) do
      {n, ""} when n > 0 ->
        do_parse_modes(rest, :grant, tail, [{:set_limit, n} | changes], views)

      _ ->
        do_parse_modes(rest, :grant, tail, changes, views)
    end
  end

  defp parse_limit_mode(rest, :grant, [], changes, views),
    do: do_parse_modes(rest, :grant, [], changes, views)

  defp parse_limit_mode(rest, :revoke, params, changes, views),
    do: do_parse_modes(rest, :revoke, params, [{:clear_limit} | changes], views)

  defp mask_letter_to_type(?b), do: :ban
  defp mask_letter_to_type(?e), do: :except
  defp mask_letter_to_type(?I), do: :invite

  # ── RPL_BANLIST/RPL_EXCEPTLIST/RPL_INVITELIST renderers ──────────────
  defp render_mask_list(:ban, target, socket, state),
    do:
      render_mask_list_with(:ban, "367", "368", "End of channel ban list", target, socket, state)

  defp render_mask_list(:except, target, socket, state),
    do:
      render_mask_list_with(
        :except,
        "348",
        "349",
        "End of channel exception list",
        target,
        socket,
        state
      )

  defp render_mask_list(:invite, target, socket, state),
    do:
      render_mask_list_with(
        :invite,
        "346",
        "347",
        "End of channel invite list",
        target,
        socket,
        state
      )

  defp render_mask_list_with(type, line_code, end_code, end_text, target, socket, state) do
    try do
      Channel.list_masks(target, type)
      |> Enum.each(fn entry ->
        send_numeric(socket, state, line_code, [
          state.nick || "*",
          target,
          entry.mask,
          entry.set_by,
          Integer.to_string(entry.set_at)
        ])
      end)
    catch
      _, _ -> :ok
    end

    send_numeric(socket, state, end_code, [state.nick || "*", target, end_text])
  end

  # Returns `{mode_string, [arg, ...]}` for use as the params of a 324
  # numeric. Mode-with-arg ordering matches the letter ordering in the
  # mode string.
  defp current_modes(target) do
    try do
      %{flags: flags, key: key, limit: limit} = Channel.mode_snapshot(target)

      flag_letters =
        ["i", "m", "n", "f", "A"]
        |> Enum.filter(fn l ->
          MapSet.member?(flags, Channel.flag_from_letter(:binary.first(l)))
        end)
        |> Enum.join()

      mode_string =
        "+nt" <>
          flag_letters <>
          if(key, do: "k", else: "") <>
          if(limit, do: "l", else: "")

      args =
        [if(key, do: key, else: nil), if(limit, do: Integer.to_string(limit), else: nil)]
        |> Enum.reject(&is_nil/1)

      {mode_string, args}
    catch
      _, _ -> {"+nt", []}
    end
  end

  # `KLINE` syntax mirrors charybdis/oftc: optional leading integer
  # (minutes) duration, then mask, then trailing reason. Examples:
  #
  #   KLINE *!*@evil.example :spam
  #   KLINE 60 *!*@1.2.3.* :try again in an hour
  defp parse_kline_args(params) do
    case params do
      [maybe_dur | rest] ->
        case Integer.parse(maybe_dur) do
          {n, ""} when n > 0 ->
            {mask, reason} = take_mask_and_reason(rest)
            {n, mask, reason}

          _ ->
            {mask, reason} = take_mask_and_reason(params)
            {nil, mask, reason}
        end

      [] ->
        {nil, "", ""}
    end
  end

  defp take_mask_and_reason([mask]), do: {mask, ""}
  defp take_mask_and_reason([mask | rest]), do: {mask, Enum.join(rest, " ")}
  defp take_mask_and_reason([]), do: {"", ""}

  # Walk every Horde-registered user; ask each for its `user@host` and
  # disconnect any matching the new K-line mask.
  defp disconnect_matching(mask, killer, reason) do
    Horde.Registry.select(HordeRegistry, [{{{:user, :"$1"}, :"$2", :_}, [], [{{:"$1", :"$2"}}]}])
    |> Enum.each(fn {_nick, pid} ->
      try do
        info = GenServer.call(pid, :whois_info, 1_000)
        # Match the K-line against the real IP as well as the displayed
        # host, so a cloak or vhost can't be used to dodge a ban placed on
        # the underlying address.
        real_host = Map.get(info, :real_host) || info.host
        targets = ["#{info.user}@#{real_host}", "#{info.user}@#{info.host}"]

        if Enum.any?(targets, &match_glob?(&1, mask)) do
          send(pid, {:irc_killed, killer, "K-Lined: " <> reason})
        end
      catch
        _, _ -> :ok
      end
    end)
  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 grant_oper(socket, state) do
    send_numeric(socket, state, "381", [state.nick, "You are now an IRC operator"])
    send_msg(socket, Message.build("MODE", [state.nick, "+o"], state.nick))
    OhMyExOhIrcd.IRC.Broadcast.subscribe_opers()
    %{state | operator?: true}
  end

  # Re-read the `is_oper` flag from the DB rather than trusting the
  # `User` struct cached at SASL/PASS time. Otherwise a `grant_oper`
  # issued *after* the client authenticated wouldn't take effect until
  # the client reconnected (the cached struct would still say false).
  defp account_is_oper?(nil), do: false

  defp account_is_oper?(%{id: id}) do
    OhMyExOhIrcd.Accounts.oper?(OhMyExOhIrcd.Accounts.get_user!(id))
  rescue
    Ecto.NoResultsError -> false
  end

  defp account_is_oper?(_), do: false

  defp schedule_idle_check(state) do
    if state.ping_timer, do: Process.cancel_timer(state.ping_timer)
    ref = Process.send_after(self(), :check_idle, state.ping_interval_ms)
    %{state | ping_timer: ref, ping_token: nil}
  end

  defp send_numeric(socket, state, code, params) do
    msg = Message.build(code, params, state.server_name || Listener.server_name())
    send_msg(socket, msg)
  end

  defp send_msg(socket, %Message{} = msg) do
    ThousandIsland.Socket.send(socket, Message.to_iodata(msg))
  end

  defp prefix(%State{nick: nick, user: user, host: host}),
    do: "#{nick}!#{user || nick}@#{host}"

  defp channel_target?("#" <> _), do: true
  defp channel_target?("&" <> _), do: true
  defp channel_target?(_), do: false

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

  defp valid_nick?(nick) when is_binary(nick) and byte_size(nick) in 1..30 do
    String.match?(nick, ~r/^[A-Za-z\[\]\\`_^{|}][A-Za-z0-9\[\]\\`_^{|}\-]*$/)
  end

  defp valid_nick?(_), do: false
end