#!/usr/bin/env python3
import os
import re
import html
import json
import time
import socket
import ssl
import random
import statistics
import collections
from pathlib import Path
from dataclasses import dataclass, field
from typing import Dict, List, Tuple, Optional, Any

# -----------------------------
# Configuration
# -----------------------------
SERVER   = os.getenv("IRC_SERVER", "irc.rnet.is")
PORT     = int(os.getenv("IRC_PORT", "6697"))
USE_TLS  = os.getenv("IRC_TLS", "1") == "1"

# Verbose wire logging (incoming/outgoing IRC lines + INFO/WARN/ERROR)
DEBUG    = os.getenv("IRC_DEBUG", "1") == "1"

NICK     = os.getenv("IRC_NICK", "BarackObama")
# User modes to set after successful registration (e.g. +B for bot)
UMODES = os.getenv("IRC_UMODES", "+B-x").strip()

USER     = os.getenv("IRC_USER", "markov")  # default to boring; override via env
REALNAME = os.getenv("IRC_REALNAME", "Markov Bot")

# Comma-separated channel list in env var
CHANNELS = os.getenv("IRC_CHANNELS", "##defocus,#bot").split(",")

# State file for persistence (set per-instance to get separate brains)
STATE_FILE = os.getenv("IRC_STATE", "state.json")

# Command prefix
CMD_PREFIX = "!"


# Directory for owner-curated training files (used by !trainfile). Paths are restricted to this directory.
TRAINFILES_DIR = os.getenv('IRC_TRAINFILES_DIR', 'training').strip() or 'training'
TRAINFILE_MAX_BYTES = int(os.getenv('IRC_TRAINFILE_MAX_BYTES', '2000000'))  # 2MB
TRAINFILE_MAX_LINES = int(os.getenv('IRC_TRAINFILE_MAX_LINES', '200000'))
MARKOV_SPLIT_SENTENCES = os.getenv('MARKOV_SPLIT_SENTENCES', '0') == '1'
# Broadcast command prefix. Example: "!all replyrate" -> behaves like "!replyrate" for *each* bot.
BROADCAST_PREFIX = os.getenv("IRC_BROADCAST_PREFIX", f"{CMD_PREFIX}all").strip()  # default "!all"
# Who may use broadcast commands in channels?
# "owner" = only owner; "ops" = owner or channel ops; "any" = anyone (not recommended)
BROADCAST_AUTH = os.getenv("IRC_BROADCAST_AUTH", "owner").lower()

# Panel discussion mode (moderated by channel ops)
PANEL_MAX_BOTS = int(os.getenv("IRC_PANEL_MAX_BOTS", "6"))
PANEL_DEFAULT_TIMEOUT = float(os.getenv("IRC_PANEL_TIMEOUT", "8.0"))  # seconds to wait between prompts
PANEL_DEFAULT_COOLDOWN = float(os.getenv("IRC_PANEL_COOLDOWN", "1.0"))  # seconds between prompts sent


# If set, commands in channels must be addressed to this bot (e.g. "BotNick: !learn off")
REQUIRE_ADDRESS_IN_CHANNELS = os.getenv("IRC_REQUIRE_ADDRESS", "1") == "1"

# Markov settings
ORDER = int(os.getenv("MARKOV_ORDER", "2"))
MAX_OUTPUT_WORDS = int(os.getenv("MARKOV_MAX_WORDS", "35"))

# Coherence/formatting knobs
# Preserve punctuation as tokens (makes output much more readable). Defaults ON.
PRESERVE_PUNCT = os.getenv("MARKOV_PUNCT", "1") == "1"
# Require at least this many tokens (roughly words) in generated output before accepting.
MIN_OUTPUT_TOKENS = int(os.getenv("MARKOV_MIN_TOKENS", "6"))
# If enabled, ensure generated messages end with ., !, or ? (adds a period if missing).
FORCE_SENTENCE_END = os.getenv("MARKOV_FORCE_END", "1") == "1"

# Tokens we never want to start a generated line with (when punctuation is preserved)
BAD_START_TOKENS = {",", ".", "!", "?", ";", ":", ")", "(", "\"","'", "<END>", "..."}

# Throttling (avoid flooding)
MIN_SECONDS_BETWEEN_SPEAK = float(os.getenv("IRC_SPEAK_COOLDOWN", "2.0"))

# Ignore messages starting with these (common bot command triggers)
IGNORE_PREFIXES = ("!", ".", "?", "/", "\\")  # tune to your channel culture

# Comma-separated nicknames to never learn from (case-insensitive)
IGNORE_NICKS = [n.strip() for n in os.getenv("IRC_IGNORE_NICKS", "").split(",") if n.strip()]


# -----------------------------
# Markov Chain Engine
# -----------------------------
@dataclass
class Markov:
    order: int = ORDER
    chain: Dict[Tuple[str, ...], List[str]] = field(default_factory=dict)
    starts: List[Tuple[str, ...]] = field(default_factory=list)

    def train(self, text: str) -> None:
        words = self._tokenize(text)
        if len(words) <= self.order:
            return

        start = tuple(words[:self.order])
        if (not PRESERVE_PUNCT) or (start and start[0] not in BAD_START_TOKENS):
            self.starts.append(start)

        for i in range(len(words) - self.order):
            state = tuple(words[i:i + self.order])
            nxt = words[i + self.order]
            self.chain.setdefault(state, []).append(nxt)

        terminal_state = tuple(words[-self.order:])
        self.chain.setdefault(terminal_state, []).append("<END>")

    def generate(self, prompt: Optional[str] = None, max_words: int = MAX_OUTPUT_WORDS) -> str:
        if not self.chain or not self.starts:
            return "I am empty inside. Feed me words."

        # Prefer seeds that can continue beyond <END>
        seed: Optional[Tuple[str, ...]] = None
        if prompt:
            p = self._tokenize(prompt)
            if len(p) >= self.order:
                candidates: List[Tuple[str, ...]] = []
                for i in range(len(p) - self.order + 1):
                    st = tuple(p[i:i + self.order])
                    opts = self.chain.get(st)
                    if opts and any(w != "<END>" for w in opts):
                        candidates.append(st)
                if candidates:
                    seed = random.choice(candidates)

        out: List[str] = []
        for _attempt in range(5):
            state = seed if seed else random.choice(self.starts)
            if PRESERVE_PUNCT:
                for _ in range(25):
                    if state and state[0] not in BAD_START_TOKENS:
                        break
                    state = seed if seed else random.choice(self.starts)
            out = list(state)

            for _ in range(max_words):
                options = self.chain.get(tuple(out[-self.order:]))
                if not options:
                    break
                nxt = random.choice(options)
                if nxt == "<END>":
                    break
                out.append(nxt)

            if len(out) > max(self.order, MIN_OUTPUT_TOKENS):
                msg = self._detokenize(out)
                if PRESERVE_PUNCT and msg and msg[0] in ",.!?;:)('":
                    continue
                if FORCE_SENTENCE_END and PRESERVE_PUNCT:
                    if msg and msg[-1] not in ".!?":
                        msg = msg + "."
                return msg

            seed = None
        msg = self._detokenize(out)
        if FORCE_SENTENCE_END and PRESERVE_PUNCT:
            if msg and msg[-1] not in '.!?':
                msg = msg + '.'
        return msg

    def stats(self) -> Dict[str, int]:
        total_transitions = sum(len(v) for v in self.chain.values())

        unique_words = set()
        for state, nexts in self.chain.items():
            unique_words.update(state)
            for w in nexts:
                if w != "<END>":
                    unique_words.add(w)

        return {
            "sentences": len(self.starts),
            "transitions": total_transitions,
            "unique_words": len(unique_words),
        }
    def dict_stats(self, top_k: int = 10) -> Dict[str, Any]:
        """Richer stats about the Markov 'dictionary' (state table)."""
        num_states = len(self.chain)
        num_starts = len(self.starts)
        total_transitions = sum(len(v) for v in self.chain.values())

        # branching factors
        branching = [len(v) for v in self.chain.values()] if self.chain else []
        avg_branch = (total_transitions / num_states) if num_states else 0.0
        med_branch = float(statistics.median(branching)) if branching else 0.0
        max_branch = max(branching) if branching else 0

        # Most common next-tokens (excluding <END>)
        counts: Dict[str, int] = {}
        end_count = 0
        for nxts in self.chain.values():
            for w in nxts:
                if w == "<END>":
                    end_count += 1
                    continue
                counts[w] = counts.get(w, 0) + 1
        top_next = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:max(0, int(top_k))]

        # States with the largest option lists (useful for spotting garbage hubs)
        top_states = sorted(self.chain.items(), key=lambda kv: len(kv[1]), reverse=True)[:5]
        top_state_summaries = [
            {"state": " ".join(st), "options": len(opts)}
            for st, opts in top_states
        ]

        # Approx bytes (rough estimate) for chain table JSON
        try:
            approx_bytes = len(json.dumps({ "|".join(k): v for k, v in self.chain.items() }).encode("utf-8"))
        except Exception:
            approx_bytes = 0

        base = self.stats()  # sentences/transitions/unique_words
        return {
            **base,
            "states": num_states,
            "starts": num_starts,
            "end_tokens": end_count,
            "avg_options_per_state": avg_branch,
            "median_options_per_state": med_branch,
            "max_options_per_state": max_branch,
            "top_next_tokens": top_next,
            "top_state_hubs": top_state_summaries,
            "approx_chain_json_bytes": approx_bytes,
        }



    def _tokenize(self, text: str) -> List[str]:
        text = text.strip()
        if not text:
            return []
        if not PRESERVE_PUNCT:
            words = text.split()
            cleaned = [re.sub(r"^\W+|\W+$", "", w) for w in words]
            return [w for w in cleaned if w]

        # Tokenize into words + punctuation tokens.
        # Keeps apostrophes inside words (don't -> don't).
        # Treats common punctuation as standalone tokens.
        # Example: 'Meredith will die. Do not doubt that' -> ['Meredith','will','die','.', 'Do','not','doubt','that']
        return re.findall(r"[A-Za-z0-9]+(?:'[A-Za-z0-9]+)?|\.\.\.|[.,!?;:()\"']", text)

    def _detokenize(self, tokens: List[str]) -> str:
        if not tokens:
            return ""
        if not PRESERVE_PUNCT:
            return " ".join(tokens)

        out: List[str] = []
        attach_left = {".", ",", "!", "?", ";", ":", ")", "'", '"'}
        attach_right = {"("}

        for t in tokens:
            if not out:
                out.append(t)
                continue
            if t in attach_left:
                out[-1] = out[-1] + t
            elif out[-1] in attach_right:
                out[-1] = out[-1] + t
            else:
                out.append(" " + t)

        return "".join(out)


