import { h, render } from "preact";
import { useState, useEffect, useRef, useCallback } from "preact/hooks";

const API = "/api/v1";
const POLL_TIMEOUT = 15;
const RECONNECT_DELAY = 3000;
const MEMBER_REFRESH_INTERVAL = 10000;
const ACTION_PREFIX = "\x01ACTION ";
const ACTION_SUFFIX = "\x01";

function api(path, opts = {}) {
  const token = localStorage.getItem("neoirc_token");
  const headers = {
    "Content-Type": "application/json",
    ...(opts.headers || {}),
  };
  if (token) headers["Authorization"] = `Bearer ${token}`;
  const { signal, ...rest } = opts;

  return fetch(API + path, { ...rest, headers, signal }).then(async (r) => {
    const data = await r.json().catch(() => null);
    if (!r.ok) throw { status: r.status, data };

    return data;
  });
}

function formatTime(ts) {
  const d = new Date(ts);

  return d.toLocaleTimeString([], {
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
    hour12: false,
  });
}

function nickColor(nick) {
  let h = 0;
  for (let i = 0; i < nick.length; i++)
    h = nick.charCodeAt(i) + ((h << 5) - h);
  const hue = Math.abs(h) % 360;

  return `hsl(${hue}, 60%, 65%)`;
}

function isAction(text) {
  return text.startsWith(ACTION_PREFIX) && text.endsWith(ACTION_SUFFIX);
}

function parseAction(text) {
  return text.slice(ACTION_PREFIX.length, -ACTION_SUFFIX.length);
}

// LoginScreen renders the initial nick selection form.
function LoginScreen({ onLogin }) {
  const [nick, setNick] = useState("");
  const [error, setError] = useState("");
  const [motd, setMotd] = useState("");
  const [serverName, setServerName] = useState("NeoIRC");
  const inputRef = useRef();

  useEffect(() => {
    api("/server")
      .then((s) => {
        if (s.name) setServerName(s.name);
        if (s.motd) setMotd(s.motd);
      })
      .catch(() => {});
    const saved = localStorage.getItem("neoirc_token");
    if (saved) {
      api("/state?initChannelState=1")
        .then((u) => onLogin(u.nick, true))
        .catch(() => localStorage.removeItem("neoirc_token"));
    }
    inputRef.current?.focus();
  }, []);

  const submit = async (e) => {
    e.preventDefault();
    setError("");
    try {
      const res = await api("/session", {
        method: "POST",
        body: JSON.stringify({ nick: nick.trim() }),
      });
      localStorage.setItem("neoirc_token", res.token);
      onLogin(res.nick);
    } catch (err) {
      setError(err.data?.error || "Connection failed");
    }
  };

  return (
    <div class="login-screen">
      <div class="login-box">
        <h1>{serverName}</h1>
        {motd && <pre class="motd">{motd}</pre>}
        <form onSubmit={submit}>
          <label>Nickname:</label>
          <input
            ref={inputRef}
            type="text"
            placeholder="Enter nickname"
            value={nick}
            onInput={(e) => setNick(e.target.value)}
            maxLength={32}
            autoFocus
          />
          <button type="submit">Connect</button>
        </form>
        {error && <div class="error">{error}</div>}
      </div>
    </div>
  );
}

// Message renders a single chat line in IRC format.
function Message({ msg, myNick }) {
  const time = formatTime(msg.ts);

  if (msg.system) {
    return (
      <div class="message system-message">
        <span class="timestamp">[{time}]</span>
        <span class="system-text"> * {msg.text}</span>
      </div>
    );
  }

  if (msg.isAction) {
    return (
      <div class="message action-message">
        <span class="timestamp">[{time}]</span>
        <span class="action-text">
          {" "}
          * <span style={{ color: nickColor(msg.from) }}>{msg.from}</span>{" "}
          {msg.text}
        </span>
      </div>
    );
  }

  return (
    <div class="message">
      <span class="timestamp">[{time}]</span>{" "}
      <span class="nick" style={{ color: nickColor(msg.from) }}>
        &lt;{msg.from}&gt;
      </span>{" "}
      <span class="content">{msg.text}</span>
    </div>
  );
}

// UserList renders the right-side nick list for channels.
function UserList({ members, onNickClick }) {
  const ops = [];
  const voiced = [];
  const regular = [];

  for (const m of members) {
    const mode = m.mode || "";
    if (mode === "o") {
      ops.push(m);
    } else if (mode === "v") {
      voiced.push(m);
    } else {
      regular.push(m);
    }
  }

  const sortNicks = (a, b) =>
    a.nick.toLowerCase().localeCompare(b.nick.toLowerCase());
  ops.sort(sortNicks);
  voiced.sort(sortNicks);
  regular.sort(sortNicks);

  const renderNick = (m, prefix) => (
    <div
      class="nick-entry"
      onClick={() => onNickClick(m.nick)}
      title={m.nick}
    >
      <span class="nick-prefix">{prefix}</span>
      <span class="nick-name" style={{ color: nickColor(m.nick) }}>
        {m.nick}
      </span>
    </div>
  );

  return (
    <div class="user-list">
      <div class="user-list-header">
        {members.length} user{members.length !== 1 ? "s" : ""}
      </div>
      <div class="user-list-entries">
        {ops.map((m) => renderNick(m, "@"))}
        {voiced.map((m) => renderNick(m, "+"))}
        {regular.map((m) => renderNick(m, ""))}
      </div>
    </div>
  );
}