# -----------------------------
# Bot State (persistence)
# -----------------------------
def _default_defaults() -> Dict[str, Any]:
    return {
        "learning_enabled": True,
        "reply_enabled": True,
        "reply_chance": 0.03,
        "max_words": MAX_OUTPUT_WORDS,
    }

@dataclass
class BotState:
    owner_account: Optional[str] = "TheTFEF"
    channels: List[str] = field(default_factory=list)

    # New: defaults + per-channel overrides (same brain globally)
    defaults: Dict[str, Any] = field(default_factory=_default_defaults)
    per_channel: Dict[str, Dict[str, Any]] = field(default_factory=dict)

    # Global emergency brake: when True, bot will not learn or auto-reply anywhere.
    quarantine: bool = False

    # Do not learn from these nicknames (case-insensitive)
    ignore_nicks: List[str] = field(default_factory=list)

    markov: Markov = field(default_factory=Markov)

    def to_json(self) -> dict:
        chain_json = {"|".join(k): v for k, v in self.markov.chain.items()}
        starts_json = ["|".join(s) for s in self.markov.starts]
        return {
            "owner_account": self.owner_account,
            "channels": self.channels,
            "defaults": self.defaults,
            "per_channel": self.per_channel,
            "quarantine": self.quarantine,
            "ignore_nicks": self.ignore_nicks,
            "order": self.markov.order,
            "chain": chain_json,
            "starts": starts_json,
        }

    @staticmethod
    def from_json(data: dict) -> "BotState":
        st = BotState()
        st.owner_account = data.get("owner_account")
        st.channels = list(data.get("channels", []))

        # Migration: old versions stored these at top-level
        legacy_learning = data.get("learning_enabled")
        legacy_reply = data.get("reply_enabled")
        legacy_chance = data.get("reply_chance")

        defaults = data.get("defaults")
        if isinstance(defaults, dict):
            st.defaults.update(defaults)
        else:
            # Build defaults from legacy if present, else use built-ins
            if legacy_learning is not None:
                st.defaults["learning_enabled"] = bool(legacy_learning)
            if legacy_reply is not None:
                st.defaults["reply_enabled"] = bool(legacy_reply)
            if legacy_chance is not None:
                try:
                    st.defaults["reply_chance"] = float(legacy_chance)
                except Exception:
                    pass

        # Normalize max_words
        try:
            st.defaults["max_words"] = int(st.defaults.get("max_words", MAX_OUTPUT_WORDS))
        except Exception:
            st.defaults["max_words"] = MAX_OUTPUT_WORDS

        per_channel = data.get("per_channel", {})
        if isinstance(per_channel, dict):
            # Normalize keys to lowercase channels
            norm: Dict[str, Dict[str, Any]] = {}
            for ch, cfg in per_channel.items():
                if not isinstance(cfg, dict):
                    continue
                norm[ch.lower()] = dict(cfg)
            st.per_channel = norm

        st.quarantine = bool(data.get("quarantine", False))

        ign = data.get("ignore_nicks", [])
        if isinstance(ign, list):
            st.ignore_nicks = [str(x) for x in ign if str(x).strip()]
        else:
            st.ignore_nicks = []

        order = int(data.get("order", ORDER))
        st.markov = Markov(order=order)

        chain = data.get("chain", {})
        for k, v in chain.items():
            tup = tuple(k.split("|")) if k else tuple()
            st.markov.chain[tup] = list(v)

        starts = data.get("starts", [])
        st.markov.starts = [tuple(s.split("|")) for s in starts if s]

        return st


# -----------------------------
# IRC Bot
# -----------------------------

# ---- Training sanitization ----
# Goal: keep scraped corpora usable (drop citation junk) without wrecking live-chat learning.

_WIKI_JUNK_RE = re.compile(r"^\s*\[\[Category:|^\s*\{\{|\}\}\s*$", re.I)

# Parenthetical date like "(27 July 2004)"
_PAREN_DATE_RE = re.compile(r"\(\s*\d{1,2}\s+[A-Za-z]{3,9}\s+(?:19|20)\d{2}\s*\)")

# Headings like "Speech to ... (12 July 2004)" (no sentence-ending punctuation)
_TITLE_DATE_LINE_RE = re.compile(
    r"^\s*[^.!?]{3,160}\(\s*\d{1,2}\s+[A-Za-z]{3,9}\s+(?:19|20)\d{2}\s*\)\s*$"
)

# Words that strongly indicate a citation/attribution paragraph
_CITE_WORDS_RE = re.compile(
    r"\b("
    r"archived|as quoted|as reported|misattributed|misquoted|reported in|"
    r"interviewer|recorded in|as told to|as described in|as recounted in|"
    r"documentary|transcript|excerpt|published in|first published"
    r")\b",
    re.I,
)

# Publisher/outlet hints (tune as needed)
_OUTLETS_RE = re.compile(
    r"\b("
    r"New York Post|Telegraph|Independent|Reuters|Associated Press|AP News|"
    r"BBC|CNN|Fox News|MSNBC|New Yorker|Tribune|Herald|Daily Caller|Hindustan Times"
    r")\b",
    re.I,
)

_SENT_SPLIT_RE = re.compile(r'(?<=[.!?])\s+(?=["\' ]?[A-Z])')

def split_into_sentences(text: str) -> list[str]:
    """Cheap sentence splitter for curated corpora (no capturing-group footguns)."""
    if not text:
        return []
    parts = _SENT_SPLIT_RE.split(text.strip())
    return [p.strip() for p in parts if p and p.strip()]

def sanitize_for_training(text: str, strict: bool = False) -> str:
    """Clean a line before feeding it to the Markov brain.

    strict=False: light cleanup suitable for live chat
    strict=True : aggressive cleanup for scraped corpora / PM training (drops citations/headings)
    """
    if not text:
        return ""

    # Decode entities and strip HTML-ish tags
    text = html.unescape(text)
    text = re.sub(r"<[^>]+>", " ", text)
    text = re.sub(r"\s+", " ", text).strip()
    if not text:
        return ""

    # Drop obvious structural junk
    if _WIKI_JUNK_RE.search(text):
        return ""

    if strict:
        # Kill pure heading/title lines like: Speech to ... (12 July 2004)
        if _TITLE_DATE_LINE_RE.match(text) and not re.search(r"[.!?]\s*$", text):
            return ""

        # Kill lines that are clearly citations / reporting metadata
        if _CITE_WORDS_RE.search(text) or _OUTLETS_RE.search(text):
            return ""

        # Kill URL-bearing lines (scrapes love these)
        if "http://" in text or "https://" in text:
            return ""

        # If there's a parenthetical date and it's short / non-sentence, treat as citation
        if _PAREN_DATE_RE.search(text) and len(text) < 190 and not re.search(r"[.!?]\s*$", text):
            return ""

        # Strip trailing citation tails that sneak onto otherwise-okay lines:
        #   ", The Chicago Tribune (27 July 2004)"
        text = re.sub(
            r"\s*,?\s*(?:in|on)?\s+[^.]{0,90}?\(\s*\d{1,2}\s+[A-Za-z]{3,9}\s+(?:19|20)\d{2}\s*\)\s*$",
            "",
            text,
            flags=re.I,
        ).strip()

    return text