function App() {
  const [loggedIn, setLoggedIn] = useState(false);
  const [nick, setNick] = useState("");
  const [tabs, setTabs] = useState([{ type: "server", name: "Server" }]);
  const [activeTab, setActiveTab] = useState(0);
  const [messages, setMessages] = useState({ Server: [] });
  const [members, setMembers] = useState({});
  const [topics, setTopics] = useState({});
  const [unread, setUnread] = useState({});
  const [input, setInput] = useState("");
  const [connected, setConnected] = useState(true);
  const [inputHistory, setInputHistory] = useState([]);
  const [historyIndex, setHistoryIndex] = useState(-1);

  const lastIdRef = useRef(0);
  const seenIdsRef = useRef(new Set());
  const pollAbortRef = useRef(null);
  const tabsRef = useRef(tabs);
  const activeTabRef = useRef(activeTab);
  const nickRef = useRef(nick);
  const messagesEndRef = useRef();
  const inputRef = useRef();

  useEffect(() => {
    tabsRef.current = tabs;
  }, [tabs]);
  useEffect(() => {
    activeTabRef.current = activeTab;
  }, [activeTab]);
  useEffect(() => {
    nickRef.current = nick;
  }, [nick]);

  // Persist joined channels.
  useEffect(() => {
    const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name);
    localStorage.setItem("neoirc_channels", JSON.stringify(channels));
  }, [tabs]);

  // Clear unread on tab switch.
  useEffect(() => {
    const tab = tabs[activeTab];
    if (tab) setUnread((prev) => ({ ...prev, [tab.name]: 0 }));
  }, [activeTab, tabs]);

  const addMessage = useCallback((tabName, msg) => {
    if (msg.id && seenIdsRef.current.has(msg.id)) return;
    if (msg.id) seenIdsRef.current.add(msg.id);
    setMessages((prev) => ({
      ...prev,
      [tabName]: [...(prev[tabName] || []), msg],
    }));
    const currentTab = tabsRef.current[activeTabRef.current];
    if (!currentTab || currentTab.name !== tabName) {
      setUnread((prev) => ({
        ...prev,
        [tabName]: (prev[tabName] || 0) + 1,
      }));
    }
  }, []);

  const addSystemMessage = useCallback((tabName, text) => {
    setMessages((prev) => ({
      ...prev,
      [tabName]: [
        ...(prev[tabName] || []),
        {
          id: "sys-" + Date.now() + "-" + Math.random(),
          ts: new Date().toISOString(),
          text,
          system: true,
        },
      ],
    }));
  }, []);

  const refreshMembers = useCallback((channel) => {
    const chName = channel.replace("#", "");
    api(`/channels/${chName}/members`)
      .then((m) => {
        setMembers((prev) => ({ ...prev, [channel]: m }));
      })
      .catch(() => {});
  }, []);

  const processMessage = useCallback(
    (msg) => {
      const body = Array.isArray(msg.body) ? msg.body.join("\n") : "";
      const base = {
        id: msg.id,
        ts: msg.ts,
        from: msg.from,
        to: msg.to,
        command: msg.command,
      };

      switch (msg.command) {
        case "PRIVMSG":
        case "NOTICE": {
          let text = body;
          let actionMsg = false;

          if (isAction(text)) {
            text = parseAction(text);
            actionMsg = true;
          }

          const parsed = { ...base, text, system: false, isAction: actionMsg };
          const target = msg.to;

          if (target && target.startsWith("#")) {
            addMessage(target, parsed);
          } else {
            const dmPeer =
              msg.from === nickRef.current ? msg.to : msg.from;
            setTabs((prev) => {
              if (!prev.find((t) => t.type === "dm" && t.name === dmPeer)) {
                return [...prev, { type: "dm", name: dmPeer }];
              }

              return prev;
            });
            addMessage(dmPeer, parsed);
          }

          break;
        }
        case "JOIN": {
          const text = `${msg.from} has joined ${msg.to}`;
          if (msg.to) addMessage(msg.to, { ...base, text, system: true });
          if (msg.to && msg.to.startsWith("#")) {
            // Create a tab when the current user joins a channel
            // (including JOINs from initChannelState on reconnect).
            if (msg.from === nickRef.current) {
              setTabs((prev) => {
                if (
                  prev.find(
                    (t) => t.type === "channel" && t.name === msg.to,
                  )
                )
                  return prev;

                return [...prev, { type: "channel", name: msg.to }];
              });
            }

            refreshMembers(msg.to);
          }

          break;
        }
        case "PART": {
          const reason = body ? " (" + body + ")" : "";
          const text = `${msg.from} has parted ${msg.to}${reason}`;
          if (msg.to) addMessage(msg.to, { ...base, text, system: true });
          if (msg.to && msg.to.startsWith("#")) refreshMembers(msg.to);

          break;
        }
        case "QUIT": {
          const reason = body ? " (" + body + ")" : "";
          const text = `${msg.from} has quit${reason}`;
          tabsRef.current.forEach((tab) => {
            if (tab.type === "channel") {
              addMessage(tab.name, { ...base, text, system: true });
            }
          });

          break;
        }
        case "NICK": {
          const newNick = Array.isArray(msg.body) ? msg.body[0] : body;
          const text = `${msg.from} is now known as ${newNick}`;
          tabsRef.current.forEach((tab) => {
            if (tab.type === "channel") {
              addMessage(tab.name, { ...base, text, system: true });
            }
          });
          if (msg.from === nickRef.current && newNick) setNick(newNick);
          tabsRef.current.forEach((tab) => {
            if (tab.type === "channel") refreshMembers(tab.name);
          });

          break;
        }
        case "TOPIC": {
          const text = `${msg.from} has changed the topic to: ${body}`;
          if (msg.to) {
            addMessage(msg.to, { ...base, text, system: true });
            setTopics((prev) => ({ ...prev, [msg.to]: body }));
          }

          break;
        }
        case "353": {
          // RPL_NAMREPLY — parse mode prefixes from names list.
          if (
            Array.isArray(msg.params) &&
            msg.params.length >= 2 &&
            msg.body
          ) {
            const channel = msg.params[1];
            const namesStr = Array.isArray(msg.body)
              ? msg.body[0]
              : String(msg.body);
            const names = namesStr.split(/\s+/).filter(Boolean);
            const parsed = names.map((n) => {
              if (n.startsWith("@")) {
                return { nick: n.slice(1), mode: "o" };
              }
              if (n.startsWith("+")) {
                return { nick: n.slice(1), mode: "v" };
              }

              return { nick: n, mode: "" };
            });
            setMembers((prev) => ({ ...prev, [channel]: parsed }));
          }

          break;
        }
        case "332": {
          // RPL_TOPIC — set topic from server.
          if (Array.isArray(msg.params) && msg.params.length >= 1) {
            const channel = msg.params[0];
            const topicText = Array.isArray(msg.body) ? msg.body[0] : body;
            if (topicText) {
              setTopics((prev) => ({ ...prev, [channel]: topicText }));
            }
          }

          break;
        }
        case "322": {
          // RPL_LIST — channel, member count, topic.
          if (Array.isArray(msg.params) && msg.params.length >= 2) {
            const chName = msg.params[0];
            const count = msg.params[1];
            const chTopic = body || "";
            addMessage("Server", {
              ...base,
              text: `${chName} (${count} users): ${chTopic.trim()}`,
              system: true,
            });
          }

          break;
        }
        case "323":
          addMessage("Server", {
            ...base,
            text: body || "End of channel list",
            system: true,
          });

          break;
        case "352": {
          // RPL_WHOREPLY — channel, user, host, server, nick, flags.
          if (Array.isArray(msg.params) && msg.params.length >= 5) {
            const whoCh = msg.params[0];
            const whoNick = msg.params[4];
            const whoFlags = msg.params.length > 5 ? msg.params[5] : "";
            addMessage("Server", {
              ...base,
              text: `${whoCh} ${whoNick} ${whoFlags}`,
              system: true,
            });
          }

          break;
        }
        case "315":
          addMessage("Server", {
            ...base,
            text: body || "End of /WHO list",
            system: true,
          });

          break;
        case "311": {
          // RPL_WHOISUSER — nick, user, host, *, realname.
          if (Array.isArray(msg.params) && msg.params.length >= 1) {
            const wiNick = msg.params[0];
            addMessage("Server", {
              ...base,
              text: `${wiNick} (${body})`,
              system: true,
            });
          }

          break;
        }
        case "312": {
          // RPL_WHOISSERVER — nick, server, server info.
          if (Array.isArray(msg.params) && msg.params.length >= 2) {
            const wiNick = msg.params[0];
            const wiServer = msg.params[1];
            addMessage("Server", {
              ...base,
              text: `${wiNick} on ${wiServer}`,
              system: true,
            });
          }

          break;
        }
        case "319": {
          // RPL_WHOISCHANNELS — nick, channels.
          if (Array.isArray(msg.params) && msg.params.length >= 1) {
            const wiNick = msg.params[0];
            addMessage("Server", {
              ...base,
              text: `${wiNick} is on: ${body}`,
              system: true,
            });
          }

          break;
        }
        case "318":
          addMessage("Server", {
            ...base,
            text: body || "End of /WHOIS list",
            system: true,
          });

          break;
        case "375":
        case "372":
        case "376":
          addMessage("Server", { ...base, text: body, system: true });

          break;
        default:
          if (body) {
            addMessage("Server", { ...base, text: body, system: true });
          }
      }
    },
    [addMessage, refreshMembers],
  );

  // Long-poll loop.
  useEffect(() => {
    if (!loggedIn) return;
    let alive = true;

    const poll = async () => {
      while (alive) {
        try {
          const controller = new AbortController();
          pollAbortRef.current = controller;
          const result = await api(
            `/messages?after=${lastIdRef.current}&timeout=${POLL_TIMEOUT}`,
            { signal: controller.signal },
          );
          if (!alive) break;
          setConnected(true);
          if (result.messages) {
            for (const m of result.messages) processMessage(m);
          }
          if (result.last_id > lastIdRef.current) {
            lastIdRef.current = result.last_id;
          }
        } catch (err) {
          if (!alive) break;
          if (err.name === "AbortError") continue;
          setConnected(false);
          await new Promise((r) => setTimeout(r, RECONNECT_DELAY));
        }
      }
    };

    poll();

    return () => {
      alive = false;
      pollAbortRef.current?.abort();
    };
  }, [loggedIn, processMessage]);

  // Refresh members for active channel.
  useEffect(() => {
    if (!loggedIn) return;
    const tab = tabs[activeTab];
    if (!tab || tab.type !== "channel") return;
    refreshMembers(tab.name);
    const iv = setInterval(
      () => refreshMembers(tab.name),
      MEMBER_REFRESH_INTERVAL,
    );

    return () => clearInterval(iv);
  }, [loggedIn, activeTab, tabs, refreshMembers]);

  // Auto-scroll.
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages, activeTab]);

  // Focus input on tab change.
  useEffect(() => {
    inputRef.current?.focus();
  }, [activeTab]);

  // Global keyboard handler — capture '/' to prevent
  // Firefox quick search and redirect focus to the input.
  useEffect(() => {
    const handleGlobalKeyDown = (e) => {
      if (
        e.key === "/" &&
        document.activeElement !== inputRef.current &&
        !e.ctrlKey &&
        !e.altKey &&
        !e.metaKey
      ) {
        e.preventDefault();
        inputRef.current?.focus();
      }
    };

    document.addEventListener("keydown", handleGlobalKeyDown);

    // Also focus input on initial mount.
    inputRef.current?.focus();

    return () =>
      document.removeEventListener("keydown", handleGlobalKeyDown);
  }, []);

  // Fetch topic for active channel.
  useEffect(() => {
    if (!loggedIn) return;
    const tab = tabs[activeTab];
    if (!tab || tab.type !== "channel") return;
    api("/channels")
      .then((channels) => {
        const ch = channels.find((c) => c.name === tab.name);
        if (ch && ch.topic)
          setTopics((prev) => ({ ...prev, [tab.name]: ch.topic }));
      })
      .catch(() => {});
  }, [loggedIn, activeTab, tabs]);

  const onLogin = useCallback(
    async (userNick, isResumed) => {
      setNick(userNick);
      setLoggedIn(true);
      addSystemMessage("Server", `Connected as ${userNick}`);

      if (isResumed) {
        // Request MOTD on resumed sessions (new sessions
        // get it automatically from the server during
        // creation).  Channel state is initialized by the
        // server via the message queue
        // (?initChannelState=1), so we do not need to
        // re-JOIN channels here.
        try {
          await api("/messages", {
            method: "POST",
            body: JSON.stringify({ command: "MOTD" }),
          });
        } catch (e) {
          // MOTD is non-critical.
        }

        return;
      }

      // Fresh session — join any previously saved channels.
      const saved = JSON.parse(
        localStorage.getItem("neoirc_channels") || "[]",
      );
      for (const ch of saved) {
        try {
          await api("/messages", {
            method: "POST",
            body: JSON.stringify({ command: "JOIN", to: ch }),
          });
          setTabs((prev) => {
            if (prev.find((t) => t.type === "channel" && t.name === ch))
              return prev;

            return [...prev, { type: "channel", name: ch }];
          });
        } catch (e) {
          // Channel may not exist anymore.
        }
      }
    },
    [addSystemMessage],
  );

  const joinChannel = async (name) => {
    if (!name) return;
    name = name.trim();
    if (!name.startsWith("#")) name = "#" + name;
    try {
      await api("/messages", {
        method: "POST",
        body: JSON.stringify({ command: "JOIN", to: name }),
      });
      setTabs((prev) => {
        if (prev.find((t) => t.type === "channel" && t.name === name))
          return prev;

        return [...prev, { type: "channel", name }];
      });
      const newIdx = tabsRef.current.length;
      setActiveTab(newIdx);
      try {
        const hist = await api(
          `/history?target=${encodeURIComponent(name)}&limit=50`,
        );
        if (Array.isArray(hist)) {
          for (const m of hist) processMessage(m);
        }
      } catch (e) {
        // History may be empty.
      }
    } catch (err) {
      addSystemMessage(
        "Server",
        `Failed to join ${name}: ${err.data?.error || "error"}`,
      );
    }
  };

  const partChannel = async (name, reason) => {
    try {
      const body = reason ? { command: "PART", to: name, body: [reason] } : { command: "PART", to: name };
      await api("/messages", {
        method: "POST",
        body: JSON.stringify(body),
      });
    } catch (e) {
      // Ignore.
    }
    setTabs((prev) =>
      prev.filter((t) => !(t.type === "channel" && t.name === name)),
    );
    setActiveTab(0);
  };

  const closeTab = (idx) => {
    const tab = tabs[idx];
    if (tab.type === "channel") {
      partChannel(tab.name);
    } else if (tab.type === "dm") {
      setTabs((prev) => prev.filter((_, i) => i !== idx));
      if (activeTab >= idx) setActiveTab(Math.max(0, activeTab - 1));
    }
  };

  const openDM = (targetNick) => {
    if (targetNick === nickRef.current) return;
    setTabs((prev) => {
      if (prev.find((t) => t.type === "dm" && t.name === targetNick))
        return prev;

      return [...prev, { type: "dm", name: targetNick }];
    });
    const idx = tabs.findIndex(
      (t) => t.type === "dm" && t.name === targetNick,
    );
    setActiveTab(idx >= 0 ? idx : tabs.length);
  };

  const handleCommand = async (text) => {
    const parts = text.split(" ");
    const cmd = parts[0].toLowerCase();
    const tab = tabs[activeTab];

    switch (cmd) {
      case "/join": {
        if (parts[1]) joinChannel(parts[1]);
        else addSystemMessage("Server", "Usage: /join #channel");

        break;
      }
      case "/part": {
        if (tab.type === "channel") {
          const reason = parts.slice(1).join(" ") || undefined;
          partChannel(tab.name, reason);
        } else {
          addSystemMessage("Server", "You are not in a channel");
        }

        break;
      }
      case "/msg": {
        if (parts[1] && parts.slice(2).join(" ")) {
          const target = parts[1];
          const body = parts.slice(2).join(" ");
          try {
            await api("/messages", {
              method: "POST",
              body: JSON.stringify({
                command: "PRIVMSG",
                to: target,
                body: [body],
              }),
            });
            openDM(target);
          } catch (err) {
            addSystemMessage(
              "Server",
              `Message failed: ${err.data?.error || "error"}`,
            );
          }
        } else {
          addSystemMessage("Server", "Usage: /msg <nick> <message>");
        }

        break;
      }
      case "/me": {
        if (tab.type === "server") {
          addSystemMessage("Server", "Cannot use /me in server window");

          break;
        }
        const actionText = parts.slice(1).join(" ");
        if (actionText) {
          try {
            await api("/messages", {
              method: "POST",
              body: JSON.stringify({
                command: "PRIVMSG",
                to: tab.name,
                body: [ACTION_PREFIX + actionText + ACTION_SUFFIX],
              }),
            });
          } catch (err) {
            addSystemMessage(
              tab.name,
              `Action failed: ${err.data?.error || "error"}`,
            );
          }
        } else {
          addSystemMessage("Server", "Usage: /me <action>");
        }

        break;
      }
      case "/nick": {
        if (parts[1]) {
          try {
            await api("/messages", {
              method: "POST",
              body: JSON.stringify({ command: "NICK", body: [parts[1]] }),
            });
          } catch (err) {
            addSystemMessage(
              "Server",
              `Nick change failed: ${err.data?.error || "error"}`,
            );
          }
        } else {
          addSystemMessage("Server", "Usage: /nick <newnick>");
        }

        break;
      }
      case "/topic": {
        if (tab.type !== "channel") {
          addSystemMessage("Server", "You are not in a channel");

          break;
        }
        const topicText = parts.slice(1).join(" ");
        if (topicText) {
          try {
            await api("/messages", {
              method: "POST",
              body: JSON.stringify({
                command: "TOPIC",
                to: tab.name,
                body: [topicText],
              }),
            });
          } catch (err) {
            addSystemMessage(
              "Server",
              `Topic change failed: ${err.data?.error || "error"}`,
            );
          }
        } else {
          addSystemMessage(
            "Server",
            `Current topic for ${tab.name}: ${topics[tab.name] || "(none)"}`,
          );
        }

        break;
      }
      case "/mode": {
        if (tab.type !== "channel") {
          addSystemMessage("Server", "You are not in a channel");

          break;
        }
        const modeArgs = parts.slice(1);
        if (modeArgs.length > 0) {
          try {
            await api("/messages", {
              method: "POST",
              body: JSON.stringify({
                command: "MODE",
                to: tab.name,
                params: modeArgs,
              }),
            });
          } catch (err) {
            addSystemMessage(
              "Server",
              `Mode change failed: ${err.data?.error || "error"}`,
            );
          }
        } else {
          addSystemMessage(
            "Server",
            "Usage: /mode <+/-mode> [params]",
          );
        }

        break;
      }
      case "/quit": {
        const reason = parts.slice(1).join(" ") || undefined;
        try {
          const body = reason ? { command: "QUIT", body: [reason] } : { command: "QUIT" };
          await api("/messages", {
            method: "POST",
            body: JSON.stringify(body),
          });
        } catch (e) {
          // Ignore.
        }
        localStorage.removeItem("neoirc_token");
        localStorage.removeItem("neoirc_channels");
        window.location.reload();

        break;
      }
      case "/motd": {
        try {
          await api("/messages", {
            method: "POST",
            body: JSON.stringify({ command: "MOTD" }),
          });
        } catch (err) {
          addSystemMessage(
            "Server",
            `Failed to request MOTD: ${err.data?.error || "error"}`,
          );
        }

        break;
      }
      case "/query": {
        if (parts[1]) {
          const target = parts[1];
          openDM(target);
          const msgText = parts.slice(2).join(" ");
          if (msgText) {
            try {
              await api("/messages", {
                method: "POST",
                body: JSON.stringify({
                  command: "PRIVMSG",
                  to: target,
                  body: [msgText],
                }),
              });
            } catch (err) {
              addSystemMessage(
                "Server",
                `Message failed: ${err.data?.error || "error"}`,
              );
            }
          }
        } else {
          addSystemMessage("Server", "Usage: /query <nick> [message]");
        }

        break;
      }
      case "/list": {
        try {
          await api("/messages", {
            method: "POST",
            body: JSON.stringify({ command: "LIST" }),
          });
        } catch (err) {
          addSystemMessage(
            "Server",
            `Failed to list channels: ${err.data?.error || "error"}`,
          );
        }

        break;
      }
      case "/who": {
        const whoTarget = parts[1] || (tab.type === "channel" ? tab.name : "");
        if (whoTarget) {
          try {
            await api("/messages", {
              method: "POST",
              body: JSON.stringify({ command: "WHO", to: whoTarget }),
            });
          } catch (err) {
            addSystemMessage(
              "Server",
              `WHO failed: ${err.data?.error || "error"}`,
            );
          }
        } else {
          addSystemMessage("Server", "Usage: /who #channel");
        }

        break;
      }
      case "/whois": {
        if (parts[1]) {
          try {
            await api("/messages", {
              method: "POST",
              body: JSON.stringify({ command: "WHOIS", to: parts[1] }),
            });
          } catch (err) {
            addSystemMessage(
              "Server",
              `WHOIS failed: ${err.data?.error || "error"}`,
            );
          }
        } else {
          addSystemMessage("Server", "Usage: /whois <nick>");
        }

        break;
      }
      case "/clear": {
        const clearTarget = tab.name;
        setMessages((prev) => ({ ...prev, [clearTarget]: [] }));

        break;
      }
      case "/help": {
        const helpLines = [
          "Available commands:",
          "  /join #channel        — Join a channel",
          "  /part [reason]        — Part the current channel",
          "  /msg nick message     — Send a private message",
          "  /query nick [message] — Open a DM tab (optionally send a message)",
          "  /me action            — Send an action",
          "  /nick newnick         — Change your nickname",
          "  /topic [text]         — View or set channel topic",
          "  /mode +/-flags        — Set channel modes",
          "  /motd                 — Display the message of the day",
          "  /list                 — List all channels",
          "  /who [#channel]       — List users in a channel",
          "  /whois nick           — Show info about a user",
          "  /clear                — Clear messages in the current tab",
          "  /quit [reason]        — Disconnect from server",
          "  /help                 — Show this help",
        ];
        for (const line of helpLines) {
          addSystemMessage("Server", line);
        }

        break;
      }
      default:
        addSystemMessage("Server", `Unknown command: ${cmd}`);
    }
  };

  const sendMessage = async () => {
    const text = input.trim();
    if (!text) return;

    // Save to input history.
    setInputHistory((prev) => {
      const next = [...prev, text];
      if (next.length > 100) next.shift();

      return next;
    });
    setHistoryIndex(-1);
    setInput("");

    const tab = tabs[activeTab];
    if (!tab) return;

    if (text.startsWith("/")) {
      await handleCommand(text);

      return;
    }

    if (tab.type === "server") {
      addSystemMessage(
        "Server",
        "Cannot send messages to the server window. Use /join #channel first.",
      );

      return;
    }

    try {
      await api("/messages", {
        method: "POST",
        body: JSON.stringify({
          command: "PRIVMSG",
          to: tab.name,
          body: [text],
        }),
      });
    } catch (err) {
      addSystemMessage(
        tab.name,
        `Send failed: ${err.data?.error || "error"}`,
      );
    }
  };

  const handleInputKeyDown = (e) => {
    if (e.key === "Enter") {
      sendMessage();
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      if (inputHistory.length > 0) {
        const newIdx =
          historyIndex === -1
            ? inputHistory.length - 1
            : Math.max(0, historyIndex - 1);
        setHistoryIndex(newIdx);
        setInput(inputHistory[newIdx]);
      }
    } else if (e.key === "ArrowDown") {
      e.preventDefault();
      if (historyIndex >= 0) {
        const newIdx = historyIndex + 1;
        if (newIdx >= inputHistory.length) {
          setHistoryIndex(-1);
          setInput("");
        } else {
          setHistoryIndex(newIdx);
          setInput(inputHistory[newIdx]);
        }
      }
    }
  };

  if (!loggedIn) return <LoginScreen onLogin={onLogin} />;

  const currentTab = tabs[activeTab] || tabs[0];
  const currentMessages = messages[currentTab.name] || [];
  const currentMembers = members[currentTab.name] || [];
  const currentTopic = topics[currentTab.name] || "";

  return (
    <div class="irc-app">
      {/* Tab bar */}
      <div class="tab-bar">
        <div class="tabs">
          {tabs.map((tab, i) => (
            <div
              class={`tab ${i === activeTab ? "active" : ""} ${unread[tab.name] > 0 && i !== activeTab ? "has-unread" : ""}`}
              onClick={() => setActiveTab(i)}
              key={tab.name}
            >
              <span class="tab-label">
                {tab.type === "dm" ? tab.name : tab.name}
              </span>
              {unread[tab.name] > 0 && i !== activeTab && (
                <span class="unread-count">({unread[tab.name]})</span>
              )}
              {tab.type !== "server" && (
                <span
                  class="tab-close"
                  onClick={(e) => {
                    e.stopPropagation();
                    closeTab(i);
                  }}
                >
                  ×
                </span>
              )}
            </div>
          ))}
        </div>
        <div class="status-area">
          {!connected && <span class="status-warn">● Reconnecting</span>}
          <span class="status-nick">{nick}</span>
        </div>
      </div>

      {/* Topic bar — channels only */}
      {currentTab.type === "channel" && (
        <div class="topic-bar">
          <span class="topic-label">Topic:</span>{" "}
          <span class="topic-text">
            {currentTopic || "(no topic set)"}
          </span>
        </div>
      )}

      {/* Main content area */}
      <div class="main-area">
        {/* Messages */}
        <div class="messages-panel">
          <div class="messages-scroll">
            {currentMessages.map((m) => (
              <Message msg={m} myNick={nick} key={m.id} />
            ))}
            <div ref={messagesEndRef} />
          </div>
        </div>

        {/* User list — channels only */}
        {currentTab.type === "channel" && (
          <UserList
            members={currentMembers}
            onNickClick={openDM}
          />
        )}
      </div>

      {/* Persistent input line */}
      <div class="input-line">
        <span class="input-prompt">
          [{nick}]
          {currentTab.type !== "server"
            ? ` ${currentTab.name}`
            : ""}
          {" >"}
        </span>
        <input
          ref={inputRef}
          type="text"
          value={input}
          onInput={(e) => setInput(e.target.value)}
          onKeyDown={handleInputKeyDown}
          placeholder={
            currentTab.type === "server"
              ? "Type /help for commands"
              : ""
          }
          spellCheck={false}
          autoComplete="off"
        />
      </div>
    </div>
  );
}

render(<App />, document.getElementById("root"));