def is_good_training_line(s: str, strict: bool = False) -> bool:
    """Heuristics to keep garbage out of the brain."""
    if not s:
        return False
    w = s.split()
    if len(w) < (5 if strict else 3):
        return False
    if len(w) > (40 if strict else 60):
        return False
    # citation prose is often comma soup
    if strict and s.count(",") >= 4 and len(s) > 120:
        return False
    # too many digits tends to be dates/refs
    digits = sum(ch.isdigit() for ch in s)
    if strict and digits >= 8:
        return False
    # avoid ellipsis spam (often truncation/scrape artifacts)
    if strict and ("..." in s):
        return False
    return True

def strip_address_prefix(text: str) -> str:
    """Remove leading 'Nick: ' / 'Nick, ' addressing from a line."""
    return re.sub(r"^[A-Za-z0-9_\-\[\]\\`^{}|]+\s*[:;,]\s+", "", text).strip()


def _dequote(s: str) -> str:
    """Strip one layer of wrapping single/double quotes from user input."""
    s = (s or "").strip()
    if len(s) >= 2 and s[0] == s[-1] and s[0] in ("'", '"'):
        return s[1:-1]
    return s

class IRCBot:
    def __init__(self):
        self.sock = None
        self.buf = b""
        self.state = self.load_state()
        self.last_speak = 0.0
        self.account_cache: Dict[str, Optional[str]] = {}
        self.debug = DEBUG

        # Track per-channel privileges from NAMES (353)
        self.chan_privs: Dict[str, Dict[str, set]] = {}

        # CAP state (fixes "connection timeout" on some networks)
        self.cap_pending_end = False
        self.cap_end_sent = False
        self.cap_started_at = 0.0

        # Seed channels from env on first run
        if not self.state.channels:
            self.state.channels = [ch.strip() for ch in CHANNELS if ch.strip()]
            self.save_state()

        # merge env ignore nicks on first run (only if state list is empty)
        if not self.state.ignore_nicks and IGNORE_NICKS:
            self.state.ignore_nicks = list(dict.fromkeys([n.strip() for n in IGNORE_NICKS if n.strip()]))
            self.save_state()

    def log(self, direction: str, line: str):
        if self.debug or direction in ("INFO", "WARN", "ERROR"):
            print(f"{direction} {line}" if direction in (">>>", "<<<") else f"[{direction}] {line}")

    # ---------- Config helpers ----------
    def chan_cfg(self, channel: str) -> Dict[str, Any]:
        ch = (channel or "").lower()
        cfg = dict(self.state.defaults)
        cfg.update(self.state.per_channel.get(ch, {}))
        # Coerce types defensively
        cfg["learning_enabled"] = bool(cfg.get("learning_enabled", True))
        cfg["reply_enabled"] = bool(cfg.get("reply_enabled", True))
        try:
            cfg["reply_chance"] = float(cfg.get("reply_chance", 0.03))
        except Exception:
            cfg["reply_chance"] = 0.03
        cfg["reply_chance"] = max(0.0, min(1.0, cfg["reply_chance"]))
        try:
            cfg["max_words"] = int(cfg.get("max_words", MAX_OUTPUT_WORDS))
        except Exception:
            cfg["max_words"] = MAX_OUTPUT_WORDS
        cfg["max_words"] = max(1, min(200, cfg["max_words"]))
        return cfg

    def set_chan_override(self, channel: str, key: str, value: Any) -> None:
        ch = channel.lower()
        if ch not in self.state.per_channel:
            self.state.per_channel[ch] = {}
        self.state.per_channel[ch][key] = value
        self.save_state()

    def unset_chan_override(self, channel: str, key: str) -> bool:
        ch = channel.lower()
        if ch not in self.state.per_channel:
            return False
        if key not in self.state.per_channel[ch]:
            return False
        del self.state.per_channel[ch][key]
        if not self.state.per_channel[ch]:
            del self.state.per_channel[ch]
        self.save_state()
        return True

    # ---------- Persistence ----------
    def load_state(self) -> BotState:
        if not os.path.exists(STATE_FILE):
            return BotState()
        try:
            with open(STATE_FILE, "r", encoding="utf-8") as f:
                return BotState.from_json(json.load(f))
        except Exception as e:
            print(f"[WARN] Failed to load state: {e}")
            return BotState()

    def save_state(self) -> None:
        tmp = STATE_FILE + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(self.state.to_json(), f)
        os.replace(tmp, STATE_FILE)

    # ---------- IRC helpers ----------
    def connect(self):
        self.log("INFO", f"Connecting to {SERVER}:{PORT} as {NICK} (TLS={'ON' if USE_TLS else 'OFF'})")
        # Resolve and connect (optionally force IPv4 if IRC_FORCE_IPV4=1)
        force_ipv4 = os.getenv("IRC_FORCE_IPV4", "0") == "1"
        family = socket.AF_INET if force_ipv4 else socket.AF_UNSPEC
        infos = socket.getaddrinfo(SERVER, PORT, family, socket.SOCK_STREAM)
        last_err = None
        raw = None
        for fam, socktype, proto, canonname, sockaddr in infos:
            try:
                raw = socket.socket(fam, socktype, proto)
                raw.settimeout(20)
                raw.connect(sockaddr)
                self.log("INFO", f"Connected TCP to {sockaddr[0]}:{sockaddr[1]} (family={'IPv4' if fam==socket.AF_INET else 'IPv6'})")
                break
            except Exception as e:
                last_err = e
                try:
                    if raw:
                        raw.close()
                except Exception:
                    pass
                raw = None
        if raw is None:
            raise last_err if last_err else OSError("Could not connect")


        if USE_TLS:
            ctx = ssl.create_default_context()

            # Some networks/servers/middleboxes choke on TLS 1.3 ClientHello. Allow forcing TLS<=1.2.
            if os.getenv('IRC_TLS_DISABLE_13', '0') == '1':
                try:
                    ctx.maximum_version = ssl.TLSVersion.TLSv1_2
                except Exception:
                    ctx.options |= getattr(ssl, 'OP_NO_TLSv1_3', 0)

            # Optional: force minimum TLS version (e.g. IRC_TLS_MIN=1.2)
            minv = os.getenv('IRC_TLS_MIN', '').strip()
            if minv:
                try:
                    ctx.minimum_version = {
                        '1.0': ssl.TLSVersion.TLSv1,
                        '1.1': ssl.TLSVersion.TLSv1_1,
                        '1.2': ssl.TLSVersion.TLSv1_2,
                        '1.3': ssl.TLSVersion.TLSv1_3,
                    }[minv]
                except Exception:
                    pass

            cafile = os.getenv("IRC_TLS_CAFILE")
            if cafile:
                ctx.load_verify_locations(cafile=cafile)

            if os.getenv("IRC_TLS_INSECURE", "0") == "1":
                ctx.check_hostname = False
                ctx.verify_mode = ssl.CERT_NONE

            self.sock = ctx.wrap_socket(raw, server_hostname=SERVER)
            self.sock.settimeout(None)  # don't treat idle as disconnect

            if self.debug:
                try:
                    self.log("INFO", f"TLS negotiated: protocol={self.sock.version()} cipher={self.sock.cipher()}")
                except Exception as e:
                    self.log("WARN", f"Could not fetch TLS details: {e}")
        else:
            self.sock = raw

        # Proper CAP handshake (prevents "connection timeout" on some networks)
        self.cap_pending_end = True
        self.cap_end_sent = False
        self.cap_started_at = time.time()

        # Panel state (in-memory only): channel -> dict
        self.panels: Dict[str, Dict[str, Any]] = {}

        self.send_raw("CAP LS 302")
        self.send_raw(f"NICK {NICK}")
        self.send_raw(f"USER {USER} 0 * :{REALNAME}")
        # Do NOT send CAP END here; wait for LS->REQ->ACK/NAK in the loop.

    def send_raw(self, line: str):
        if not self.sock:
            return
        if self.debug:
            self.log(">>>", line)
        self.sock.sendall((line + "\r\n").encode("utf-8", errors="ignore"))

    def privmsg(self, target: str, msg: str):
        now = time.time()
        if now - self.last_speak < MIN_SECONDS_BETWEEN_SPEAK:
            return
        self.last_speak = now
        self.send_raw(f"PRIVMSG {target} :{msg}")

    def privmsg_unthrottled(self, target: str, msg: str):
        self.send_raw(f"PRIVMSG {target} :{msg}")

    def join_channels(self):
        for ch in self.state.channels:
            ch = ch.strip()
            if ch:
                self.send_raw(f"JOIN {ch}")

    # ---------- Parsing ----------
    def read_lines(self):
        while True:
            try:
                chunk = self.sock.recv(4096)
            except socket.timeout:
                # No data right now; keep the connection alive.
                continue
            if not chunk:
                raise ConnectionError("Disconnected")
            self.buf += chunk
            while b"\r\n" in self.buf:
                line, self.buf = self.buf.split(b"\r\n", 1)
                yield line.decode("utf-8", errors="ignore")

    def parse_irc_line(self, line: str):
        tags = {}
        prefix = None
        trailing = None

        rest = line
        if rest.startswith("@"):
            tagpart, rest = rest.split(" ", 1)
            for kv in tagpart[1:].split(";"):
                if "=" in kv:
                    k, v = kv.split("=", 1)
                    tags[k] = v
                else:
                    tags[kv] = True

        if rest.startswith(":"):
            prefix, rest = rest[1:].split(" ", 1)

        if " :" in rest:
            rest, trailing = rest.split(" :", 1)

        parts = rest.split()
        cmd = parts[0] if parts else ""
        params = parts[1:] if len(parts) > 1 else []
        return tags, prefix, cmd, params, trailing

    def nick_from_prefix(self, prefix: Optional[str]) -> Optional[str]:
        return prefix.split("!", 1)[0] if prefix else None

    def _extract_addressed_command(self, target: str, msg: str) -> Optional[str]:
        """Return command text (starting with CMD_PREFIX) if msg addresses this bot in a channel."""
        if not target.startswith("#"):
            return None
        m = msg.lstrip()
        # Accept: "Nick: !cmd ..." or "Nick, !cmd ..." (case-insensitive nick match)
        nick_re = re.escape(NICK)
        pat = rf"^(?i:{nick_re})\s*[:;,]\s*(.+)$"
        mo = re.match(pat, m)
        if not mo:
            return None
        rest = mo.group(1).lstrip()
        if rest.startswith(CMD_PREFIX):
            return rest
        return None

    # ---------- Owner check ----------
    def _train_from_file(self, target: str, filename: str, strict: bool = True) -> None:
        """Owner-only: read a text file from TRAINFILES_DIR and train the Markov brain.

        Safety:
          - only relative paths inside TRAINFILES_DIR
          - max bytes / max lines
          - never echoes file contents back to IRC, only stats
        """
        filename = (filename or "").strip().strip('"').strip("'")
        if not filename:
            self.privmsg(target, f"Usage: {CMD_PREFIX}trainfile <filename> [strict|loose]")
            return

        # Disallow absolute paths / traversal
        if filename.startswith(("/", "~")) or ".." in filename or "\\" in filename:
            self.privmsg(target, "Nope. Use a plain filename inside the training dir.")
            return

        base = Path(TRAINFILES_DIR).expanduser()
        path = (base / filename).resolve()
        try:
            base_res = base.resolve()
        except Exception:
            base_res = base

        if base_res not in path.parents and path != base_res:
            self.privmsg(target, "Nope. File must live inside the training dir.")
            return

        if not path.exists() or not path.is_file():
            self.privmsg(target, f"File not found: {path}")
            return

        try:
            size = path.stat().st_size
        except Exception:
            size = None

        if size is not None and size > TRAINFILE_MAX_BYTES:
            self.privmsg(target, f"File too large ({size} bytes). Limit is {TRAINFILE_MAX_BYTES}.")
            return

        data = path.read_text(encoding="utf-8", errors="ignore")
        # Bound by bytes even if stat lied (or encoding expands)
        b = data.encode("utf-8", errors="ignore")
        if len(b) > TRAINFILE_MAX_BYTES:
            data = b[:TRAINFILE_MAX_BYTES].decode("utf-8", errors="ignore")

        lines = data.splitlines()[:TRAINFILE_MAX_LINES]

        trained = 0
        dropped = 0
        for raw in lines:
            s = raw.strip()
            if not s:
                continue

            cleaned = sanitize_for_training(s, strict=strict)
            cleaned = strip_address_prefix(cleaned)
            if not cleaned:
                dropped += 1
                continue

            if strict and MARKOV_SPLIT_SENTENCES:
                for sent in split_into_sentences(cleaned):
                    sent = sent.strip()
                    if not sent:
                        continue
                    if is_good_training_line(sent, strict=True):
                        self.state.markov.train(sent)
                        trained += 1
                    else:
                        dropped += 1
            else:
                if is_good_training_line(cleaned, strict=strict):
                    self.state.markov.train(cleaned)
                    trained += 1
                else:
                    dropped += 1

        self.save_state()
        mode = "strict" if strict else "loose"
        self.privmsg(target, f"Trained from {filename} ({mode}). trained={trained} dropped={dropped} lines_read={len(lines)}")


    # ---- Pruning / maintenance (owner-only) ----
    def _prune_stats(self) -> str:
        mk = self.state.markov
        states = len(mk.chain)
        starts = len(mk.starts)
        transitions = sum(len(v) for v in mk.chain.values())
        # token stats
        uniq = set()
        next_counts = collections.Counter()
        for st, opts in mk.chain.items():
            for w in st:
                if w != "<END>":
                    uniq.add(w)
            for o in opts:
                if o != "<END>":
                    uniq.add(o)
                next_counts[o] += 1

        # start token distribution
        start_first = collections.Counter()
        for s in mk.starts:
            if s:
                start_first[s[0]] += 1

        top_starts = ", ".join([f"{tok}({cnt})" for tok, cnt in start_first.most_common(8)]) or "n/a"
        top_next = ", ".join([f"{tok}({cnt})" for tok, cnt in next_counts.most_common(8)]) or "n/a"

        return (
            f"dict: states={states} starts={starts} transitions={transitions} uniq={len(uniq)} "
            f"top_start={top_starts} top_next={top_next}"
        )

    def _prune_dropstarts(self, pattern: str) -> tuple[int, int]:
        mk = self.state.markov
        before = len(mk.starts)
        rx = re.compile(pattern)
        mk.starts = [s for s in mk.starts if not (s and rx.search(s[0]))]
        return before, len(mk.starts)

    def _prune_droptoken(self, token: str) -> tuple[int, int]:
        mk = self.state.markov
        removed = 0
        states_touched = 0
        for st, opts in list(mk.chain.items()):
            new_opts = [o for o in opts if o != token]
            if len(new_opts) != len(opts):
                removed += (len(opts) - len(new_opts))
                mk.chain[st] = new_opts
                states_touched += 1
        return removed, states_touched

    def _prune_dropregex(self, pattern: str) -> tuple[int, int]:
        mk = self.state.markov
        rx = re.compile(pattern)
        removed = 0
        states_touched = 0
        for st, opts in list(mk.chain.items()):
            new_opts = [o for o in opts if not rx.search(o)]
            if len(new_opts) != len(opts):
                removed += (len(opts) - len(new_opts))
                mk.chain[st] = new_opts
                states_touched += 1
        return removed, states_touched

    def _prune_vacuum(self) -> tuple[int, int]:
        mk = self.state.markov
        # remove empty states
        before_states = len(mk.chain)
        mk.chain = {st: opts for st, opts in mk.chain.items() if opts}
        after_states = len(mk.chain)

        # remove starts that no longer exist in chain (can't continue)
        before_starts = len(mk.starts)
        mk.starts = [s for s in mk.starts if s in mk.chain]
        after_starts = len(mk.starts)

        return (before_states - after_states), (before_starts - after_starts)

    def _prune_dropstarts_preview(self, pattern: str) -> tuple[int, int]:
        mk = self.state.markov
        before = len(mk.starts)
        rx = re.compile(pattern)
        after = sum(1 for s in mk.starts if not (s and rx.search(s[0])))
        return before, after

    def _prune_droptoken_preview(self, token: str) -> tuple[int, int]:
        mk = self.state.markov
        removed = 0
        states_touched = 0
        for opts in mk.chain.values():
            # count removals without modifying
            cnt = sum(1 for o in opts if o == token)
            if cnt:
                removed += cnt
                states_touched += 1
        return removed, states_touched

    def _prune_dropregex_preview(self, pattern: str) -> tuple[int, int]:
        mk = self.state.markov
        rx = re.compile(pattern)
        removed = 0
        states_touched = 0
        for opts in mk.chain.values():
            cnt = sum(1 for o in opts if rx.search(o))
            if cnt:
                removed += cnt
                states_touched += 1
        return removed, states_touched

    def _prune_vacuum_preview(self) -> tuple[int, int]:
        mk = self.state.markov
        removed_states = sum(1 for opts in mk.chain.values() if not opts)
        # starts that wouldn't exist after empty-state removal
        would_chain = {st for st, opts in mk.chain.items() if opts}
        removed_starts = sum(1 for s in mk.starts if s not in would_chain)
        return removed_states, removed_starts

    def is_owner(self, nick: str, tags: dict) -> bool:
        if not self.state.owner_account:
            return False

        acct = tags.get("account")
        if acct and acct != "*":
            self.account_cache[nick] = acct
            return acct.lower() == self.state.owner_account.lower()

        cached = self.account_cache.get(nick)
        return bool(cached) and cached.lower() == self.state.owner_account.lower()

    def is_chanop(self, channel: str, nick: str) -> bool:
        ch = (channel or "").lower()
        if not ch.startswith("#"):
            return False
        cmap = self.chan_privs.get(ch, {})
        privs = cmap.get(nick, set())
        return ("op" in privs) or ("halfop" in privs)

    # ---------- Panel (ops-only in-channel) ----------
    def _panel_get(self, channel: str) -> Optional[Dict[str, Any]]:
        return self.panels.get((channel or "").lower())

    def _panel_set(self, channel: str, st: Dict[str, Any]) -> None:
        self.panels[(channel or "").lower()] = st

    def _panel_clear(self, channel: str) -> None:
        self.panels.pop((channel or "").lower(), None)

    def _panel_parse_bots(self, parts: List[str]) -> List[str]:
        """Parse --bots Anders,Obama,... from a command parts list."""
        if "--bots" not in parts:
            return []
        i = parts.index("--bots")
        if i + 1 >= len(parts):
            return []
        raw = parts[i + 1]
        bots = [b.strip() for b in raw.split(",") if b.strip()]
        seen = set()
        out: List[str] = []
        for b in bots:
            bl = b.lower()
            if bl in seen:
                continue
            seen.add(bl)
            out.append(b)
        return out[:max(1, PANEL_MAX_BOTS)]

    def _panel_status_line(self, channel: str) -> str:
        st = self._panel_get(channel)
        if not st:
            return "Panel: OFF."
        topic = st.get("topic", "")
        bots = ", ".join(st.get("bots", [])) or "n/a"
        r = st.get("round", 0)
        rmax = st.get("max_rounds", 0)
        return f"Panel: ON (round {r}/{rmax}) topic='{topic}' bots=[{bots}]"

    def _panel_prompt_bot(self, channel: str, botnick: str, topic: str) -> None:
        """Cue a panelist bot to speak.

        Uses an unthrottled PRIVMSG call, but the panel loop applies a cooldown delay
        between cues to avoid bursts.
        """
        topic_clean = (topic or "").strip() or "Say something."
        self.privmsg_unthrottled(channel, f"{botnick}: {CMD_PREFIX}say {topic_clean}")

    # ---------- Parsing helpers ----------
    def _parse_chance(self, s: str) -> Optional[float]:
        t = s.strip()
        try:
            if t.endswith('%'):
                val = float(t[:-1]) / 100.0
            else:
                val = float(t)
                if val > 1.0:
                    val = val / 100.0
            return max(0.0, min(1.0, val))
        except Exception:
            return None

    def _parse_bool(self, s: str) -> Optional[bool]:
        t = s.strip().lower()
        if t in ("on", "yes", "true", "1"):
            return True
        if t in ("off", "no", "false", "0"):
            return False
        return None

    # ---------- Help ----------
    def _cmd_help(self, target: str, where: str):
        cfg = self.chan_cfg(where)
        owner = self.state.owner_account or "unset"
        learning = "ON" if cfg["learning_enabled"] else "OFF"
        replies = "ON" if cfg["reply_enabled"] else "OFF"
        pct = int(round(cfg["reply_chance"] * 100))
        mw = cfg["max_words"]
        lines = [
            "Commands: !help, !stats, !knowledge, !dictstats, !trainfile <file> [strict|loose], !prune <...> (try: !prune dryrun ...), !say [prompt], !panel <HostNick> <start|round|stop|status> (ops-only)",
            f"Addressed commands in channels: {NICK}: !help (required)" if REQUIRE_ADDRESS_IN_CHANNELS else "Addressed commands in channels: optional",
            "Owner-only: !owner set <NickServAccount>, !learn on|off, !reply on|off, !replyrate <5%|0.05|5>, !join #chan[,#chan2], !part #chan, !quarantine on|off|status",
            "Per-channel config (owner): !cshow [#chan], !cset #chan <learning|reply|chance|max_words> <value>, !cunset #chan <key>",
            "Ignore learning (owner): !ignore list|add <nick>|del <nick>",
            f"This channel: learning={learning}, replies={replies}@{pct}%, max_words={mw}. Owner={owner}.",
        ]
        for ln in lines:
            self.privmsg_unthrottled(target, ln)

    # ---------- Command handling ----------
    def handle_command(self, nick: str, target: str, text: str, tags: dict):
        parts = text.strip().split()
        if not parts:
            return
        cmd = parts[0].lower()

        # prevent broadcast recursion
        if cmd == (CMD_PREFIX + 'all'):
            self.privmsg(target, 'Nice try. Use !all <real_command>.')
            return

        # Determine context channel for per-channel ops:
        # - If message is in-channel, target is the channel.
        # - If message is a PM, target is our nick; per-channel ops should be explicit.
        in_channel = target.startswith("#")
        ctx_channel = target if in_channel else ""

        if cmd in (f"{CMD_PREFIX}help", f"{CMD_PREFIX}commands"):
            self._cmd_help(target, ctx_channel or (self.state.channels[0] if self.state.channels else ""))
            return

        if cmd in (f"{CMD_PREFIX}knowledge", f"{CMD_PREFIX}memory", f"{CMD_PREFIX}brain"):
            s = self.state.markov.stats()
            self.privmsg(target, f"I remember {s['sentences']} messages, {s['unique_words']} unique words, {s['transitions']} transitions.")
            return

        if cmd == f"{CMD_PREFIX}trainfile":
            # owner-only; best used via PM so you can train bots individually
            if not self.is_owner(nick or "", tags):
                self.privmsg(target, "Nope.")
                return
            if len(parts) < 2:
                self.privmsg(target, f"Usage: {CMD_PREFIX}trainfile <filename> [strict|loose]")
                return
            filename = parts[1]
            mode = parts[2].lower() if len(parts) >= 3 else "strict"
            strict = (mode != "loose")
            self._train_from_file(target, filename, strict=strict)
            return


        if cmd == f"{CMD_PREFIX}prune":
            if not self.is_owner(nick or "", tags):
                self.privmsg(target, "Nope.")
                return
            if len(parts) < 2:
                self.privmsg(
                    target,
                    f"Usage: {CMD_PREFIX}prune <stats|dropstarts|droptoken|dropregex|vacuum> [arg]"
                )
                return
            dryrun = False
            sub = parts[1].lower()
            if sub == 'dryrun':
                dryrun = True
                if len(parts) < 3:
                    self.privmsg(target, f"Usage: {CMD_PREFIX}prune dryrun <stats|dropstarts|droptoken|dropregex|vacuum> [arg]")
                    return
                sub = parts[2].lower()

            if sub == "stats":
                self.privmsg(target, self._prune_stats())
                return

            if sub == "dropstarts":
                if len(parts) < (4 if dryrun else 3):
                    self.privmsg(target, f"Usage: {CMD_PREFIX}prune {'dryrun ' if dryrun else ''}dropstarts <regex>")
                    return
                # args start at 3 when dryrun, else 2
                pattern = _dequote(" ".join(parts[3:]) if dryrun else " ".join(parts[2:]))
                if dryrun:
                    before, after = self._prune_dropstarts_preview(pattern)
                    self.privmsg(target, f"DRYRUN dropstarts: {before} -> {after} (would remove {before-after}) (regex={pattern})")
                    return
                before, after = self._prune_dropstarts(pattern)
                self.save_state()
                self.privmsg(target, f"Pruned starts: {before} -> {after} (regex={pattern})")
                return

            if sub == "droptoken":
                if len(parts) < (4 if dryrun else 3):
                    self.privmsg(target, f"Usage: {CMD_PREFIX}prune {'dryrun ' if dryrun else ''}droptoken <token>")
                    return
                token = _dequote(parts[3] if dryrun else parts[2])
                if dryrun:
                    removed, touched = self._prune_droptoken_preview(token)
                    self.privmsg(target, f"DRYRUN droptoken '{token}': would_remove={removed} touched_states={touched}")
                    return
                removed, touched = self._prune_droptoken(token)
                self.save_state()
                self.privmsg(target, f"Dropped token '{token}': removed={removed} touched_states={touched}")
                return

            if sub == "dropregex":
                if len(parts) < (4 if dryrun else 3):
                    self.privmsg(target, f"Usage: {CMD_PREFIX}prune {'dryrun ' if dryrun else ''}dropregex <regex>")
                    return
                pattern = _dequote(" ".join(parts[3:]) if dryrun else " ".join(parts[2:]))
                if dryrun:
                    removed, touched = self._prune_dropregex_preview(pattern)
                    self.privmsg(target, f"DRYRUN dropregex: would_remove={removed} touched_states={touched} (regex={pattern})")
                    return
                removed, touched = self._prune_dropregex(pattern)
                self.save_state()
                self.privmsg(target, f"Dropped next tokens by regex: removed={removed} touched_states={touched} (regex={pattern})")
                return

            if sub == "vacuum":
                if dryrun:
                    removed_states, removed_starts = self._prune_vacuum_preview()
                    self.privmsg(target, f"DRYRUN vacuum: would_remove_states={removed_states} would_remove_starts={removed_starts}")
                    return
                removed_states, removed_starts = self._prune_vacuum()
                self.save_state()
                self.privmsg(target, f"Vacuumed: removed_states={removed_states} removed_starts={removed_starts}")
                return

            self.privmsg(target, "Unknown prune subcommand. Try: stats, dropstarts, droptoken, dropregex, vacuum")
            return

        if cmd == f"{CMD_PREFIX}panel":
            # Ops-only and only usable in-channel (so ops status is meaningful).
            if not target.startswith("#") or not self.is_chanop(target, nick or ""):
                self.privmsg(target, "Nope. Channel ops only (in-channel).")
                return

            if len(parts) < 2:
                self.privmsg(target, f"Usage: {CMD_PREFIX}panel <start|round|stop|status> ...")
                return

            sub = parts[1].lower()

            if sub in ("status", "show"):
                self.privmsg(target, self._panel_status_line(target))
                return

            if sub == "stop":
                self._panel_clear(target)
                self.privmsg(target, "Panel: OFF.")
                return

            if sub == "start":
                # Syntax: !panel start <rounds:int> <topic...> --bots Anders,Obama,...
                if len(parts) < 4:
                    self.privmsg(target, f"Usage: {CMD_PREFIX}panel start <rounds> <topic...> --bots Anders,BarackObama")
                    return
                try:
                    max_rounds = int(parts[2])
                except Exception:
                    self.privmsg(target, "Rounds must be an integer, e.g. 2")
                    return
                max_rounds = max(1, min(10, max_rounds))
                bots = self._panel_parse_bots(parts)
                if not bots:
                    self.privmsg(target, f"Usage: {CMD_PREFIX}panel start <rounds> <topic...> --bots Anders,BarackObama")
                    return

                # Topic is everything between rounds and --bots (if present)
                try:
                    bots_i = parts.index("--bots")
                    topic = " ".join(parts[3:bots_i]).strip()
                except ValueError:
                    topic = " ".join(parts[3:]).strip()
                if not topic:
                    topic = "Say something relevant."

                st = {
                    "topic": topic,
                    "bots": bots,
                    "round": 0,
                    "max_rounds": max_rounds,
                    "started_by": nick,
                    "timeout": PANEL_DEFAULT_TIMEOUT,
                    "cooldown": PANEL_DEFAULT_COOLDOWN,
                    "last_sent": 0.0,
                }
                self._panel_set(target, st)
                self.privmsg(target, f"[Panel] Topic: {topic} | Roster: {', '.join(bots)} | Rounds: {max_rounds}. Use {CMD_PREFIX}panel round to begin.")
                return

            if sub == "round":
                st = self._panel_get(target)
                if not st:
                    self.privmsg(target, "Panel is OFF. Start one with: !panel start <rounds> <topic...> --bots A,B,C")
                    return
                r = int(st.get("round", 0)) + 1
                rmax = int(st.get("max_rounds", 1))
                if r > rmax:
                    self._panel_clear(target)
                    self.privmsg(target, f"[Panel] Finished. (ran {rmax} rounds)")
                    return
                st["round"] = r
                self._panel_set(target, st)

                topic = str(st.get("topic", "")).strip()
                bots = list(st.get("bots", []))

                self.privmsg(target, f"[Panel] Round {r}/{rmax}: {topic}")

                for b in bots:
                    if b.lower() == NICK.lower():
                        continue
                    self._panel_prompt_bot(target, b, topic)
                    st["last_sent"] = time.time()
                    time.sleep(float(st.get('cooldown', PANEL_DEFAULT_COOLDOWN) or 0.0))
                self._panel_set(target, st)
                self.privmsg(target, f"[Panel] Round {r} cued. Use {CMD_PREFIX}panel round for next, or {CMD_PREFIX}panel stop.")
                return

            self.privmsg(target, f"Unknown panel subcommand. Try: {CMD_PREFIX}panel start|round|stop|status")
            return



        if cmd in (f"{CMD_PREFIX}dict", f"{CMD_PREFIX}dictstats", f"{CMD_PREFIX}mstats"):
            s = self.state.markov.dict_stats(top_k=8)
            top_words = ", ".join([f"{w}({c})" for w, c in s["top_next_tokens"]]) if s["top_next_tokens"] else "n/a"
            hubs = "; ".join([f"{h['state']} [{h['options']}]" for h in s["top_state_hubs"]]) if s["top_state_hubs"] else "n/a"
            self.privmsg(
                target,
                "dict: "
                f"states={s['states']} starts={s['starts']} transitions={s['transitions']} end={s['end_tokens']} "
                f"opts(avg/med/max)={s['avg_options_per_state']:.2f}/{s['median_options_per_state']:.0f}/{s['max_options_per_state']} "
                f"uniq={s['unique_words']} approx_json={s['approx_chain_json_bytes']}B "
                f"top_next={top_words} "
                f"hubs={hubs}"
            )
            return


        # Owner bootstrap / set
        if cmd == f"{CMD_PREFIX}owner":
            if len(parts) >= 3 and parts[1].lower() == "set":
                desired = parts[2].strip()
                if not self.state.owner_account:
                    self.state.owner_account = desired
                    self.save_state()
                    self.privmsg(target, f"Owner set to '{desired}'.")
                else:
                    if self.is_owner(nick, tags):
                        self.state.owner_account = desired
                        self.save_state()
                        self.privmsg(target, f"Owner updated to '{desired}'.")
                    else:
                        self.privmsg(target, "Nope. Owner only.")
            else:
                self.privmsg(target, "Usage: !owner set <NickServAccount>")
            return

        # Per-channel config commands
        if cmd == f"{CMD_PREFIX}cshow":
            if not self.is_owner(nick, tags):
                self.privmsg(target, "Nope. Owner only.")
                return
            ch = parts[1].strip() if len(parts) >= 2 else (ctx_channel or (self.state.channels[0] if self.state.channels else ""))
            if not ch:
                self.privmsg(target, "Usage: !cshow #channel")
                return
            cfg = self.chan_cfg(ch)
            over = self.state.per_channel.get(ch.lower(), {})
            self.privmsg(
                target,
                f"{ch} cfg: learning={cfg['learning_enabled']} reply={cfg['reply_enabled']} chance={cfg['reply_chance']:.3f} max_words={cfg['max_words']} | overrides={over}"
            )
            return

        if cmd == f"{CMD_PREFIX}cset":
            if not self.is_owner(nick, tags):
                self.privmsg(target, "Nope. Owner only.")
                return
            if len(parts) < 4:
                self.privmsg(target, "Usage: !cset #channel <learning|reply|chance|max_words> <value>")
                return
            ch = parts[1].strip()
            key = parts[2].strip().lower()
            valraw = parts[3].strip()

            if not ch.startswith("#"):
                self.privmsg(target, "First arg must be a channel like #bot")
                return

            if key in ("learning", "learn", "learning_enabled"):
                b = self._parse_bool(valraw)
                if b is None:
                    self.privmsg(target, "Value must be on/off")
                    return
                self.set_chan_override(ch, "learning_enabled", b)
                self.privmsg(target, f"{ch}: learning_enabled={b}")
                return

            if key in ("reply", "replies", "reply_enabled"):
                b = self._parse_bool(valraw)
                if b is None:
                    self.privmsg(target, "Value must be on/off")
                    return
                self.set_chan_override(ch, "reply_enabled", b)
                self.privmsg(target, f"{ch}: reply_enabled={b}")
                return

            if key in ("chance", "replychance", "reply_chance", "replyrate"):
                fval = self._parse_chance(valraw)
                if fval is None:
                    self.privmsg(target, "Value must be like 5% or 0.05 or 5")
                    return
                self.set_chan_override(ch, "reply_chance", fval)
                self.privmsg(target, f"{ch}: reply_chance={fval:.3f}")
                return

            if key in ("max", "maxwords", "max_words"):
                try:
                    iv = int(valraw)
                except Exception:
                    self.privmsg(target, "max_words must be an integer")
                    return
                iv = max(1, min(200, iv))
                self.set_chan_override(ch, "max_words", iv)
                self.privmsg(target, f"{ch}: max_words={iv}")
                return

            self.privmsg(target, "Unknown key. Use learning|reply|chance|max_words")
            return

        if cmd == f"{CMD_PREFIX}cunset":
            if not self.is_owner(nick, tags):
                self.privmsg(target, "Nope. Owner only.")
                return
            if len(parts) < 3:
                self.privmsg(target, "Usage: !cunset #channel <learning_enabled|reply_enabled|reply_chance|max_words>")
                return
            ch = parts[1].strip()
            key = parts[2].strip()
            if not ch.startswith("#"):
                self.privmsg(target, "First arg must be a channel like #bot")
                return
            ok = self.unset_chan_override(ch, key)
            self.privmsg(target, f"{ch}: {'unset' if ok else 'no-op'} {key}")
            return

        # Quarantine: emergency brake. Usable by owner anywhere, or by channel ops *in that channel*.
        # Usage: !quarantine on|off|status
        if cmd == f"{CMD_PREFIX}quarantine":
            in_channel = target.startswith("#")
            authorized = self.is_owner(nick, tags) or (in_channel and self.is_chanop(target, nick))
            if not authorized:
                self.privmsg(target, "Nope. Owner or channel ops only.")
                return

            arg = parts[1].lower() if len(parts) >= 2 else "status"
            if arg in ("status", "show"):
                self.privmsg(target, f"Quarantine is {'ON' if self.state.quarantine else 'OFF'}.")
                return

            b = self._parse_bool(arg)
            if b is None:
                self.privmsg(target, "Usage: !quarantine on|off|status")
                return

            self.state.quarantine = b
            self.save_state()
            if b:
                self.privmsg(target, "Quarantine: ON. Learning and auto-replies are disabled everywhere.")
            else:
                self.privmsg(target, "Quarantine: OFF. Normal per-channel settings apply again.")
            return


        # Ignore list for learning (owner-only)
        # Usage: !ignore list | !ignore add <nick> | !ignore del <nick>
        if cmd == f"{CMD_PREFIX}ignore":
            if not self.is_owner(nick, tags):
                self.privmsg(target, "Nope. Owner only.")
                return

            if len(parts) < 2:
                self.privmsg(target, "Usage: !ignore list | !ignore add <nick> | !ignore del <nick>")
                return

            sub = parts[1].lower()
            if sub == "list":
                if not self.state.ignore_nicks:
                    self.privmsg(target, "Ignore list is empty.")
                else:
                    self.privmsg(target, "Ignored nicks (no learning): " + ", ".join(self.state.ignore_nicks))
                return

            if sub in ("add", "plus", "+"):
                if len(parts) < 3:
                    self.privmsg(target, "Usage: !ignore add <nick>")
                    return
                n = parts[2].strip()
                if not n:
                    self.privmsg(target, "Nick cannot be empty.")
                    return
                lower = {x.lower() for x in self.state.ignore_nicks}
                if n.lower() in lower:
                    self.privmsg(target, f"{n} is already ignored.")
                    return
                self.state.ignore_nicks.append(n)
                self.save_state()
                self.privmsg(target, f"Now ignoring {n} for learning.")
                return

            if sub in ("del", "rm", "remove", "-"):
                if len(parts) < 3:
                    self.privmsg(target, "Usage: !ignore del <nick>")
                    return
                n = parts[2].strip()
                if not n:
                    self.privmsg(target, "Nick cannot be empty.")
                    return
                before = len(self.state.ignore_nicks)
                self.state.ignore_nicks = [x for x in self.state.ignore_nicks if x.lower() != n.lower()]
                if len(self.state.ignore_nicks) == before:
                    self.privmsg(target, f"{n} was not in ignore list.")
                    return
                self.save_state()
                self.privmsg(target, f"Removed {n} from ignore list.")
                return

            self.privmsg(target, "Usage: !ignore list | !ignore add <nick> | !ignore del <nick>")
            return

        # Back-compat owner commands: in-channel -> set that channel override;
        # in PM -> set defaults.
        if cmd == f"{CMD_PREFIX}learn":
            if not self.is_owner(nick, tags):
                self.privmsg(target, "Nope. Owner only.")
                return
            if len(parts) < 2:
                # show effective if in-channel, else defaults
                if ctx_channel:
                    cfg = self.chan_cfg(ctx_channel)
                    self.privmsg(target, f"{ctx_channel}: learning is {'ON' if cfg['learning_enabled'] else 'OFF'}." )
                else:
                    self.privmsg(target, f"Defaults: learning is {'ON' if self.state.defaults['learning_enabled'] else 'OFF'}." )
                return
            b = self._parse_bool(parts[1])
            if b is None:
                self.privmsg(target, "Usage: !learn on|off")
                return
            if ctx_channel:
                self.set_chan_override(ctx_channel, "learning_enabled", b)
                self.privmsg(target, f"{ctx_channel}: learning {'ON' if b else 'OFF'}." )
            else:
                self.state.defaults["learning_enabled"] = b
                self.save_state()
                self.privmsg(target, f"Defaults: learning {'ON' if b else 'OFF'}." )
            return

        if cmd in (f"{CMD_PREFIX}reply", f"{CMD_PREFIX}respond"):
            if not self.is_owner(nick, tags):
                self.privmsg(target, "Nope. Owner only.")
                return
            if len(parts) < 2:
                if ctx_channel:
                    cfg = self.chan_cfg(ctx_channel)
                    self.privmsg(target, f"{ctx_channel}: replies are {'ON' if cfg['reply_enabled'] else 'OFF'} at {int(round(cfg['reply_chance']*100))}%." )
                else:
                    self.privmsg(target, f"Defaults: replies are {'ON' if self.state.defaults['reply_enabled'] else 'OFF'} at {int(round(float(self.state.defaults['reply_chance'])*100))}%." )
                return
            b = self._parse_bool(parts[1])
            if b is None:
                self.privmsg(target, "Usage: !reply on|off")
                return
            if ctx_channel:
                self.set_chan_override(ctx_channel, "reply_enabled", b)
                self.privmsg(target, f"{ctx_channel}: replies {'ON' if b else 'OFF'}." )
            else:
                self.state.defaults["reply_enabled"] = b
                self.save_state()
                self.privmsg(target, f"Defaults: replies {'ON' if b else 'OFF'}." )
            return

        if cmd in (f"{CMD_PREFIX}replyrate", f"{CMD_PREFIX}chance", f"{CMD_PREFIX}replychance"):
            if not self.is_owner(nick, tags):
                self.privmsg(target, "Nope. Owner only.")
                return
            if len(parts) < 2:
                if ctx_channel:
                    cfg = self.chan_cfg(ctx_channel)
                    self.privmsg(target, f"{ctx_channel}: reply chance is {int(round(cfg['reply_chance']*100))}%." )
                else:
                    self.privmsg(target, f"Defaults: reply chance is {int(round(float(self.state.defaults['reply_chance'])*100))}%." )
                return
            fval = self._parse_chance(parts[1])
            if fval is None:
                self.privmsg(target, "Couldn't parse that. Try: 5% or 0.05 or 5")
                return
            if ctx_channel:
                self.set_chan_override(ctx_channel, "reply_chance", fval)
                self.privmsg(target, f"{ctx_channel}: reply chance set to {int(round(fval*100))}%." )
            else:
                self.state.defaults["reply_chance"] = fval
                self.save_state()
                self.privmsg(target, f"Defaults: reply chance set to {int(round(fval*100))}%." )
            return

        # Join channels (owner)
        if cmd == f"{CMD_PREFIX}join":
            if not self.is_owner(nick, tags):
                self.privmsg(target, "Nope. Owner only.")
                return
            if len(parts) < 2:
                self.privmsg(target, "Usage: !join #channel[,#channel2]")
                return
            new_chans: List[str] = []
            for ch in parts[1].split(","):
                ch = ch.strip()
                if not ch.startswith("#"):
                    continue
                if ch not in self.state.channels:
                    self.state.channels.append(ch)
                    new_chans.append(ch)
                self.send_raw(f"JOIN {ch}")
            if new_chans:
                self.save_state()
                self.privmsg(target, f"Joined: {', '.join(new_chans)}")
            else:
                self.privmsg(target, "Joined (or already in) specified channels.")
            return

        # Part channels (owner)
        if cmd == f"{CMD_PREFIX}part":
            if not self.is_owner(nick, tags):
                self.privmsg(target, "Nope. Owner only.")
                return
            if len(parts) < 2:
                self.privmsg(target, "Usage: !part #channel")
                return
            ch = parts[1].strip()
            self.send_raw(f"PART {ch} :Requested by owner")
            if ch in self.state.channels:
                self.state.channels.remove(ch)
                self.save_state()
            self.privmsg(target, f"Left {ch}.")
            return

        # Generate
        if cmd == f"{CMD_PREFIX}say":
            prompt = " ".join(parts[1:]) if len(parts) > 1 else None
            # For !say, use max_words for the current channel if present
            where = ctx_channel if ctx_channel else (self.state.channels[0] if self.state.channels else "")
            cfg = self.chan_cfg(where) if where else self.state.defaults
            mw = int(cfg.get("max_words", MAX_OUTPUT_WORDS))
            self.privmsg(target, self.state.markov.generate(prompt=prompt, max_words=mw))
            return

        # Stats
        if cmd == f"{CMD_PREFIX}stats":
            where = ctx_channel if ctx_channel else (self.state.channels[0] if self.state.channels else "")
            cfg = self.chan_cfg(where) if where else self.state.defaults
            self.privmsg(
                target,
                f"states={len(self.state.markov.chain)} starts={len(self.state.markov.starts)} "
                f"learning={'ON' if cfg.get('learning_enabled', True) else 'OFF'} "
                f"replies={'ON' if cfg.get('reply_enabled', True) else 'OFF'} "
                f"chance={int(round(float(cfg.get('reply_chance', 0.03))*100))}% "
                f"max_words={int(cfg.get('max_words', MAX_OUTPUT_WORDS))} "
                f"owner={self.state.owner_account or 'unset'}"
            )
            return

    # ---------- CAP handling ----------
    def _handle_cap(self, params: List[str]):
        # CAP <nick> LS/ACK/NAK :stuff
        if len(params) < 2:
            return
        subcmd = params[1].upper()

        if subcmd == "LS":
            # Request what we want once we know what's offered
            self.send_raw("CAP REQ :message-tags account-tag")
            return

        if subcmd in ("ACK", "NAK"):
            if self.cap_pending_end and not self.cap_end_sent:
                self.send_raw("CAP END")
                self.cap_end_sent = True
                self.cap_pending_end = False
            return

    # ---------- Content gates (channel-independent) ----------
    def should_reply_to(self, nick: Optional[str], text: str) -> bool:
        if not nick or nick.lower() == NICK.lower():
            return False
        if not self.state.markov.chain or not self.state.markov.starts:
            return False
        t = text.strip()
        if not t:
            return False
        if any(t.startswith(p) for p in IGNORE_PREFIXES):
            return False
        if "http://" in t or "https://" in t:
            return False
        return True

    def should_learn_from(self, nick: Optional[str], text: str) -> bool:
        if not nick or nick.lower() == NICK.lower():
            return False
        # Never learn from ignored nicks
        if nick and nick.lower() in {n.lower() for n in self.state.ignore_nicks}:
            return False
        t = text.strip()
        if not t:
            return False
        if any(t.startswith(p) for p in IGNORE_PREFIXES):
            return False
        if "http://" in t or "https://" in t:
            return False
        return True

    def run(self):
        self.connect()
        for line in self.read_lines():
            if self.debug:
                self.log("<<<", line)

            tags, prefix, cmd, params, trailing = self.parse_irc_line(line)

            # CAP timeout failsafe: if CAP never answers, end it after 3s
            if self.cap_pending_end and not self.cap_end_sent and (time.time() - self.cap_started_at) > 3.0:
                self.send_raw("CAP END")
                self.cap_end_sent = True
                self.cap_pending_end = False

            if cmd == "PING":
                self.send_raw(f"PONG :{trailing or ''}")
                continue

            if cmd == "CAP":
                self._handle_cap(params)
                continue

            # RPL_NAMREPLY: build per-channel privilege map (@=op, +=voice, %=halfop)
            if cmd == "353" and len(params) >= 3 and trailing is not None:
                ch = params[2].lower()
                cmap = self.chan_privs.setdefault(ch, {})
                for entry in trailing.split():
                    if not entry:
                        continue
                    prefix = entry[0]
                    name = entry
                    if prefix in ("@", "+", "%", "~", "&"):
                        name = entry[1:]
                    privs = cmap.setdefault(name, set())
                    if prefix in ("@", "&", "~"):
                        privs.add("op")
                    elif prefix == "%":
                        privs.add("halfop")
                    elif prefix == "+":
                        privs.add("voice")
                continue

            # End of NAMES list
            if cmd == "366" and len(params) >= 2:
                continue

            if cmd == "001":
                # Set user modes after successful registration
                if UMODES:
                    self.send_raw(f"MODE {NICK} {UMODES}")
                self.join_channels()
                continue

            if cmd == "PRIVMSG" and params and trailing is not None:
                target = params[0]
                nick = self.nick_from_prefix(prefix)
                msg = trailing

                acct = tags.get("account")
                if nick and acct and acct != "*":
                    self.account_cache[nick] = acct

                cmd_text = None

                # Broadcast commands: "!all <cmd...>" runs like "!<cmd...>" on every bot.
                # This bypasses address requirement so it's quick to chain.
                msg_strip = msg.strip()
                if msg_strip.lower().startswith((BROADCAST_PREFIX.lower() + " ")):
                    payload = msg_strip[len(BROADCAST_PREFIX):].strip()
                    # Auth check for broadcast in-channel (defaults to owner-only)
                    if target.startswith("#"):
                        allowed = False
                        if BROADCAST_AUTH == "any":
                            allowed = True
                        elif BROADCAST_AUTH == "ops":
                            allowed = self.is_owner(nick or "", tags) or self.is_chanop(target, nick or "")
                        else:  # "owner"
                            allowed = self.is_owner(nick or "", tags)
                        if not allowed:
                            # Quietly ignore to avoid revealing auth rules to randos
                            payload = ""
                    if payload:
                        cmd_text = payload if payload.startswith(CMD_PREFIX) else (CMD_PREFIX + payload)

                if cmd_text is None and target.startswith("#") and REQUIRE_ADDRESS_IN_CHANNELS:
                    cmd_text = self._extract_addressed_command(target, msg)
                elif cmd_text is None:
                    # PMs (or when address requirement disabled) accept plain prefix commands
                    if msg.startswith(CMD_PREFIX):
                        cmd_text = msg

                # Also allow addressed commands even if REQUIRE_ADDRESS_IN_CHANNELS is off
                if cmd_text is None and target.startswith("#"):
                    cmd_text = self._extract_addressed_command(target, msg)
                # Allow certain commands in-channel without addressing (e.g., quick moderation tools).
                # Still gated by permissions inside handle_command (owner/op checks).
                if cmd_text is None and target.startswith("#") and msg.startswith(CMD_PREFIX):
                    parts0 = msg.strip().split()
                    first = parts0[0].lower() if parts0 else ""
                    if first == f"{CMD_PREFIX}quarantine":
                        cmd_text = msg
                    elif first == f"{CMD_PREFIX}panel":
                        # Panel is intentionally unaddressed, but MUST be directed at a specific host bot:
                        #   !panel <HostNick> start 2 <topic...> --bots A,B,C
                        # Only the named host should react; others should ignore silently.
                        if len(parts0) >= 2 and parts0[1].lower() == NICK.lower():
                            # Strip the hostnick so handle_command sees: !panel <subcmd> ...
                            cmd_text = f"{CMD_PREFIX}panel " + " ".join(parts0[2:])


                if cmd_text is not None:
                    self.handle_command(nick or "", target, cmd_text, tags)
                    continue

                # Per-channel behavior knobs (single brain globally)
                cfg = self.chan_cfg(target) if target.startswith("#") else dict(self.state.defaults)

                if self.state.quarantine:
                    continue

                if cfg.get("learning_enabled", True) and self.should_learn_from(nick, msg):
                    strict_train = bool(int(os.getenv('MARKOV_TRAIN_STRICT', '0')))
                    strict_here = strict_train or (not target.startswith('#') and self.is_owner(nick or '', tags))
                    clean = sanitize_for_training(msg, strict=strict_here)

                    clean = strip_address_prefix(clean)

                    if clean and is_good_training_line(clean, strict=strict_here):


                        if bool(int(os.getenv('MARKOV_SPLIT_SENTENCES', '0'))) and strict_here:


                            for sent in split_into_sentences(clean):


                                if is_good_training_line(sent, strict=True):


                                    self.state.markov.train(sent)


                        else:


                            self.state.markov.train(clean)
                    if random.random() < 0.02:
                        self.save_state()

                if cfg.get("reply_enabled", True) and self.should_reply_to(nick, msg):
                    if random.random() < float(cfg.get("reply_chance", 0.03)):
                        mw = int(cfg.get("max_words", MAX_OUTPUT_WORDS))
                        self.privmsg(target, self.state.markov.generate(prompt=msg, max_words=mw))

        self.save_state()


def main():
    bot = IRCBot()
    while True:
        try:
            bot.run()
        except Exception as e:
            print(f"[ERROR] {e}. Reconnecting in 10s...")
            time.sleep(10)


if __name__ == "__main__":
    main()
