#!/data/data/com.termux/files/usr/bin/env python3

import itertools as it, operator as op, functools as ft
import contextlib as cl, datetime as dt, pathlib as pl
import collections as cs, collections.abc as cs_abc
import os, sys, io, stat, re, time, secrets, enum, json, zlib, math
import asyncio, socket, signal, inspect, configparser, tempfile
import hashlib, base64, random, unicodedata, string, ast, urllib.parse
import logging, logging.handlers

import aiohttp



class RDIRCDConfigBase:
	# This is a regular ini file with key=value lines under couple sections.
	# String time intervals can be either simple floats or stuff like
	#  "30s", "10min", "1h 20m", "1mo3d5h", etc - see parse_duration func for that.
	# For values ending with whitespace (like prefixes) in ini config files,
	#  add backslash ("\") at the end after spaces, for example: prefix-edit = E: \
	# Values starting with spaces/tabs should be prefixed
	#  with a backslash. Example: prefix-guild-event = \ - ev

	version = '23.02.3' # git-version: py-str

	irc_host = '127.0.0.1' # socket bind addr, can be set via -i/--irc-bind cli option
	irc_port = 6667 # irc listening port, can set in -i/--irc-bind, see also tls opts
	irc_password_hash = '' # use -H/--conf-pw-scrypt option to generate hash for this
	irc_password = '' # for plaintext password - do not use, use password-hash instead
	irc_auth_tbf = '30:8' # token-bucket filter, rate=1/30s burst=8
	irc_host_af = 0 # auto-picked by getaddrinfo(), 2=IPv4 10=IPv6
	irc_uid_len = 4 # can be increased to fix conflicts
	irc_uid_seed = ''
	irc_prefix_edit = '[edit] '
	irc_prefix_attachment = '[att] '
	irc_prefix_embed = '[em.{}] '
	irc_prefix_sticker = '[sticker] '
	irc_prefix_uis = '[UIs] '
	irc_prefix_call = '[call] '
	irc_prefix_guild_event = '--- ' # user bans, friend statuses, scheduled guild events, etc
	irc_prefix_all = '' # universal prefix to add to all non-notice incoming messages
	irc_prefix_all_private = '' # universal prefix to include in all incoming private-chat msgs
	irc_names_timeout = '1d' # time since last activity
	irc_motd_file_path = '' # file path to read for motd command
	irc_nick_sys = 'core' # nick used as source for info/error notice-msgs

	# Path to PEM file with cert and key to use TLS
	#  for all IRC connections, with default python/openssl server-side parameters.
	# If file is specified but missing, it will be auto-generated by default.
	irc_tls_pem_file = ''
	# openssl req -subj to generate cert pem
	#  file with, if specified path is missing. Empty - disabled.
	irc_tls_pem_gen_subj = '/CN=rdircd'
	irc_tls = None # socket tls context, if enabled

	irc_len_hwm = 450 # split irc lines when they're longer than hwm to lwm length
	irc_len_lwm = 300
	irc_len_topic = 300 # cannot be split, so is truncated instead
	irc_len_monitor = 350 # to tuncate long lines in #rdircd.monitor/leftover channels
	irc_len_monitor_lines = 4 # to limit multiline msgs in #rdircd.monitor/leftover channels

	irc_chan_modes = False # causes needless spam on ZNC reconnects
	irc_chan_auto_join_re = '' # regexp to match against irc chan names to auto-join
	irc_disable_reactions = False # disables all "--- reacts" messages
	irc_inline_reply_quote_len = 90 # 0 = disable

	irc_private_chat_min_others_to_use_id_name = 2 # for #me.chat.user1+user2+... vs #me.chat.<id>
	irc_private_chat_name_len = 120 # long userlist-name will truncate usernames, depends on client
	irc_private_chat_name_user_min_len = 20 # don't truncate usernames in userlist-name beyond that

	irc_thread_chan_name_len = 30 # truncate thread-name to this len in thread-chans, 0 = just id

	irc_chan_sys = 'rdircd.{type}' # name format for type=control and type=debug channels
	# Name format (python str.format) for private chat channels.
	# {names} or {id} can be used instead of {names_or_id} to force e.g. #me.chat.<id> format.
	irc_chan_private = 'chat.{names_or_id}'
	# Name of catch-all "monitor" channel for all msgs. Set to empty - won't be created.
	irc_chan_monitor = 'rdircd.monitor'
	# Name format of per-discord
	#  "monitor" channels for all msgs in there. Empty - disabled.
	irc_chan_monitor_guild = 'rdircd.monitor.{prefix}'

	# Name of "leftover" channel for any discord messages in channels that
	#  IRC client is not connected to. "monitor" channels don't count. Empty - won't be created.
	irc_chan_leftover = 'rdircd.leftover'
	# Name of "leftover" chan for msgs in any
	#  non-joined channels of one specific discord server/guild. Empty - disable.
	irc_chan_leftover_guild = 'rdircd.leftover.{prefix}'

	discord_auto_connect = True
	discord_api_url = 'https://discord.com/api/v{api_ver}/'
	discord_api_user_agent = ( f'rdircd/{version}'
		f' (reliable-discord-irc-client) aiohttp/{aiohttp.__version__}' )
	discord_ws_timeout = 20.0
	discord_ws_heartbeat = 15.0
	discord_ws_auth_timeout = 60.0 # reconnect websocket if auth process lags too badly
	discord_ws_reconnect_min = '65.0'
	discord_ws_reconnect_max = '700.0'
	discord_ws_reconnect_factor = 1.6
	discord_ws_reconnect_warn = '1800:6' # to filter-out expected reconnects
	discord_ws_reconnect_warn_max_delay = True # log warnings when reconnects reached max delay
	discord_ws_reconnect_warn_always = False # log warning with session info on every reconnect
	discord_ws_reconnect_on_auth_fail = False # useful for discord service disruptions
	discord_http_delay_padding = 10.0 # added to retry_after
	discord_http_timeout_conn = 40.0
	discord_http_timeout_conn_sock = 30.0
	discord_gateway = '' # fetched and stored in last config, region-specific

	# discord_msg_mention_re should match only discord user mentions.
	# "nick" group must be irc nick, to be replaced with
	#  discord user-id tag, all other capturing groups are replaced by "".
	# Don't use repeating/overlapping capturing groups (w/o "?:"). Empty value - disable.
	discord_msg_mention_re = r'(?:^|\s)(@)(?P<nick>[^\s,;@+]+)'
	# discord_msg_mention_re_ignore is matched
	#  against full capture of the regexp above, not full line.
	discord_msg_mention_re_ignore = r'@(?:everyone|here)'
	discord_msg_mention_irc_decode = True # try irc_name_revert on mention-matches from irc

	discord_msg_confirm_timeout = 25.0 # can include extra requests to resolve user-mentions
	discord_user_mention_cache_timeout = 3 * 24 * 3600 # to remember nicks for user-mentions

	# discord_msg_edit_re is a regexp to match follow-up last-message edits, e.g. s/aaa/bbb/.
	# "aaa" group is used as a python regexp to match what to replace, "bbb" - replacement.
	# Any msg matched by this regexp is treated as edit for re.sub(), never sent to channel.
	# If re.sub() with these parameters makes no replacement(s), error notice is generated.
	# Default regex matches s/A/B/ or s|A|B| or s:A:B: - sed/perl-like regexp-replace expressions.
	discord_msg_edit_re = r'^\s*s(?P<sep>[/|:])(?P<aaa>.*)(?P=sep)(?P<bbb>.*)(?P=sep)\s*$'
	discord_msg_del_re = r'^\s*//del\s*$' # deletes last-sent msg, if matched, never sent to channel

	# Prefix for thread-id values, used in thread-chan names
	#   and msg prefixes if propagation from these to parent channel is enabled.
	discord_thread_id_prefix = '='
	# discord_thread_msgs_in_parent_chan propagates messages from threads to a parent channel,
	#   adding thread-id prefix, through which response can be redirected as well, if enabled.
	discord_thread_msgs_in_parent_chan = True
	# Enable to see mirrored msgs
	#   in #rdircd.monitor channel(s) too - e.g. to have it all there as-is.
	discord_thread_msgs_in_parent_chan_monitor = False
	# discord_thread_msgs_in_parent_chan_full_prefix - enable to use full
	#  chan-name prefix instead of shorter thread-id, for IRC clients with easy click-to-join.
	discord_thread_msgs_in_parent_chan_full_prefix = False
	# discord_thread_redirect_prefixed_responses_from_parent_chan allows to send msgs
	#   to threads from parent channel by prepending thread-id prefix to each one of these.
	# For example "=vot5 hi!" will send "hi!" msg to =vot5 thread
	#   sub-channel only, or print an error if such thread-id is not recognized.
	discord_thread_redirect_prefixed_responses_from_parent_chan = True

	# Print info for some embedded youtube, twitter, etc links, if/when discord provides it
	discord_embed_info = True
	discord_embed_info_buffer = 40 # last N links to remember for delayed annotation updates
	discord_embed_info_len = 250 # will truncate long youtube titles and twitter msgs

	discord_chan_dedup_fmt = '{name}.{id_hash}' # how same-irc-name discord chans get disambiguated
	discord_chan_dedup_hash_len = 4 # length of id_hash part in chan-dedup-fmt

	auth_email = '' # discord account email, used to login there
	auth_password = ''
	auth_token = '' # auto-fetched using email/password, unless mfa/captcha is in the way
	auth_token_manual = False # never fetch/refresh auth token, for captcha/mfa logins

	debug_verbose = False # for debug-level stderr and debug channel, same as --debug option
	debug_err_cut = 150 # for various error messages from discord, which can be long html junk
	debug_msg_cut = 50 # message part length in debug logs
	debug_proto_log_shared = True # send protocol logs to normal debug logging and log-file too
	debug_proto_cut = 90 # cut-length for irc/discord protocol msgs in debug logs, if shared
	debug_proto_log_file = '' # log file(s) for all irc/discord protocol messages
	debug_proto_log_file_size = int(1.5e6)
	debug_proto_log_file_count = 9
	debug_proto_aiohttp = True # log aiohttp request/response info in proto-log
	debug_log_file = '' # debug-level log, not affected by "verbose" option
	debug_log_file_size = int(1.5e6)
	debug_log_file_count = 9
	debug_chan_proto_cut = 230 # limit for printing protocol msgs via debug-channel command
	debug_chan_proto_tail = 50
	debug_asyncio_logs = False # debug= value in asyncio.run()
	# Randomly segfault-crash every 1<n<2*mmts
	#   minutes, for backwards-compatibility with various legacy C daemons :)
	debug_mean_minutes_to_segfault = 0.0

	_conf_path = '~/.rdircd.ini'
	_conf_sections = 'auth', 'irc', 'discord', 'debug'
	_conf_sections_old = dict(auth_main='auth', aliases='renames') # old -> new renames
	_conf_sections_old_found = set()

	# Converted values get assigned to attrs like conf._irc_chan_auto_join_re
	_conv_irc_chan_auto_join_re = lambda s, v: re.compile(v or '$x') # $x = never match
	_conv_discord_msg_mention_re_ignore = lambda s, v: re.compile(v or '$x')
	_conv_discord_msg_del_re = lambda s, v: re.compile(v or '$x')
	def _conv_discord_msg_mention_re(self, v):
		if not v: return
		rx = re.compile(v)
		if 'nick' not in rx.groupindex:
			raise ValueError(f'Missing "nick" group in discord-mentions regexp: {v!r}')
		return rx
	def _conv_discord_msg_edit_re(self, v):
		if not v: return re.compile('$x')
		rx = re.compile(v)
		if {'aaa', 'bbb'}.difference(rx.groupindex):
			raise ValueError( 'discord-edit regexp must contain named'
				f' "aaa" and "bbb" groups to capture pattern/replacement: {v!r}' )
		return rx



err_fmt = lambda err: f'[{err.__class__.__name__}] {err}'

class LogMessage:
	def __init__(self, fmt, a, k): self.fmt, self.a, self.k = fmt, a, k
	def __str__(self):
		return ( self.fmt.format(*self.a, **self.k)
			if self.a or self.k else self.fmt ).replace('\n', ' ⏎ ')

class LogStyleAdapter(logging.LoggerAdapter):
	def __init__(self, logger, extra=None): super().__init__(logger, extra or {})
	def log(self, level, msg, *args, **kws):
		if not self.isEnabledFor(level): return
		log_kws = dict((k, kws.pop(k, None)) for k in ['extra', 'exc_info'])
		if not isinstance(log_kws['extra'], dict): log_kws['extra'] = dict(extra=log_kws['extra'])
		msg, kws = self.process(msg, kws)
		self.logger._log(level, LogMessage(msg, args, kws), (), **log_kws)

class LogFuncHandler(logging.Handler):
	def __init__(self, func):
		super().__init__()
		self.func, self.locked = func, False
	def emit(self, record):
		if self.locked: return # to avoid logging-of-logging loops, assuming sync call
		self.locked = True
		try: self.func(self.format(record))
		# except Exception: self.handleError(record) # too noisy
		except Exception as err: log_bak.exception('LogFuncHandler failed - {}', err_fmt(err))
		finally: self.locked = False

class LogEmptyMsgFilter(logging.Filter):
	def filter(self, record):
		msg = record.msg
		return bool(msg if isinstance(msg, str) else msg.fmt)
log_empty_filter = LogEmptyMsgFilter()

class LogProtoDebugFilter(logging.Filter):
	debug_re = re.compile(rb'^:\S+ (PRIVMSG|NOTICE) #rdircd\.debug :')
	def filter(self, record):
		try: st, msg = record.extra
		except: return True
		return not ( st == ' >>'
			and isinstance(msg, bytes)
			and self.debug_re.search(msg) )
log_proto_debug_filter = LogProtoDebugFilter()

class LogProtoFormatter(logging.Formatter):
	last_ts = last_reltime = None
	def format(self, record):
		# LogRecords coming here tend to be duplicated, as handler is
		#  attached to multiple loggers, e.g. irc.conn + proto.irc.conn
		# So to track relative timestamps, such same-time duplicates have to be skipped
		if record.created != self.last_ts: # new (non-dupe) record
			self.last_ts, reltime = ( record.created,
				(record.created - self.last_ts) if self.last_ts else 0 )
			self.last_reltime = '{:>7s}'.format(f'{"+" if reltime >= 0 else ""}{reltime:,.3f}')
		record.reltime = self.last_reltime
		record.asctime = time.strftime(
			'%Y-%m-%dT%H:%M:%S', time.localtime(record.created) )
		record.asctime += f'.{record.msecs:03.0f}'
		if record.name.startswith('proto.'): record.name = record.name[6:]
		try:
			st, msg = record.extra
			if isinstance(msg, bytes): msg = json.dumps(msg.decode())
		except Exception as err:
			st, msg = 'err', err_fmt(err)
			log_bak.exception('LogProtoFormatter failed - {}', msg)
		record.message = f'{st} :: {msg}'
		return self.formatMessage(record)

class LogFileHandler(logging.handlers.RotatingFileHandler):
	def set_file(self, path):
		self.stream, self.baseFilename = None, os.path.abspath(os.fspath(path))
	def get_file(self): return self.baseFilename

class LogLevelCounter(logging.Handler):
	def __init__(self, *args, **kws):
		super().__init__(*args, **kws)
		self.counts = cs.Counter()
	def emit(self, record):
		self.counts['all'] += 1
		if record.levelno <= logging.DEBUG: return
		self.counts[record.levelname.lower()] += 1

def pprint(data):
	if not (pp := getattr(pprint, 'module', None)):
		log_bak.critical('--- !!! Debug-pprint was left in the code somewhere !!! ---')
		import pprint as pp; pprint.module = pp
	return pp.pprint(data, width=140, compact=True)

get_logger = lambda name: LogStyleAdapter(logging.getLogger(name))
log_bak = get_logger('fallback')
log_proto_root = logging.getLogger('proto')


def setup_aiohttp_trace_logging(log):
	tc = aiohttp.TraceConfig()

	@cl.contextmanager
	def log_req(part, ident=None, t='req'):
		if not log.isEnabledFor(logging.DEBUG): return
		try:
			dt = ' >>' if t == 'req' else '<< '
			if ident: log.debug('', extra=(dt, f'{t} :: {ident}'))
			log.debug('', extra=(dt, f'{t} :: {part} start'))
			yield log
			log.debug('', extra=(dt, f'{t} :: {part} end'))
		except Exception as err:
			err = err_fmt(err)
			log.exception( 'Protocol logging failed: {}',
				err, extra=('xxx', f'{t} :: {part} FAIL :: {err}') )

	# This does not dump all req headers, but should be good enough
	async def on_req_start(s, tc_ctx, ps):
		tc_ctx.req_uid = str_hash(os.urandom(8))
		with log_req('headers', f'[{tc_ctx.req_uid}] {ps.method} {ps.url}') as log:
			for k, v in ps.headers.items(): log.debug('', extra=('  >', f'  {k}: {v}'))
	tc.on_request_start.append(on_req_start)

	async def on_req_chunk(s, tc_ctx, ps):
		a = getattr(tc_ctx, 'pos', 0)
		b = tc_ctx.pos = a + len(ps.chunk)
		with log_req('body', f'[{tc_ctx.req_uid}] {a}-{b}') as log:
			if ps.chunk: log.debug('', extra=('  >', ps.chunk))
	tc.on_request_chunk_sent.append(on_req_chunk)

	async def on_req_done(s, tc_ctx, ps):
		res_info = ( '{0.status} {0.reason}'
			' HTTP/{0.version.major}.{0.version.minor}' ).format(ps.response)
		with log_req('headers', f'[{tc_ctx.req_uid}] {res_info}', 'res') as log:
			for k, v in ps.response.headers.items(): log.debug('', extra=('<  ', f'  {k}: {v}'))
		with log_req('body', f'[{tc_ctx.req_uid}]', 'res') as log:
			log.debug('', extra=('<  ', await ps.response.read()))
	tc.on_request_end.append(on_req_done)

	return tc

def sockopt_resolve(prefix, v):
	prefix = prefix.upper()
	for k in dir(socket):
		if not k.startswith(prefix): continue
		if getattr(socket, k) == v: return k[len(prefix):]
	return v


# str_norm is NOT used in irc, where rfc1459 (ascii letters/chars) casefold is more traditional
str_norm = lambda v: unicodedata.normalize('NFKC', v.strip()).casefold()

def str_part(s, sep, default=None):
	'Examples: str_part("user@host", "<@", "root"), str_part("host:port", ":>")'
	if sep.strip(c := sep.strip('<>')) == '<':
		return (default, s) if c not in s else s.split(c, 1)
	else: return (s, default) if c not in s else s.rsplit(c, 1)

def str_cut(s, max_len, len_bytes=False, repr_fmt=False, ext=' ...[{s_len}]'):
	'''Truncates longer strings to "max_len", adding "ext" suffix.
		Squashes line-breaks to ⏎, unless bytes or repr_fmt are used for full repr() escaping.'''
	if isinstance(s, bytes): s, repr_fmt = s.decode('utf-8', 'replace'), True
	if not isinstance(s, str): s = str(s)
	if repr_fmt: s = repr(s)[1:-1] # for logs and such, to escape all weird chars
	else: s = s.replace('\n', '⏎')
	s_len, ext_tpl = f'{len(s):,d}', ext.format(s_len='12/345')
	if max_len > 0 and len(s) > max_len:
		s_len = f'{max_len}/{s_len}'
		if not len_bytes: s = s[:max_len - len(ext_tpl)] + ext.format(s_len=s_len)
		else:
			n = max_len - len(ext_tpl.encode())
			s = s.encode()[:n].decode(errors='ignore') + ext.format(s_len=s_len)
	return s

def data_repr(data):
	pp = getattr(data_repr, '_pp', None)
	if not pp:
		import pprint as pp
		pp = data_repr._pp = pp.PrettyPrinter(indent=2, width=100, compact=True)
	return pp.pformat(data)

def str_hash(s, c=None, key='rdircd', strip=''):
	s_raw, s = s, base64.urlsafe_b64encode(
		hashlib.blake2s(str(s).encode(), key=key.encode()).digest() ).decode()
	for sc in strip + '-_=': s = s.replace(sc, '')
	if c is None: return s
	for n in range(30): # limit is to avoid unlikely a -> ... -> a loops
		if len(s) < c: s = str_hash(s, c, key, strip)
		if len(s) >= c: break
	else: raise RuntimeError(f'str_hash() failed on: {s_raw!r} {[c, key, strip]}')
	return s[:c]

def pw_hash(pw, hash_str=None, salt=None):
	'Generates scrypt-hash with random salt or checks pw against one'
	scheme = 'rdircd.1'
	if isinstance(pw, str): pw = pw.encode()
	if not hash_str:
		if not salt: salt = os.urandom(16)
		crypt = salt + hashlib.scrypt( pw,
			salt=hashlib.blake2s(salt, person=scheme.encode()).digest(),
			n=2**15, r=8, p=1, maxmem=48*2**20, dklen=32 )
		chk = base64.urlsafe_b64encode(hashlib.blake2s(
			crypt, digest_size=3, person=scheme.encode()).digest() ).decode()
		crypt = base64.urlsafe_b64encode(crypt).decode()
		return f'{scheme}.{crypt}.{chk}'
	elif hash_str.startswith(f'{scheme}.'):
		try:
			crypt, chk = hash_str[9:].split('.', 1)
			crypt = base64.urlsafe_b64decode(crypt)
			chk2 = base64.urlsafe_b64encode(hashlib.blake2s(
				crypt, digest_size=3, person=scheme.encode()).digest() ).decode()
			if not secrets.compare_digest(chk, chk2): raise ValueError
		except: raise ValueError(f'corrupted {scheme} password hash string')
		return secrets.compare_digest(hash_str, pw_hash(pw, salt=crypt[:16]))
	raise ValueError('unrecognized password-hash type')

def tuple_hash(t, c=None, key='rdircd'):
	s = '\0'.join(str(v).replace('\0', '\0\0') for v in t)
	return str_hash(s, c=c, key=key)


@cl.contextmanager
def safe_replacement(path, *open_args, mode=None, **open_kws):
	path = str(path)
	if mode is None:
		try: mode = stat.S_IMODE(os.lstat(path).st_mode)
		except OSError: pass
	open_kws.update( delete=False,
		dir=os.path.dirname(path), prefix=os.path.basename(path)+'.' )
	if not open_args: open_kws['mode'] = 'w'
	with tempfile.NamedTemporaryFile(*open_args, **open_kws) as tmp:
		try:
			if mode is not None: os.fchmod(tmp.fileno(), mode)
			yield tmp
			if not tmp.closed: tmp.flush()
			os.rename(tmp.name, path)
		finally:
			try: os.unlink(tmp.name)
			except OSError: pass

def file_tail(p, n, grep=None, bs=100 * 2**10):
	import mmap
	lines = list()
	with open(p,'rb') as src:
		a, buff, mm = 1, b'', mmap.mmap(
			src.fileno(), 0, access=mmap.ACCESS_READ )
		while len(lines) < n:
			b = a + bs
			a, buff, end = b, buff + mm[-a:-b:-1], b > len(mm)
			while True:
				try: line, buff = buff.split(b'\n', 1)
				except ValueError:
					if end and buff: line, buff = buff, b''
					else: break
				if line: lines.append(line[::-1].decode())
			if end: break
	return list(reversed(lines[:n]))

def token_bucket(spec, negative_tokens=False):
	'''Spec: { interval_seconds: float | float_a/float_b }[:burst_float]
			Examples: 1/4:5 (interval=0.25s, rate=4/s, burst=5), 5, 0.5:10, 20:30.
		Expects a number of tokens (can be float, default: 1)
			and *always* subtracts these.
		Yields either None if there's enough
			tokens or delay (in seconds, float) until when there will be.'''
	try:
		try: interval, burst = spec.rsplit(':', 1)
		except (ValueError, AttributeError): interval, burst = spec, 1.0
		else: burst = float(burst)
		if isinstance(interval, str):
			try: a, b = interval.split('/', 1)
			except ValueError: interval = float(interval)
			else: interval = float(a) / float(b)
		if min(interval, burst) < 0: raise ValueError()
	except: raise ValueError('Invalid format for rate-limit: {!r}'.format(spec))
	tokens, rate, ts_sync = max(0, burst - 1), interval**-1, time.monotonic()
	val = (yield) or 1
	while True:
		ts = time.monotonic()
		ts_sync, tokens = ts, min(burst, tokens + (ts - ts_sync) * rate)
		val, tokens = ( (None, tokens - val) if tokens >= val else
			((val - tokens) / rate, (tokens - val) if negative_tokens else tokens) )
		val = (yield val) or 1


async def aio_await_wrap(res):
	'Wraps coroutine, callable that creates one or any other awaitable.'
	if not inspect.isawaitable(res) and callable(res): res = res()
	if inspect.isawaitable(res): res = await res
	return res

async def aio_task_cancel(task_list):
	'Cancel and await a task or a list of such, which can have empty values mixed-in.'
	if inspect.isawaitable(task_list): task_list = [task_list]
	task_list = list(filter(None, task_list))
	for task in task_list:
		with cl.suppress(asyncio.CancelledError): task.cancel()
	for task in task_list:
		with cl.suppress(asyncio.CancelledError): await task

class aio_timeout:
	def __init__(self, timeout):
		loop = asyncio.get_running_loop()
		self._timeout, self._timeout_call = False, loop.call_at(
			loop.time() + timeout, self._timeout_set, asyncio.current_task() )
	def _timeout_set(self, task): self._timeout = task.cancel() or True
	async def __aenter__(self): return self
	async def __aexit__(self, err_t, err, err_tb):
		if err_t is asyncio.CancelledError and self._timeout: raise asyncio.TimeoutError
		self._timeout_call.cancel()


class StacklessContext:
	'''Like AsyncContextStack, but for tracking tasks that
		can finish at any point without leaving stack frames.'''

	def __init__(self, log): self.tasks, self.log = dict(), log
	async def __aenter__(self): return self
	async def __aexit__(self, *err):
		if self.tasks:
			task_list, self.tasks = self.tasks.values(), None
			await aio_task_cancel(task_list)
	async def close(self): await self.__aexit__(None, None, None)

	def add_task(self, coro, run_after=None):
		'Start task eating its own tail, with an optional success-only callback'
		task_id = None
		async def _task_wrapper(coro=coro):
			try:
				await aio_await_wrap(coro)
				if run_after: await aio_await_wrap(coro := run_after())
			except asyncio.CancelledError: pass
			except Exception as err:
				self.log.exception( 'Background task failed: {} - {}',
					coro, str_cut(err_fmt(err), 200, repr_fmt=True) )
			finally:
				assert task_id is not None, task_id
				if self.tasks: self.tasks.pop(task_id, None)
		task = asyncio.create_task(_task_wrapper())
		task_id = id(task)
		self.tasks[task_id] = task
		return task
	add = add_task


def parse_iso8601( spec, tz_default=dt.timezone.utc, validate=False,
		_re=re.compile( r'(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?'
			r'(?::(?P<s>\d{2})(?:\.(?P<us>\d+))?)?\s*(?P<tz>Z|[-+]\d{2}:\d{2})?' ) ):
	if not (m := _re.search(spec)): raise ValueError(spec)
	if validate: return
	if m.group('tz'):
		tz = m.group('tz')
		if tz == 'Z': tz = dt.timezone.utc
		else:
			k = {'+':1,'-':-1}[tz[0]]
			hh, mm = ((int(n) * k) for n in tz[1:].split(':', 1))
			tz = dt.timezone(dt.timedelta(hours=hh, minutes=mm), tz)
	else: tz = tz_default
	ts_list = list(m.groups()[:5])
	if not ts_list[3]: ts_list[3] = ts_list[4] = 0
	ts_list = [ *map(int, ts_list),
		int(m.group('s') or 0), int(m.group('us') or 0) ]
	ts = dt.datetime.strptime(
		'{:04d}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}.{:06d}'.format(*ts_list),
		'%Y-%m-%d %H:%M:%S.%f' )
	return ts.replace(tzinfo=tz).timestamp()

def ts_iso8601(ts, ms=3, to_str=True, human=False, strip_date=None):
	if not isinstance(ts, dt.datetime):
		ts = ( dt.datetime.utcfromtimestamp(ts)
			if not human else dt.datetime.fromtimestamp(ts) )
	if not human: ts = ts.replace(tzinfo=dt.timezone.utc)
	if ts.year > 2030: raise ValueError(ts) # sanity check
	if not to_str: return ts
	if human:
		ts_fmt = '%Y-%m-%d %H:%M:%S'
		if strip_date:
			if strip_date is True: ts_fmt = ts_fmt[9:]
			else: # strip specified redundant or rolled-over (next) date
				tss, d0 = strip_date, ts.strftime('%Y-%m-%d')
				if not isinstance(tss, dt.date): # includes datetime
					tss = dt.datetime.fromtimestamp(tss)
				for tss in [tss, tss + dt.timedelta(days=1)]:
					if tss.strftime('%Y-%m-%d') == d0: ts_fmt = ts_fmt[9:]
		ts = ts.strftime(ts_fmt)
		return ts[:-3] if ts.endswith(':00') else ts
	if int(ts_ext := f'{ts.microsecond:06d}'[:ms]): ts_ext = f'.{ts_ext}'
	return ts.strftime('%Y-%m-%dT%H:%M:%S') + ts_ext + 'Z'

_td_days = dict(
	y=365.25, yr=365.25, year=365.25,
	mo=30.5, month=30.5, w=7, week=7, d=1, day=1 )
_td_s = dict( h=3600, hr=3600, hour=3600,
	m=60, min=60, minute=60, s=1, sec=1, second=1 )
_td_usort = lambda d: sorted(
	d.items(), key=lambda kv: (kv[1], len(kv[0])), reverse=True )
_td_re = re.compile('(?i)^[-+]?' + ''.join( fr'(?P<{k}>\d+{k}\s*)?'
	for k, v in [*_td_usort(_td_days), *_td_usort(_td_s)] ) + '$')

def parse_duration(ts_str, to_float=True):
	try: delta = dt.timedelta(seconds=float(ts_str))
	except ValueError:
		if not ((m := _td_re.search(ts_str)) and any(m.groups())):
			raise ValueError(ts_str) from None
		delta = list()
		for units in _td_days, _td_s:
			val = 0
			for k, v in units.items():
				if not m.group(k): continue
				val += v * int(''.join(filter(str.isdigit, m.group(k))) or 1)
			delta.append(val)
		delta = dt.timedelta(*delta)
	return delta if not to_float else delta.total_seconds()

def repr_duration( ts, ts0=None,
		ext=True, units_max=2, units_res=None,
		_units=dict( h=3600, m=60, s=1,
			y=365.25*86400, mo=30.5*86400, w=7*86400, d=1*86400 ) ):
	delta = ts if ts0 is None else (ts - ts0)
	if ext is True: ext = 'ago' if delta < 0 else 'from now'
	res, s, n_last = list(), abs(delta), units_max - 1
	for unit, unit_s in sorted(_units.items(), key=op.itemgetter(1), reverse=True):
		if not (val := math.floor(s / unit_s)):
			if units_res == unit: break
			continue
		if len(res) == n_last or units_res == unit:
			val, n_last = round(s / unit_s), True
		res.append(f'{val:.0f}{unit}')
		if n_last is True: break
		s -= val * unit_s
	if not res: return '-'
	else:
		if ext: res.append(ext)
		return ' '.join(res)


def force_list(v):
	if not v: v = list()
	elif isinstance(v, cs_abc.ValuesView): v = list(v)
	elif not isinstance(v, list): v = [v]
	return v

def dict_update(d, du_iter=None, sync=True):
	'Update or replace dict contents, returning difference in the latter case.'
	keys_old, du = set(d.keys()), dict()
	if du_iter: du.update(du_iter)
	d.update(du)
	if sync: return dict((k, d.pop(k)) for k in keys_old.difference(du))

def iter_gather(container):
	'Auto-gathers iterator function result into some container.'
	def _cls_wrapper(func):
		def _wrapper(self, *args, **kws):
			return container(func(self, *args, **kws))
		return ft.wraps(func)(_wrapper)
	return _cls_wrapper


class adict(cs.UserDict):
	'dict with simplier access via attrs and update-tracking for cache invalidation.'
	__slots__ = 'data', 'attrs', '_track_keys', '_track_cb'

	def __init__(self, *args, **kws):
		self.attrs = dict()
		self._track_keys = self._track_cb = None
		super().__init__(*args, **kws)
		self._make(self)

	def _make(self, v):
		if v is self: v.update((k, self._make(v)) for k,v in v.items())
		elif type(v) is dict: v = adict(v)
		elif isinstance(v, (tuple, list)): v = type(v)(map(self._make, v))
		return v

	def _track(self, keys=None, cb=None):
		'Run cb() on specified (or any) key changes in this adict.'
		# Intended to only be used in one place, so overrides any earlier cb
		if keys:
			self._track_keys = set(keys.split())
			for k in self._track_keys:
				if type(d := self.get(k)) is adict: d._track(cb=cb)
		self._track_cb = cb
	def _track_check(self, k):
		if not self._track_cb: return
		if self._track_keys:
			if k not in self._track_keys: return
			elif type(d := self.get(k)) is adict: d._track(cb=cb)
		self._track_cb()

	def __getattr__(self, k):
		if k not in self.__slots__:
			return self[k] if not k.startswith('ø_') else self.attrs[k[2:]]
		else: return self.__getattribute__(k)
	def __setattr__(self, k, v):
		if k not in self.__slots__:
			if not k.startswith('ø_'): self[k] = v
			else: self.attrs[k[2:]] = v
		else: return super().__setattr__(k, v)
	def __delattr__(self, k, v):
		if not k.startswith('ø_'): del self[k]
		else: del self.attrs[k[2:]]

	def __setitem__(self, k, v):
		super().__setitem__(k, v)
		self._track_check(k)
	def __delitem__(self, k):
		super().__delitem__(k)
		self._track_check(k)


def irc_casefold_rfc1459(name, _irc_rfc1459_table=dict(zip(
		map(ord, string.ascii_uppercase + '\\[]'), string.ascii_lowercase + '|{}' ))):
	return name.translate(_irc_rfc1459_table)
irc_name_eq = lambda a, b: irc_casefold_rfc1459(a) == irc_casefold_rfc1459(b)

class irc_name_dict(cs.UserDict):
	'Mapping that uses IRC rfc1459-casefolded keys'
	value_map = classmethod(
		lambda cls, names: cls((v, v) for v in names) )
	def add(self, name): self[name] = name # for ci-name -> name mappings
	def remove(self, name): del self[name]
	def get(self, k, default=None): return super().get(irc_casefold_rfc1459(k), default)
	def __contains__(self, k): return irc_casefold_rfc1459(k) in self.data
	def __getitem__(self, k): return self.data[irc_casefold_rfc1459(k)]
	def __setitem__(self, k, v): self.data[irc_casefold_rfc1459(k)] = v
	def __delitem__(self, k): del self.data[irc_casefold_rfc1459(k)]



class IRCProtocolError(Exception): pass
class IRCProtocolArgsError(IRCProtocolError): pass
class IRCBridgeSignal(Exception): pass

class IRCProtocol:
	'''IRC protocol state and implementation.
		It's unique to each connected IRC client, and they don't ever interact.'''

	# Extensive lists of modes are copied from freenode to make clients happy
	feats_modes = 'DOQRSZaghilopswz CFILMPQSbcefgijklmnopqrstvz'
	feats_support = ( 'AWAYLEN=200 CASEMAPPING=rfc1459'
		' CHANLIMIT=#:512 CHANTYPES=# CHANMODES=eIbq,k,flj,CFLMPQScgimnprstz'
		' CHANNELLEN=80 ELIST=C NETWORK=rdircd NICKLEN=64'
		' PREFIX=(ov)@+ SAFELIST STATUSMSG=@+ TOPICLEN=390 USERLEN=32' ).split()
	nx_chan_warn = ( 'WARNING :: Discord has no channel'
		' linked to this one, nothing will happen here :: WARNING' )

	@classmethod
	def factory_for_bridge(cls, rdircd):
		def _wrapper():
			try: return cls(rdircd)
			except Exception as err:
				log = get_logger('rdircd.irc.factory')
				log.exception('Failed to initialize ircd protocol: {}', err_fmt(err))
				log.critical('Stopping daemon due to unhandled protocol error')
				rdircd.loop.stop()
		return _wrapper

	def __init__(self, rdircd):
		self.bridge, self.conf = rdircd, rdircd.conf
		self.log = get_logger('rdircd.irc.init')
		self.transport, self.buff, self.recv_queue = None, b'', asyncio.Queue()
		self._cmd_cache, self.st = dict(), adict(
			nick=None, user=None, pw=None,
			host=None, auth=False, cap_neg=False, away=None,
			# Channels here get initialized on joins and removed on parts
			# There is no need to track them otherwise
			chans=irc_name_dict() )


	def connection_made(self, tr):
		host, port = tr.get_extra_info('peername')[:2]
		conn_id = tuple_hash([host, port], 3)
		self.log_proto = get_logger(f'proto.irc.{conn_id}')
		self.log_proto.debug( '--- -conn- {} {} {}',
			host, port, conn_id, extra=('---', f'conn {host} {port}') )
		self.log = get_logger(f'rdircd.irc.{conn_id}')
		self.log.debug('Connection from {} {} [{}]', host, port, conn_id)
		self.transport, self.st.host = tr, host
		self.send(0, 'NOTICE * :*** rdircd ready')
		self.bridge.irc_conn_new(self)
		self.bridge.cmd_delay(self.recv_queue_proc)

	def data_received(self, data):
		self.buff += data
		while b'\n' in self.buff:
			line, self.buff = self.buff.split(b'\n', 1)
			if not line.strip(): continue
			line_len, line_repr = self._repr_bin(line, True)
			self.log_proto.debug( '<<  [{} {}] {}',
				self.st.nick or '---', line_len, line_repr, extra=('<< ', line) )
			if not line.strip(): continue
			if self.recv_queue: self.recv_queue.put_nowait(line)
			else: self.log.error('Data after recv queue stopped: {!r}', line)

	def eof_received(self): pass
	def connection_lost(self, err):
		reason = err or 'closed cleanly'
		if isinstance(reason, Exception): reason = err_fmt(reason)
		self.log_proto.debug('--- -close- :: {}', reason, extra=('---', 'close'))
		self.log.debug('Connection lost: {}', reason)
		if self.recv_queue: self.recv_queue.put_nowait(StopIteration)
		self.bridge.irc_conn_lost(self)

	def data_send(self, data):
		data_len, data_repr = self._repr_bin(data, True)
		self.log_proto.debug( ' >> [{} {}] {}',
			self.st.nick or '---', data_len, data_repr, extra=(' >>', data) )
		self.transport.write(data)


	def _repr_bin(self, data, prefix=False, max_len=None, ext=' [{data_len}]'):
		'Binary-only version of str_cut().'
		if isinstance(data, str): data = data.encode()
		if max_len is None: max_len = self.conf.debug_proto_cut
		data_len, data_repr, ext_len = f'{len(data):,d}', repr(data)[2:-1], len(ext)
		if max_len > 0 and len(data_repr) > max_len:
			data_len = f'{max_len}/{data_len}'
			data_repr = data_repr[:max_len - ext_len] + ext.format(data_len=data_len)
		return (data_len, data_repr) if prefix else data_repr

	def _parse(self, line):
		if isinstance(line, bytes): line = line.decode()
		m = adict(line=line, params=list())
		for k in '@tags', ':src':
			pre, k = k[0], k[1:]
			if line.startswith(pre):
				try: m[k], line = line.split(' ', 1)
				except ValueError:
					raise IRCProtocolLineError(line) from None
				line = line.lstrip(' ')
			else: m[k] = None
		while True:
			if line.startswith(':'):
				m.params.append(line[1:])
				break
			if ' ' in line:
				param, line = line.split(' ', 1)
				line = line.lstrip(' ')
			else: param, line = line, ''
			m.params.append(param)
			if not line: break
		if m.params: m.cmd, m.params = m.params[0].lower(), m.params[1:]
		else: raise IRCProtocolLineError(line)
		return m

	def send(self, line_or_code, *args, max_len=None, auto_split=True):
		'Send msg or command to IRC client on the other end of this connection'
		line = line_or_code
		if isinstance(line, int): # code=0 only adds :host prefix
			code, line = line, f':{self.bridge.server_host}'
			if code: line += f' {code:03d} {self.st.nick or "*"}'
		if args: line += ' ' + ' '.join(map(str, args))
		if isinstance(line, str): line = line.encode()
		if max_len is None: max_len = self.conf.irc_len_hwm
		line = line.rstrip(b'\r\n')
		if b'\n' in line or len(line) > max_len:
			if auto_split:
				m = self._parse(line)
				if m.cmd in ['privmsg', 'notice']: return self.send_split_msg(m)
			if len(line) > max_len:
				self.log.info('Sending line with >{}B: {!r}', max_len, self._repr_bin(line))
		if b'\n' in line: raise IRCProtocolError(f'Line with newlines: {line!r}')
		line += b'\r\n'
		self.data_send(line)

	def send_split_msg(self, m):
		dst, line = m.params
		pre, max_len = f'{m.cmd.upper()} {dst}', self.conf.irc_len_lwm
		if m.src: pre = f'{m.src} {pre}'
		if '\n' in line:
			for line in line.split('\n'): self.send(pre, f':{line.rstrip()}')
			return
		line, ws = '', re.findall(r'(\S+)(\s*)', line)
		for w, sep in ws:
			if line.strip() and len(line) + len(w) > max_len:
				self.send(pre, f':{line.rstrip()}', auto_split=False)
				line = sep_last
			sep_last, line = sep, line + w + sep
		if line.strip(): self.send(pre, f':{line.rstrip()}', auto_split=False)

	async def recv_queue_proc(self):
		try:
			while True:
				line = await self.recv_queue.get()
				if line is StopIteration: break
				try: await self.recv(line)
				except Exception as err:
					self.log.exception(f'Failed to parse line: {line}')
		finally: self.recv_queue = None

	async def recv(self, line_raw):
		'Runs on every IRC line received from the client on the other end'
		# Dispatches call to recv_cmd_* funcs below, like recv_cmd_privmsg
		if isinstance(line_raw, str): line = line_raw
		else:
			try: line = line_raw.decode().strip()
			except UnicodeDecodeError:
				return self.log.error('Failed to decode line as utf-8: {!r}', self._repr_bin(line_raw))
		try: m = self._parse(line)
		except IRCProtocolLineError:
			return self.log.error('Line protocol error: {!r}', self._repr_bin(line_raw))
		if cmd_cache := self._cmd_cache.get(m.cmd):
			cmd_func, cmd_ps_n = cmd_cache
		else:
			cmd_func, cmd_ps_n = getattr(self, f'recv_cmd_{m.cmd}', None), 0
			if cmd_func:
				args = list(inspect.signature(cmd_func).parameters.values())
				cmd_ps_n = len(args)
				if cmd_ps_n == 1 and args[0].annotation == 'msg': cmd_ps_n = None
				else: cmd_ps_n = cmd_ps_n - sum(1 for p in args if p.default is not p.empty), cmd_ps_n
			self._cmd_cache[m.cmd] = cmd_func, cmd_ps_n
		if not cmd_func:
			self.log.error('Unhandled cmd: {!r}', self._repr_bin(line_raw))
			return self.send(421, ':Unknown command')
		if not self.check_access(m.cmd):
			return self.log.error('Out-of-order cmd: {!r}', self._repr_bin(line_raw))
		if cmd_ps_n is None: await aio_await_wrap(cmd_func(m))
		else:
			(a, b), n = cmd_ps_n, len(m.params)
			if not a <= n <= b:
				self.log.error( 'Command/args'
					' mismatch [{} vs {}-{}]: {!r}', n, a, b, self._repr_bin(line) )
				return self.send(461, ':Incorrect command parameters')
			try: await aio_await_wrap(cmd_func(*m.params))
			except Exception as err:
				self.send(400, m.cmd.upper(), f':BUG - Internal Error - {err_fmt(err)}')
				self.log.exception('Error processing message: {}', m)

	def check_access(self, cmd):
		if not self.st.auth:
			res = cmd in ['cap', 'user', 'nick', 'pass', 'quit', 'ping']
			if not res: self.send(451, ':You have not registered')
			return res
		elif cmd in ['user', 'pass']:
			self.send(462, ':You may not reregister')
			return False
		if self.st.cap_neg:
			# No commands other than auth allowed until capability negotiation ends
			# Note that negotiation can be skipped entirely too
			return cmd in ['cap', 'quit', 'ping']
		return True # any commands allowed outside of phases above

	# chan_spec=#casemap(some-channel), chan_name=casemap(some-channel)
	_csc = lambda c: c.startswith('#')
	chan_spec_check = staticmethod(_csc)
	chan_spec = staticmethod(
		lambda name,_csc=_csc: name if _csc(name) else f'#{name}' )
	chan_name = staticmethod(
		lambda chan,_csc=_csc: chan if not _csc(chan) else chan[1:] )


	def recv_cmd_ping(self, server, server_dst=None):
		# See https://gitlab.com/gitterHQ/irc-bridge/-/issues/34#note_190986152
		self.send(0, f'PONG {self.bridge.server_host} :{server}')

	def recv_cmd_cap(self, sub, caps=''):
		sub = sub.lower()
		if sub == 'ls':
			self.send(0, 'CAP * LS :')
			if caps == '302': self.st.cap_neg = True
		elif sub == 'list': self.send(0, f'CAP * LIST :')
		elif sub == 'req':
			self.st.cap_neg = True
			reject = set(c for c in caps.split() if not c.startswith('-'))
			if reject: self.send(0, f'CAP * NAK :{caps}')
			else: self.send(0, f'CAP * ACK :{caps}')
		elif sub == 'end': self.st.cap_neg = False

	def recv_cmd_pass(self, pw):
		self.st.pw = pw
		self.check_auth_done()
	def recv_cmd_user(self, name, a, b, real_name):
		self.st.update(user=name, real_name=real_name)
		self.check_auth_done()
	def recv_cmd_nick(self, nick):
		if not re.search(r'^[-._a-zA-Z0-9]+$', nick):
			return self.send(432, nick, ':Erroneus nickname')
		if self.bridge.cmd_conn(nick):
			return self.send(433, nick, ':Nickname is already in use')
		self.st.nick = nick
		if self.st.auth and self.st.nick:
			self.send(f':{self.st.nick} NICK {nick}')
		self.check_auth_done()

	def check_auth_done(self):
		# Delay is to avoid trivial bruteforcing
		self.bridge.cmd_delay('irc_auth', self.check_auth_done_delayed)
	def check_auth_done_delayed(self):
		if self.st.auth: return
		if not (self.st.nick and self.st.user): return
		if self.conf.irc_password_hash:
			if not pw_hash(self.st.pw or '', self.conf.irc_password_hash):
				return self.send(464, ':Password incorrect')
		self.st.auth = True
		self.send(0, 'NOTICE * :*** registration completed')
		self.send(1, f':Welcome to the rdircd discord-irc bridge, {self.st.nick}')
		self.send(2,
			f':Your host is {self.bridge.server_host},'
			f' running rdircd {self.bridge.server_ver}' )
		self.send(3, ':This server was created at {}'.format(
			self.bridge.server_ts.strftime('%Y-%m-%d %H:%M:%S UTC') ))
		self.send(4, f'{self.bridge.server_host} rdircd-{self.bridge.server_ver} {self.feats_modes}')
		self.send_feats()
		self.send_stats()
		self.send_motd()

	def send_feats(self, msg_feats_max=10, msg_len_max=200):
		feat_line, ext = list(), ':are supported by this server'
		for feat in it.chain(self.feats_support, [None]):
			if feat: feat_line.append(feat)
			n, msg_len = len(feat_line), sum((len(f)+1) for f in feat_line)
			if feat_line and (not feat or n >= msg_feats_max or msg_len >= msg_len_max):
				self.send(5, ' '.join(feat_line), ext)
				feat_line.clear()

	def send_stats(self):
		s = self.bridge.irc_conn_stats()
		self.send(251, f':There are {s.auth} users and 0 invisible on {s.servers} server(s)')
		self.send(252, f'{s.op} :IRC Operators online')
		self.send(253, f'{s.unknown} :unknown connection(s)')
		self.send(254, f'{s.chans} :channels formed')
		self.send(255, f':I have {s.total} client(s) and {s.servers} server(s)')
		self.send( 265, f'{s.total} {s.total_max}',
			f':Current local users {s.total}, max {s.total_max}' )
		self.send( 266, f'{s.total} {s.total_max}',
			f':Current global users {s.total}, max {s.total_max}' )

	def send_motd(self):
		if motd := self.conf.irc_motd_file_path:
			try: motd = pl.Path(self.conf.irc_motd_file_path).read_text()
			except FileNotFoundError: motd = ''
		if not motd: return self.send(422, ':MOTD File is missing')
		self.send(375, f':- {self.bridge.server_host} Message of the day -')
		for line in motd.splitlines(): self.send(372, f':- {line}')
		self.send(376, ':End of /MOTD command')

	def recv_cmd_quit(self, reason=None):
		self.send('QUIT :Client quit')
		self.send('ERROR :Closing connection (client quit)')
		self.transport.close()

	def req_chan_info(self, chan, cm=None, check=True):
		info = self._req_chan_info(chan, cm, check)
		return info
	def _req_chan_info(self, chan, cm=None, check=True):
		if not self.chan_spec_check(chan):
			return self.send(403, chan, ':No such channel')
		if not cm: cm = self.bridge.cmd_chan_map()
		if c := cm.get(self.chan_name(chan)): return c
		if check: self.send(403, chan, ':No such channel')
		if cm.ø_online: return False # confirmed to not exist on discord

	def recv_cmd_join(self, chan, key=None):
		if chan == '0': return self.recv_cmd_part(','.join(self.st.chans))
		chan_list, cm = chan.split(','), self.bridge.cmd_chan_map()
		for chan in chan_list: self.cmd_join(chan, cm=cm)

	def cmd_join(self, chan, cm=None):
		if not cm: cm = self.bridge.cmd_chan_map()
		c = self.req_chan_info(chan, cm=cm, check=False)
		if c is False: return self.send(403, chan, ':No such channel')
		name = c.name if c else self.chan_name(chan)
		self.send(f':{self.st.nick} JOIN {chan}')
		self.send_topic(chan, c=c)
		self.send_names(chan, own=True, c=c)
		self.send(0, f'MODE {chan} +v {self.st.nick}')
		# topic=None will always be updated on cmd_chan_list_sync
		self.st.chans[name] = adict(topic=c and c.topic)

	def recv_cmd_part(self, chan, reason=None):
		chan_list, cm = chan.split(','), self.bridge.cmd_chan_map()
		for chan in chan_list:
			c = self.req_chan_info(chan, cm=cm, check=False)
			name = c.name if c else self.chan_name(chan)
			if name not in self.st.chans:
				self.send(442, chan, ':You are not on that channel')
			else:
				del self.st.chans[name]
				self.send(f':{self.st.nick} PART {chan}')

	async def recv_cmd_topic(self, chan, topic=None):
		if not (c := self.req_chan_info(chan)):
			return self.send(403, chan, ':No such channel')
		if not topic:
			self.send_topic(chan)
			return await self.bridge.irc_topic_cmd(self, c.name)
		try: await self.bridge.irc_topic_cmd(self, c.name, topic)
		except IRCBridgeSignal as err: self.send(482, chan, f':{err}')

	def send_topic(self, chan, c=...):
		if c is ...: c = self.req_chan_info(chan)
		if c and c.topic:
			topic = str_cut(c.topic.strip(), self.conf.irc_len_topic, len_bytes=True)
			self.send(332, chan, f':{topic}')
		elif c is False: self.send(332, chan, f':{self.nx_chan_warn}')
		else: self.send(331, chan, ':No topic is set')

	def send_topic_update(self, chan, topic):
		topic = str_cut(topic.strip(), self.conf.irc_len_topic, len_bytes=True)
		self.send(f':{self.conf.irc_nick_sys} TOPIC {chan} :{topic}')

	def recv_cmd_names(self, chan):
		for chan in chan.split(','): self.send_names(chan)

	def send_names(self, chan, own=False, c=..., msg_len_max=200):
		if c is ...: c = self.req_chan_info(chan)
		name_line, names = list(), self.bridge.cmd_chan_names(c.name) if c else list()
		for name in it.chain(names, [None]):
			if name:
				if irc_name_eq(name, self.st.nick): own = False
				name_line.append(name)
			elif own and self.st.nick: name_line.append(self.st.nick)
			if name_line and (
					not name or sum(len(n)+1 for n in name_line) > msg_len_max ):
				self.send(353, '=', chan, ':' + ' '.join(name_line))
				name_line.clear()
		self.send(366, chan, ':End of /NAMES list')

	def recv_cmd_mode(self, target, mode=None, mode_args=None):
		if self.chan_spec_check(target):
			chan = target
			c = self.req_chan_info(chan, check=False)
			if self.conf.irc_chan_modes: self.send(324, chan, '+cnrt')
			if c: chan_ts = int(c.ts_created)
			else:
				if self.chan_name(chan) not in self.st.chans:
					return self.send(403, chan, ':No such channel')
				chan_ts = int(time.time())
			if self.conf.irc_chan_modes: self.send(329, chan, chan_ts)
		else:
			if not irc_name_eq(target, self.st.nick):
				return self.send(502, ':No access to modes of other users')
			self.send(221, ':+w')

	def recv_cmd_away(self, msg=None):
		self.st.away = msg or None
		if self.st.away: self.send(306, ':You have been marked as being away')
		else: self.send(305, ':You are no longer marked as being away')

	def recv_cmd_list(self, chan=None, cond=None):
		self.send(321, 'Channel :Users  Name')
		for c in self.bridge.cmd_chan_map().values():
			names = self.bridge.cmd_chan_names(c.name)
			topic = str_cut(c.topic, self.conf.irc_len_topic, len_bytes=True)
			self.send(322, self.chan_spec(c.name), len(names) or 1, f':{topic}')
		self.send(323, ':End of /LIST')

	def recv_cmd_motd(self, target=None): self.send_motd()

	def recv_cmd_version(self, target=None):
		self.send(351, self.bridge.server_ver, 'rdircd', ':rdircd discord-to-irc bridge')
		self.send_feats()

	def recv_cmd_privmsg(self, target, text):
		self.cmd_msg_from_irc(self.st.nick, target, text, from_self=True)

	def recv_cmd_notice(self, target, text):
		self.cmd_msg_from_irc(self.st.nick, target, text, notice=True, from_self=True)

	def cmd_chan_list_sync(self, cm):
		for name, ch in self.st.chans.items():
			topic = cm[name].topic if name in cm else self.nx_chan_warn
			if topic == ch.topic: continue
			self.log.debug('Client-topic update [ {} ]: {!r} -> {!r}', name, ch.topic, topic)
			self.send_topic_update(chan := self.chan_spec(name), topic)
			ch.topic = topic
			if name in cm: continue
			self.send(f':{self.conf.irc_nick_sys} NOTICE {chan} :{self.nx_chan_warn}')
			self.send(403, chan, ':No such channel')
			# Doesn't actually removes channels, as they get rename-notification there

	def cmd_msg_from_irc(self, src, target, text, notice=False, from_self=False):
		'''Handler for messages posted from IRC.
			With notice=True message is only handled in irc, without proxying it to discord.'''
		msg_type = 'PRIVMSG' if not notice else 'NOTICE'
		if self.chan_spec_check(target):
			chan, name = target, self.chan_name(target)
			if not notice: c = self.req_chan_info(chan)
			else: c = self.bridge.cmd_chan_map().get(name) # avoids error-notices
			if c is False:
				self.send( f':{self.conf.irc_nick_sys} NOTICE {chan} :WARNING:'
					' there is no corresponding discord channel, msgs here are discarded' )
			if not c: return
			if from_self and c.t not in [c.t.proxy, c.t.sys]:
				self.send( f':{self.conf.irc_nick_sys} NOTICE {chan} :WARNING:'
					' this is read-only channel, msgs posted here are not proxied to discord' )
			for conn in self.bridge.cmd_chan_conns(name): # mirror to other clients
				if from_self and conn is self: continue
				if name not in conn.st.chans: self.cmd_join(chan) # from chan_auto_join_re
				conn.send(f':{src} {msg_type} {chan} :{text}')
			if not notice: self.bridge.irc_msg(self, chan, text)
		else:
			if not (conn := self.bridge.cmd_conn(target)):
				if not notice: self.send(401, target, ':No such nick/channel')
			else: conn.send(f':{src} {msg_type} {target} :{text}')

	def cmd_msg_self(self, src, text, notice=True):
		'Send direct message to this IRC client and nick'
		msg_type = ['PRIVMSG', 'NOTICE'][bool(notice)]
		self.send(f':{src} {msg_type} {self.st.nick} :{text}')

	def cmd_msg_chan(self, src, chan, text, notice=False):
		'''Send message to channel that this IRC client is connected to.
			Message is never filtered/dropped here for reliability/sanity reasons.'''
		msg_type = ['PRIVMSG', 'NOTICE'][bool(notice)]
		chan, name = self.chan_spec(chan), self.chan_name(chan)
		if ( name not in self.st.chans and
				self.conf._irc_chan_auto_join_re.search(name) ):
			self.cmd_join(chan)
		self.send(f':{src} {msg_type} {chan} :{text}')

	# Some of following user/server/channel-info cmds
	#  can be (ab)used to provide discord user/channel info.
	# Currently this isn't stored or queried anywhere,
	#  so no need for these beyond stubs for what ZNC and such use.

	def recv_cmd_userhost(self, nick):
		if irc_name_eq(nick, self.st.nick):
			self.send(302, f':{nick}=+~{self.st.user}@{self.st.host}')
		else: self.send(401, nick, ':No such nick/channel')

	def recv_cmd_who(self, name):
		if re.search(r'^[#@%]\d+$', name):
			self.bridge.cmd_delay(self.bridge.cmd_info(self, name[0], name[1:]))
		else:
			if conn := self.bridge.cmd_conn_map().get(name):
				self.send( 352, '*', conn.st.user, conn.st.host,
					self.bridge.server_host,conn.st.nick, 'H', f':0 {conn.st.nick}' )
		self.send(315, name, ':End of /WHO list.')

	# recv_cmd_whois
	# recv_cmd_whowas
	# recv_cmd_admin
	# recv_cmd_time
	# recv_cmd_stats
	# recv_cmd_info



class DiscordError(Exception): pass
class DiscordAbort(DiscordError): pass
class DiscordHTTPError(DiscordError): pass
class DiscordSessionError(Exception): pass


class Discord:

	def __init__(self, rdircd):
		self.bridge, self.conf = rdircd, rdircd.conf
		self.log = get_logger('rdircd.discord')
		self._repr = ft.partial(str_cut, max_len=self.conf.debug_msg_cut, repr_fmt=True)

	async def __aenter__(self):
		if not ( self.conf.get('auth_email')
				and self.conf.get('auth_password') ):
			self.log.error('Disabling discord due to missing access credentials')
			self.session = None
		else:
			s = self.session = DiscordSession(self)
			s.task = asyncio.create_task(s.run_async())
		self.flake_id = (int.from_bytes(os.urandom(2), 'big') & 0x3ff) << 12
		self.flake_n, self.msg_confirms = 0, dict()
		self.user_mention_cache = cs.defaultdict(irc_name_dict)
		self.user_mention_cache_ts_cleanup = 0
		return self

	async def __aexit__(self, *err):
		if self.session: await aio_task_cancel(self.session.task)

	def connect(self):
		if self.session: self.session.connect()
	def disconnect(self):
		if self.session: self.session.disconnect()

	def flake_parse(self, flake):
		if not flake: return None
		try: return (int(flake) >> 22)/1e3 + 1420070400
		except ValueError: return None
	def flake_build(self, ts):
		flake = self.flake_n | self.flake_id | int((ts - 1420070400) * 1e3) << 22
		self.flake_n = (self.flake_n + 1) % 0xfff
		return str(flake)

	@property
	def st(self):
		try:
			if not self.session: raise KeyError
			return self.session.st
		except KeyError: return adict(guilds=dict())

	def irc_chan_name(self, cc):
		return IRCProtocol.chan_spec(
			self.bridge.cache.did_chan.get(cc.did, '???') )

	online = False
	def cmd_online_state(self, st):
		if st == self.online: return
		self.online = st
		if self.online: self.bridge.cmd_chan_map_sync()

	def cmd_chan_map_update(self):
		'Discord channel changes signal, to rebuild various mappings as necessary.'
		return list(self.bridge.cmd_chan_map()) # to trigger re-caching, if needed

	async def cmd_history(self, gg, cc, ts, lwm=70, hwm=90):
		if not self.session: return list()
		msg_list, flake = list(), self.flake_build(ts)
		while True:
			msg_batch = await self.session.req(
				f'channels/{cc.id}/messages',
				params=dict(after=flake, limit=hwm) )
			flake_last = flake
			for m in map(adict, msg_batch or list()):
				line, tags = self.session.op_msg_parse(m, gg)
				if not line: continue # joins/parts/pins and such
				# Note: parse_iso8601(m.timestamp) == flake_parse(m.id)
				self.cmd_user_cache(gg.id, m.author.id, m.author.username, replace=False)
				msg_list.append(adict( nick=m.author.username,
					line=line.strip(), tags=tags, ts=self.flake_parse(m.id) ))
				if int(m.id) > int(flake): flake = m.id
			if flake == flake_last or len(msg_list) < lwm: break
		return sorted(msg_list, key=op.itemgetter('ts'))

	async def cmd_info_dump(self, info_url):
		if not self.session: return f'No info for {info_url}: no discord session'
		try: info = await self.session.req(info_url)
		except DiscordHTTPError as err:
			err = str_cut(err, self.conf.debug_err_cut, repr_fmt=True)
			return f'No info for {info_url}: {err}'
		return data_repr(info)

	def cmd_msg_recv( self, cc, nick, line,
			tags=None, flake=None, nonce=None, notice=None, skip_monitor=False ):
		'Relay message from Discord to IRC.'
		self.log.debug('MSG: <<  :: {} {} :: {} :: {} {}', cc.gg.id, cc.name, nick, line, tags or '')
		if nonce and flake and not ( self.conf.discord_thread_msgs_in_parent_chan
				and tags.get('_prefix', '').startswith(self.conf.discord_thread_id_prefix) ):
			# Thread-prefixed msgs can be mirrored to two chans, hence the check/skip here
			if fut := self.msg_confirms.pop(nonce, None):
				self.log.debug('MSG: confirm nonce={} flake={}', nonce, flake)
				return fut.set_result(flake)
		self.bridge.cmd_msg_discord( cc, nick, line,
			tags=tags, notice=notice, ts=self.flake_parse(flake), skip_monitor=skip_monitor )

	def _cmd_msg_err_wrap(func):
		@ft.wraps(func)
		async def _wrapper(self, *args, **kws):
			try: return await func(self, *args, **kws)
			except IRCBridgeSignal: raise
			except Exception as err:
				self.log.exception('Failed discord-send: {}', err_str := err_fmt(err))
				raise IRCBridgeSignal(err_str)
		return _wrapper

	@_cmd_msg_err_wrap
	async def cmd_msg_edit_last(self, cc, aaa, bbb):
		if not cc.last_msg_sent: raise IRCBridgeSignal('no-last-msg')
		flake, line_old = cc.last_msg_sent.flake, cc.last_msg_sent.line
		line = re.sub(aaa, bbb, line_old)
		if line == line_old: raise IRCBridgeSignal('edit-changed-nothing')
		self.log.debug(
			'MSG:  E> :: {} {} {} :: {}', cc.gg.id, cc.name, flake, self._repr(line) )
		line_tagged = await self.cmd_msg_mentionify(cc.gg.id, line)
		await self.session.req(
			f'channels/{cc.id}/messages/{flake}',
			m='patch', json=dict(content=line_tagged) )
		if cc.last_msg_sent.get('flake') == flake: cc.last_msg_sent.line = line

	@_cmd_msg_err_wrap
	async def cmd_msg_del_last(self, cc):
		if not cc.last_msg_sent: raise IRCBridgeSignal('no-last-msg')
		flake, line_old = cc.last_msg_sent.flake, cc.last_msg_sent.line
		self.log.debug(
			'MSG:  D> :: {} {} {} :: {}', cc.gg.id, cc.name, flake, self._repr(line_old) )
		await self.session.req(
			f'channels/{cc.id}/messages/{flake}', m='delete', raw=True )
		if cc.last_msg_sent.get('flake') == flake: cc.last_msg_sent.clear()

	@_cmd_msg_err_wrap
	async def cmd_msg_send(self, cc, line):
		'Relay message from IRC to Discord.'
		if not self.session: raise IRCBridgeSignal('no-discord-conn')

		if ( self.conf.discord_thread_redirect_prefixed_responses_from_parent_chan
				and line.split(None, 1)[0].startswith(self.conf.discord_thread_id_prefix) ):
			c_tid, line = line.split(None, 1)
			cc = cc.threads.get(str_norm(c_tid))
			if not cc: raise IRCBridgeSignal(f'no-such-thread {c_tid}')

		nonce = self.flake_build(time.time())
		self.log.debug('MSG:  >> :: {} {} {} :: {}', cc.gg.id, cc.name, nonce, line)
		try:
			res = None
			async with aio_timeout(self.conf.discord_msg_confirm_timeout):
				line_tagged = await self.cmd_msg_mentionify(cc.gg.id, line)
				res = asyncio.create_task(self.session.req(
					f'channels/{cc.id}/messages',
					m='post', json=dict(content=line_tagged, nonce=nonce) ))
				fut = self.msg_confirms[nonce] = asyncio.Future()
				res = asyncio.gather(fut, res)
				flake_gw, res = await res
		except asyncio.TimeoutError:
			if res: await aio_task_cancel(res)
			raise IRCBridgeSignal('msg-confirm-timed-out')
		finally: self.msg_confirms.pop(nonce, None)

		try: flake_res = res['id']
		except: raise DiscordError(f'Invalid response: {res}')
		if flake_gw != flake_res:
			self.log.warning( 'Same-nonce sent/received'
				' msg-id mismatch: {} != {}', flake_gw, flake_res )
		self.log.debug('Sending of msg {} confirmed: {}', flake_res, self._repr(line))
		cc.last_msg_sent.update(flake=flake_res, line=line)

	async def cmd_msg_mentionify(self, gid, line):
		'''Translate IRC nick mentions matched by
				"discord_msg_mention_re" replaced with Discord mention-tags, if any.
			Either returns line with all mentions translated or fails with DiscordError.'''
		# Regexp group replacements are made from the end to start,
		#  with line/tag parts lists containing reversed str parts to reassemble.
		# https://discord.com/developers/docs/reference#message-formatting
		if not self.conf._discord_msg_mention_re: return line
		line_parts, nick_idx = [line], self.conf._discord_msg_mention_re.groupindex['nick']
		matches = list(self.conf._discord_msg_mention_re.finditer(line))
		for n, m in enumerate(reversed(matches)):
			line, tag, (a, b) = line_parts.pop(), m.group(), m.span()
			mx = self.conf._discord_msg_mention_re_ignore.search(tag)
			self.log.debug(
				'Discord mention match [{}/{}]: {!r} [{}-{}]{}',
				n+1, len(matches), tag, a, b, ' + ignore-pattern match' if mx else '' )
			tag_parts = ( self.cmd_msg_mentionify_ignore_strip(tag, mx)
				if mx else await self.cmd_msg_mentionify_translate_tag(gid, m, nick_idx) )
			line_parts.extend([line[b:], *tag_parts, line[:a]])
		return ''.join(reversed(line_parts))

	def cmd_msg_mentionify_ignore_strip(self, tag, mx):
		'Strips all capture groups in match from tag-string'
		tag, subs = [tag], (mx.span(n) for n, s in enumerate(mx.groups(), 1))
		for a, b in sorted(subs, reverse=True):
			part = tag.pop()
			tag.extend([part[b:], part[:a]])
		return tag

	async def cmd_msg_mentionify_translate_tag(self, gid, m, nick_idx):
		tag, (a, b) = m.group(), m.span()
		nick, (na, nb) = m.group(nick_idx), m.span(nick_idx)
		if not (tag and nick): return [tag]
		subs = list( (ga-a, gb-a, '') for ga, gb in
			(m.span(n) for n, group in enumerate(m.groups(), 1) if n != nick_idx) )
		if self.conf.discord_msg_mention_irc_decode:
			# This should make irc nicks usable for mention-tags as-is,
			#  and fix matching e.g. discord names with spaces for default regexp.
			nick = self.bridge.irc_name_revert(nick) or nick
		if uid := self.user_mention_cache[gid].get(nick): # exact match
			nick = f'<@{uid}>'
			self.cmd_user_cache(gid, uid, replace=False)
		else:
			user_map = await self.session.ws_req_users_query(gid, nick, 6)
			if not user_map: raise IRCBridgeSignal(f'{nick}=no-matches')
			elif len(user_map) > 1:
				nicks = list(user_map.values())
				nicks = ' '.join(map(repr, nicks[:5])) + (' +' if len(nicks) > 5 else '')
				raise IRCBridgeSignal(f'{nick}=[{nicks}]')
			else: # successful server-lookup with unique result
				uid = next(iter(user_map.keys()))
				self.cmd_user_cache(gid, uid, nick, replace=False, query=True)
				nick = f'<@{uid}>'
		tag = [tag]
		for sa, sb, s in sorted(subs + [(na-a, nb-a, nick)], reverse=True):
			part = tag.pop()
			tag.extend([part[sb:], s, part[:sa]])
		return tag

	def cmd_user_cache(self, gid, uid, name=None, replace=True, query=False):
		'''Updates user_mention_cache for discord-user-id, as nick_or_query=uid mapping.
			name is auto-translated to irc nick, unless query=True.
			name=None replace=True deletes the entry.
			name=None replace=False resets entry timestamp, so it stays for timeout from now.'''
		users, ts = self.user_mention_cache[gid], time.monotonic()
		uid_key = f'\0{uid}' if not query else f'\t{uid}' # so that queries can co-exist with users
		if not replace and uid_key in users:
			if not name: users[uid_key] = users[uid_key][0], ts
			return
		name_old, ts_old = users.pop(uid_key, (None, 0))
		# There can be two users with exactly same username, esp. with bridge-bots
		if name_old: users.pop(name_old, None)
		if not name: return
		if not query: name = self.bridge.irc_name(name)
		users[name], users[uid_key] = uid, (name, ts)
		if ( (ts - self.user_mention_cache_ts_cleanup)
				> self.conf.discord_user_mention_cache_timeout / 2 ):
			ts -= self.conf.discord_user_mention_cache_timeout
			for uid_key in list(users.keys()):
				if uid_key[0] not in '\0\t' or uid_key not in users: continue
				if users[uid_key][1] < ts: self.cmd_user_cache(gid, uid_key[1:])

	def cmd_chan_rename_func(self, cc, name_old):
		name_old = self.bridge.irc_name(name_old)
		send_func = self.bridge.cmd_msg_rename_func(cc)
		return lambda: send_func(line=(
			f'-----== Discord {"channel" if not cc.tid else "thread"}'
			f' renamed: {name_old} -> {self.irc_chan_name(cc)} ==-----' ))

	def cmd_chan_thread(self, cc):
		# Idea behind tag prefix is to allow sending msgs from parent channel to a prefixed thread
		self.bridge.cmd_msg_discord( cc,
			line=f'[{cc.tid}] --- Thread channel created: {self.irc_chan_name(cc)}' )

	def cmd_guild_event(self, gid, msg, ev_hash=''):
		'Report non-discord-channel event in global/guild monitor channels, e.g. user bans'
		if gid == 1: prefix = ''
		else: prefix = gg.name if (gg := self.st.guilds.get(gid)) else f'%{gid}'
		if ev_hash: prefix = f'{prefix} ev#{ev_hash}'
		if prefix := prefix.strip(): prefix = f'[{prefix}] '
		self.bridge.cmd_msg_monitor(
			nick=self.conf.irc_nick_sys, gid=gid, notice=True,
			prefix=f'{self.conf.irc_prefix_guild_event}{prefix}', msg=msg )



class DiscordSession:

	# See https://discord.com/developers/docs/change-log
	api_ver = 10

	class c(enum.IntEnum):
		dispatch = 0
		heartbeat = 1
		identify = 2
		status_update = 3
		voice_state_update = 4
		resume = 6
		reconnect = 7
		request_guild_members = 8
		invalid_session = 9
		hello = 10
		heartbeat_ack = 11
		request_sync = 12
		client_disconnected = 13
		request_sync_chan = 14

		unknown_error = 4000
		unknown_opcode = 4001
		decode_error = 4002
		not_authenticated = 4003
		authentication_failed = 4004
		already_authenticated = 4005
		invalid_seq = 4007
		rate_limited = 4008
		session_timeout = 4009
		invalid_shard = 4010
		sharding_required = 4011
		invalid_api_ver = 4012
		intent_invalid = 4013
		intent_denied = 4014

		oneshot = 10_000

	class c_chan_type(enum.IntEnum):
		# https://discord.com/developers/docs/resources/channel#channel-object-channel-types
		text = 0
		private = 1
		voice = 2
		private_group = 3
		group = 4 # header for a group of channels
		news = 5 # announcements channels
		store = 6
		thread_news = 10 # threads in news channels
		thread = 11
		thread_private = 12 # invite-only threads
		stage = 13
		forum = 15

	class c_msg_type(enum.IntEnum):
		# https://discord.com/developers/docs/resources/channel#message-object-message-types
		default = 0
		recipient_add = 1
		recipient_remove = 2
		call = 3
		channel_name_change = 4
		channel_icon_change = 5
		channel_pinned_message = 6
		guild_member_join = 7
		user_premium_guild_subscription = 8
		user_premium_guild_subscription_tier_1 = 9
		user_premium_guild_subscription_tier_2 = 10
		user_premium_guild_subscription_tier_3 = 11
		channel_follow_add = 12
		guild_discovery_disqualified = 14
		guild_discovery_requalified = 15
		guild_discovery_grace_period_initial_warning = 16
		guild_discovery_grace_period_final_warning = 17
		thread_created = 18
		reply = 19 # inline reply msg, was type=default with old api
		application_command = 20
		thread_starter_message = 21
		guild_invite_reminder = 22

		# Negative values are internal types
		bot_embeds = -1001
		bot_empty = -1002
		media_info = -1003

	class c_rel_type(enum.IntEnum):
		none = 0
		friend = 1
		blocked = 2
		friend_req_in = 3
		friend_req_out = 4
		friend_implicit = 5

	c_msg_tags = enum.Enum('c_msg_tags', 'user chan role emo ts everyone other')
	c_msg_tags_ts_fmts = dict(
		t='%H:%M', T='%H:%M:%S', d='%Y-%m-%d', D='%m %B %Y',
		f='%Y-%m-%d %H:%M:%S', F='%c', R=repr_duration )

	def __init__(self, discord):
		self.discord, self.conf = discord, discord.conf
		self.api_url = self.conf.discord_api_url.format(api_ver=self.api_ver)
		self.log = get_logger(f'rdircd.discord')
		self.log_ws = get_logger(f'proto.ws')
		self.log_http = get_logger(f'proto.http')
		self.log_http_reqres = get_logger(f'proto.http.reqres')
		self._repr = ft.partial(str_cut, max_len=self.conf.debug_proto_cut, repr_fmt=True)

	def get_auth(self, k, default=ValueError):
		try: return self.conf.get(f'auth_{k}')
		except AttributeError as err:
			if default is ValueError: raise
			return default

	async def __aenter__(self):
		if not (self.get_auth('email') and self.get_auth('password')):
			raise DiscordSessionError('Missing account auth credentials')
		self.ctx, self.tasks = cl.AsyncExitStack(), StacklessContext(self.log)
		aiohttp_opts = adict(timeout=aiohttp.ClientTimeout(
			connect=self.conf.discord_http_timeout_conn,
			sock_connect=self.conf.discord_http_timeout_conn_sock ))
		if self.conf.debug_proto_aiohttp:
			aiohttp_opts.trace_configs = [setup_aiohttp_trace_logging(self.log_http_reqres)]
		self.http = await self.ctx.enter_async_context(aiohttp.ClientSession(**aiohttp_opts))
		self.ws_ctx = self.ws = self.ws_tasks = self.ws_handlers = self.ws_nonces = None
		self.ws_closed, self.ws_closed_clean = asyncio.Event(), asyncio.Event()
		self.ws_closed_clean.set()
		self.rate_limits = adict()
		self.st = adict(guilds={1: adict(
			id=1, name='me', ts_joined=0, kh='me', chans=dict(), roles=dict() )})
		self.st.update(me=self.st.guilds[1], embed_info=dict())
		self.auth_token, self.auth_token_manual = (
			self.get_auth('token'), self.get_auth('token_manual') )
		self.ws_enabled = False
		return self

	async def __aexit__(self, *err):
		if self.ws_enabled:
			self.ws_enabled.cancel()
			self.ws_enabled = None
		if self.ws_ctx: await self.ws_ctx.aclose()
		if self.ctx: await self.ctx.aclose()
		if self.tasks: await self.tasks.close()

	async def run(self):
		self.log.debug('Initializing discord session...')
		try: await asyncio.Future() # run forever
		except asyncio.CancelledError: pass
		self.log.debug('Finished')

	async def run_async(self):
		async with self: await self.run()

	def connect(self):
		if self.ws_enabled and not self.ws_enabled.done(): return
		task = self.ws_enabled = asyncio.create_task(self.ws_connect_loop())
		return self.tasks.add(task)
	def disconnect(self):
		if self.ws_enabled:
			self.ws_enabled.cancel()
			self.ws_enabled = None
		return self.tasks.add(self.ws_close())

	def state(self, st):
		'''This is purely informative, and should never
			actually be checked - use class flags for that, not strings here.'''
		st_old, self.st.state = self.st.get('state', 'none'), st
		if st == st_old: return
		self.log_ws.debug( '--- state: {} -> {}',
			st_old, st, extra=('---', f'st {st_old} -> {st}') )
		self.log.info('State: {} -> {}', st_old, st)
		if (online := st == 'ready') or st_old == 'ready':
			self.discord.cmd_online_state(online)


	### Regular HTTP requests and OAuth2 stuff

	async def rate_limit_wrapper(self, route, req_func):
		# Discord also returns proactive X-Rate-Limit headers, but these are
		#  not used here - shouldn't be needed, as simple client is unlikely to bump into them
		req_limit_defaults = 1, None
		while True:
			ts = time.time()
			req_limit, req_limit_ts = self.rate_limits.get(route) or req_limit_defaults
			if req_limit_ts and ts > req_limit_ts: req_limit = 1
			if req_limit <= 0 and req_limit_ts and req_limit_ts > ts:
				delay = req_limit_ts - ts
				self.log.debug('Rate-limiting request on route {!r}: delay={:,.1f}s', route, delay)
				await asyncio.sleep(delay + self.conf.discord_http_delay_padding)
			res = await req_func()
			req_limit_headers = list( res.headers.get(k)
				for k in ['X-RateLimit-Remaining', 'X-RateLimit-Reset'] )
			if any(req_limit_headers):
				warn, req_limit_vals = False, list(req_limit_defaults)
				for n, v in enumerate(req_limit_headers):
					try: req_limit_vals[n] = float(v)
					except ValueError as err: warn = False
				if warn:
					self.log.warning( 'Failed to parse rate-limit http'
						' header value(s), assuming default(s): {!r} / {!r}', *req_limit_headers )
				req_limit, req_limit_ts = self.rate_limits[route] = req_limit_vals
			if res.status == 429:
				m = await res.json()
				if delay := m.get('retry_after'):
					self.log.debug( 'Rate-limiting request on route'
							' {!r}: explicit-retry-after, delay={:,.1f}s, global={}, msg={!r}',
						route, delay, m.get('global'), m.get('message') )
					await asyncio.sleep(float(delay) + self.conf.discord_http_delay_padding)
					continue
				elif req_limit <= 0 and req_limit_ts and req_limit_ts > time.time(): continue
				else:
					raise DiscordSessionError(
						'Failed to get API rate-limiting retry delay for http-429 error' )
			break
		return res

	async def req_auth_token(self):
		while isinstance(self.auth_token, asyncio.Event): await self.auth_token.wait()
		if not self.auth_token:
			if self.auth_token_manual:
				raise DiscordAbort( 'Authentication token is set'
					' to be configured manually, but is not specified' )
			auth_token_ev = self.auth_token = asyncio.Event()
			try:
				email, pw = (self.get_auth(k) for k in ['email', 'password'])
				res = await self.req( 'auth/login', m='post',
					auth=False, json=dict(email=email, password=pw) )
				if res.get('mfa'):
					raise DiscordAbort( 'Multi-factor auth'
						' requirement detected, but is not supported by rdircd' )
				self.conf.set('auth_token', res['token'])
				self.conf.update_file_section('auth', 'token')
				self.auth_token = self.get_auth('token')
			except:
				self.auth_token = None # reset for next attempt
				raise
			finally: auth_token_ev.set()
		return self.auth_token

	async def req( self, url, m='get',
			route=None, auth=True, raw=False, **kws ):
		if not re.search(r'^https?:', url): url = urllib.parse.urljoin(self.api_url, url.lstrip('/'))
		if route is None: route = url
		kws.setdefault('headers', dict()).setdefault(
			'User-Agent', self.conf.discord_api_user_agent )
		for att in 'normal', 'token_refresh':
			if auth:
				token = await self.req_auth_token()
				kws.setdefault('headers', dict()).update(Authorization=token)
			req_func = ft.partial(self.http.request, m, url, **kws)
			self.log_http.debug(' >> {} {}', m, url, extra=(' >>', f'{m} {url}'))
			res = await self.rate_limit_wrapper(route, req_func)
			if not auth: break
			if res.status == 401:
				res.release()
				if att != 'normal': raise DiscordSessionError('Auth failed')
			break
		if res.status >= 400:
			err = str_cut(await res.text(), self.conf.debug_err_cut, repr_fmt=True)
			raise DiscordHTTPError(f'[{res.status}] {res.reason} - {err}')
		if not raw:
			res_raw = res
			try: res = await res.json()
			except aiohttp.ContentTypeError:
				err = str_cut(await res.text(), self.conf.debug_err_cut, repr_fmt=True)
				raise DiscordHTTPError(
					f'[{res.status}] non-JSON data ({res.content_type}) - {err}' ) from None
			finally: res_raw.release()
		res_repr = str(res)
		if '\n' in res_repr: res_repr = (res_repr.strip() + ' ').splitlines()[0]
		self.log_http.debug('<<  {}', self._repr(res_repr), extra=('<< ', res_repr))
		return res


	### Gateway Websocket wrappers

	async def ws_connect_loop(self):
		opts = adict((k, parse_duration(
			self.conf.get(f'discord_ws_reconnect_{k}') )) for k in ['min', 'max'] )
		opts.factor = self.conf.discord_ws_reconnect_factor
		reconn_warn_tb = token_bucket(self.conf.discord_ws_reconnect_warn)
		interval, loop = opts.min, asyncio.get_running_loop()
		self.log.debug('Starting ws_connect_loop...')
		while self.ws_enabled:
			if next(reconn_warn_tb):
				self.log.warning( 'Reconnecting to discord faster than {},'
						' can be persistent problem, see info/debug/protocol logs for details',
					self.conf.discord_ws_reconnect_warn )
				# Reset tbf to avoid re-issuing warning on every subsequent reconnect
				reconn_warn_tb = token_bucket(self.conf.discord_ws_reconnect_warn)
			elif self.conf.discord_ws_reconnect_warn_max_delay and interval == opts.max:
				self.log.warning( 'Detected persistent discord server connection failures'
					' at max delay between attempts - there is likely some persistent problem,'
					' see info/debug/protocol logs for details' )
			try: await self.ws_connect()
			except DiscordSessionError as err:
				self.log.info('Connection failure, retrying in {:.1f}s: {}', interval, err)
				await asyncio.sleep(interval)
				interval = min(opts.max, interval * opts.factor)
				continue
			except Exception as err:
				self.log.exception( 'Unexpected error,'
					' stopping reconnection loop: {}', err_fmt(err) )
				await self.ws_close()
				break
			ts0 = loop.time()
			await self.ws_closed.wait()
			if not self.ws_enabled: break
			ts_diff = loop.time() - ts0
			if ts_diff > interval:
				interval = opts.min
				self.log.info('Disconnected, reconnecting immediately')
			else:
				interval = min(opts.max, interval * opts.factor)
				delay = interval - ts_diff
				self.log.info( 'Disconnected too quickly'
					' ({:.1f}s), reconnecting in {:.1f}s', ts_diff, delay )
				await asyncio.sleep(delay)
		self.log.debug('ws_connect_loop finished')

	async def ws_connect(self):
		if self.ws_ctx: return
		if not self.ws_closed_clean.is_set():
			self.log.warning('BUG: ws_connect issued before old websocket is closed')
			await self.ws_closed_clean.wait()
		self.state('connecting.init')
		self.ws_ctx = ctx = cl.AsyncExitStack()
		self.ws, self.ws_tasks = None, StacklessContext(self.log)
		self.ws_handlers, self.ws_nonces = dict(), dict()
		ctx.push_async_callback(self.ws_close)
		self.ws_closed.clear()
		for cache in True, False:
			if cache:
				if not self.conf.discord_gateway: continue
				self.state('connecting.ws.cached')
			else:
				self.state('connecting.ws.get-url')
				try: self.conf.discord_gateway = (await self.req('gateway', auth=False))['url']
				except aiohttp.ClientError as err:
					self.state('connecting.ws.error')
					self.log.info('Failed to fetch discord gateway URL: {}', err_fmt(err))
					continue # ws_connect will fail
				self.conf.update_file_section('discord', 'gateway')
			parts = adict(urllib.parse.urlsplit(self.conf.discord_gateway)._asdict())
			query = urllib.parse.parse_qs(parts.query)
			query.update(v=str(self.api_ver), encoding='json', compress='zlib-stream')
			parts.query = urllib.parse.urlencode(query)
			ws_url = urllib.parse.urlunsplit(tuple(parts.values()))
			self.log_ws.debug('--- -conn- {}', ws_url, extra=('---', f'conn {ws_url}'))
			self.state('connecting.ws')
			try:
				self.ws = await ctx.enter_async_context(self.http.ws_connect(
					ws_url, heartbeat=self.conf.discord_ws_heartbeat,
					headers={'User-Agent': self.conf.discord_api_user_agent},
					timeout=self.conf.discord_ws_timeout, max_msg_size=20 * 2**20 ))
			except aiohttp.ClientError as err:
				err_str = err_fmt(err)
				self.log_ws.debug('--- -conn-fail- {}', err_str, extra=('---', f'conn-fail {err_str}'))
				self.state('connecting.ws.error')
				self.log.info('Gateway connection error: {}', err_str)
				if cache: continue # try fetching new gw url
			else: break
		else: self.ws = None
		if self.ws_closed.is_set():
			self.ws_closed.clear() # to have it do cleanup again
			await self.ws_close()
			raise DiscordSessionError('Close-command issued while connecting')
		if not self.ws:
			self.state('connecting.ws.fail')
			await self.ws_close()
			raise DiscordSessionError('Failed to connect to discord')
		self.state('connected')
		self.ws_add_handler(self.c.dispatch, self.op_track_seq)
		self.ws_add_handler(self.c.reconnect, self.op_reconnect)
		self.ws_add_handler(self.c.hello, self.op_hello)
		self.ws_add_handler(self.c.invalid_session, self.op_invalid_session_retry)
		self.ws_tasks.add(self.ws_poller())
		self.ws_tasks.add(self.ws_auth_timeout())

	_ws_handler = cs.namedtuple('ws_handler', 'op t func')
	def ws_add_handler(self, op=None, func=None, t=None, replace=False, remove=False):
		if replace or remove:
			for k, wsh in list(self.ws_handlers.items()):
				if wsh.op == op and wsh.t == t: del self.ws_handlers[k]
			if remove: return
		if not func: raise ValueError(func)
		wsh = self._ws_handler(op, t, func)
		self.ws_handlers[wsh] = wsh

	async def ws_poller(self):
		try: await self.ws_poller_loop()
		except Exception as err:
			self.log.exception('Unhandled ws handler failure, aborting: {}', err_fmt(err))
		self.ws_close_later()

	async def ws_poller_loop(self):
		# {op=0**, s=**42, d={...}, t=**'GATEWAY_EVENT_NAME'}
		# {op=...[, d={...}]}
		inflator, buff, buff_end = zlib.decompressobj(), bytearray(), b'\x00\x00\xff\xff'
		async for msg in self.ws:
			msg_type, msg_data = msg.type, getattr(msg, 'data', '')
			if msg_type == aiohttp.WSMsgType.binary:
				buff.extend(msg_data or b'')
				if msg_data[-4:] != buff_end: continue # partial data
				msg_data = inflator.decompress(buff).decode()
				msg_type = aiohttp.WSMsgType.text
				buff.clear()
			if msg_type == aiohttp.WSMsgType.text:
				self.log_ws.debug( '<<  {} {}', msg_type.name.lower(),
					self._repr(msg_data), extra=('<< ', f'{msg_type} {msg_data}') )
				msg_data, hs_discard, handled = adict(json.loads(msg_data)), set(), False
				if self.conf.ws_dump_filter and self.conf.ws_dump_file:
					chk = self.conf.ws_dump_filter
					if ( chk.get('op') == msg_data.get('op')
							and chk.get('t') == msg_data.get('t') ):
						with open(self.conf.ws_dump_file, 'w') as dst: dst.write(msg_data)
				for k, h in list(self.ws_handlers.items()):
					if h.op is not None and msg_data.get('op') != h.op: continue
					if h.t is not None and (msg_data.get('t') or '').lower() != h.t: continue
					handled = True
					status = await aio_await_wrap(h.func(msg_data))
					if status is self.c.oneshot: hs_discard.add(k)
				for k in hs_discard: self.ws_handlers.pop(k, None)
				if not handled:
					err, msg_repr = 'unhandled-text', self._repr(msg_data)
					self.log_ws.debug( 'xxx {} {}', err,
						msg_repr, extra=('xxx', f'{err} {msg_data}') )
					self.log.debug('Unhandled ws event: {}', msg_repr)
			elif msg_type == aiohttp.WSMsgType.closed: break
			elif msg_type == aiohttp.WSMsgType.error:
				self.log_ws.debug('err {}', msg, extra=('err', msg))
				self.log.error('ws protocol error, aborting: {}', msg)
				break
			else: self.log.warning('Unhandled ws msg type {}, ignoring: {}', msg.type, msg)

	async def ws_auth_timeout(self):
		await asyncio.sleep(timeout := self.conf.discord_ws_auth_timeout)
		if self.st.state == 'ready': return
		self.log.error( 'Discord gateway auth failed'
			' to complete within {:.1f}s, closing connection', timeout )
		self.ws_close_later()

	async def ws_send_task(self, msg_data):
		try: await self.ws.send_str(msg_data)
		except ConnectionError as err: # not handled by aiohttp in some cases
			self.log.info('Conn error when trying to send last protocol data: {}', err_fmt(err))
			self.state('connection.fail')
			self.ws_close_later()

	def ws_send(self, op, d):
		msg_data = json.dumps(dict(op=op, d=d))
		self.log_ws.debug( ' >> text {}',
			self._repr(msg_data), extra=(' >>', f'text {msg_data}') )
		self.tasks.add(self.ws_send_task(msg_data))

	def ws_close_later(self):
		'Wrapper to schedule ws_close() from one of ws_tasks or synchronously.'
		# Idea here is just to avoid ws_close_task() cancelling itself
		self.tasks.add(self.ws_close())

	def ws_close(self):
		# Makes sure that only one ws_close_task()
		#  is scheduled at a time, and only if needed
		if ( self.ws_closed.is_set()
			or not self.ws_closed_clean.is_set() ): return asyncio.sleep(0)
		self.ws_closed_clean.clear()
		return self.ws_close_task()

	async def ws_close_task(self):
		try:
			if self.ws_closed.is_set():
				return self.log.warning('BUG: ws_close with websocket already closed')
			self.log_ws.debug('--- -close-', extra=('---', 'close'))
			self.state('closing')
			if self.ws_nonces:
				for fut in self.ws_nonces.values(): fut.cancel()
			if self.ws_tasks: await self.ws_tasks.close()
			if self.ws: await self.ws.close()
			self.ws_ctx = self.ws = self.ws_tasks = self.ws_handlers = self.ws_nonces = None
			self.st.disconnect_ts = time.monotonic()
			self.state('disconnected')
			self.ws_closed.set()
		finally: self.ws_closed_clean.set()


	### Gateway Websocket request wrappers (rare)

	async def ws_req_users_query(self, gid, query, limit):
		nonce = self.discord.flake_build(time.time())
		fut = self.ws_nonces[nonce] = asyncio.Future()
		self.ws_send( self.c.request_guild_members,
			dict(nonce=nonce, guild_id=[gid], presences=False, limit=limit, query=query) )
		try: return await fut
		finally: del self.ws_nonces[nonce]

	def ws_req_thread_list_sync(self):
		for gid, gg in self.st.guilds.items():
			if gid == 1: continue # "me" pseudo-guild
			chan_req = list( (cc.id, [[0, 99]])
				for cc in gg.chans.values() if cc.t == self.c_chan_type.text )
			if not chan_req: continue
			# It doesn't seem to matter what channels= are sent in request_sync_chan -
			#   all threads are returned regardless, along with GUILD_MEMBER_LIST_UPDATE and such
			self.ws_send( self.c.request_sync_chan,
				dict(guild_id=gid, threads=True, channels=dict([chan_req[0]])) )

	### Gateway Websocket event handlers

	def op_track_seq(self, m): self.st.seq = m.s

	def op_reconnect(self, m):
		self.log.info('Received reconnect event - closing connection')
		self.ws_close_later()

	async def op_hello(self, m):
		self.state('hello')
		self.st.hb_interval = m.d.heartbeat_interval / 1e3
		await self.op_hello_auth()
		return self.c.oneshot

	async def op_hello_auth(self):
		self.state('hello.auth.token')
		sid = self.st.get('session_id')
		token = await self.req_auth_token()
		if not sid:
			self.state('hello.auth.identify')
			self.ws_add_handler( self.c.dispatch,
				t='ready', func=self.op_ready, replace=True )
			# Note: sending API intents seem to strip "contents" from other people's messages!
			self.ws_send(self.c.identify, dict(
				properties={'os': 'Linux', 'browser': 'rdircd', 'device': ''},
				token=token, compress=False ))
		else:
			self.state('hello.auth.resume')
			self.ws_add_handler( self.c.dispatch,
				t='resumed', func=self.op_ready, replace=True )
			self.ws_send(self.c.resume, dict(
				token=token, session_id=sid, seq=self.st.get('seq') ))

	async def op_invalid_session_retry(self, m):
		# "expected to wait a random amount of time -
		#  - between 1 and 5 seconds - then send a fresh Opcode 2 Identify"
		self.state('session.error.delay')
		delay = asyncio.create_task(asyncio.sleep(1 + random.random() * 4))
		if not m.get('d') and self.st.get('session_id'):
			self.log.info( 'Session/auth rejected (id={}) - trying'
				' to open new session first', self.st.get('session_id', '-')[:6] )
			self.st.session_id = self.st.seq = None
			await delay
		else:
			if self.auth_token_manual:
				self.log.info( 'Auth rejected, but auth token'
					' is set to be manual, so just retrying once more' )
			else:
				self.log.info('Auth rejected - updating auth token')
				self.auth_token = None
				token = await self.req_auth_token()
			await delay
			self.ws_add_handler( self.c.invalid_session,
				self.op_invalid_session_fail, replace=True )
		self.state('session.error')
		await self.op_hello_auth()

	def op_invalid_session_fail(self, m):
		if self.conf.discord_ws_reconnect_on_auth_fail:
			self.state('session.auth-fail-reconnect')
		else:
			self.log.warning('Session/auth rejected unexpectedly - disabling connection')
			self.state('session.fail')
			self.ws_enabled = False
		self.ws_close_later()

	def op_invalid_session_event(self, m):
		self.log.warning('Unexpected "invalid session" event - reconnecting')
		self.state('session.fail')
		self.ws_close_later()

	def op_ready(self, m):
		md, resume, ts = m.get('d'), False, time.monotonic()
		ts_start = tuple(self.st.get(k) for k in ['session_ts', 'connect_ts'])
		if md and md.get('session_id'):
			self.st.update(
				session_id=md.session_id,
				session_ts=ts,
				user_id=md.user.id,
				user_name=md.user.username,
				user_n=md.user.discriminator )
			self.op_ev_guilds(md.get('guilds'), sync=True)
			self.op_ev_chans(self.st.me.id, md.get('private_channels'), sync=True)
			self.ws_req_thread_list_sync()
			self.log.debug( 'New session id: {} gw=[ {} ]',
				self.st.session_id, md.get('resume_gateway_url') )
		else: resume = True
		if resume_url := md.get('resume_gateway_url'):
			self.conf.discord_gateway = resume_url
		td_sess, td_conn = ((repr_duration(self.st.get(
			'disconnect_ts' ) or ts, ts0, ext=None) if ts0 else '-') for ts0 in ts_start)
		level = 'warning' if self.conf.discord_ws_reconnect_warn_always else 'debug'
		ws_addr = ws_ep[0] if (ws_ep := self.ws.get_extra_info('peername')) else '?'
		getattr(self.log, level)( 'Discord session (re-)connected{}: id={} gw-addr={}{}',
			' [resumed]' if resume else ' [new]', self.st.session_id[:6], ws_addr,
			'' if not (td_sess + td_conn).strip('-') else ' {}sess-lifetime=[{}]{}'.format(
				'last-' if not resume else '', td_sess,
				'' if td_conn == td_sess else f' last-conn-duration=[{td_conn}]' ) )
		self.st.connect_ts, self.st.disconnect_ts = ts, None
		self.state('ready')
		self.ws_add_handler(self.c.dispatch, func=self.op_ev)
		self.ws_add_handler( self.c.invalid_session,
			self.op_invalid_session_event, replace=True )
		self.ws_tasks.add(self.op_heartbeat_task(self.st.hb_interval))
		return self.c.oneshot

	async def op_heartbeat_task(self, interval):
		loop = asyncio.get_running_loop()
		self.st.hb_ts_ack = hb_ts = loop.time() + interval
		self.ws_add_handler(self.c.heartbeat_ack, self.op_heartbeat_ack)
		while not self.ws_closed.is_set():
			self.ws_send(self.c.heartbeat, self.st.get('seq'))
			ts = loop.time()
			if self.st.hb_ts_ack < ts - interval*2:
				self.log.info('Missing heartbeat ack, reconnecting')
				self.state('heartbeat.fail')
				return self.ws_close_later()
			while hb_ts <= ts: hb_ts += interval
			delay = hb_ts - ts
			await asyncio.sleep(delay)

	def op_heartbeat_ack(self, m):
		self.st.hb_ts_ack = asyncio.get_running_loop().time()

	def op_ev(self, m):
		'Dispatcher for op=0 (dispatch) gateway-ws events, which is most of the activity'
		mt = (m.get('t') or '').lower()
		try: o, act = mt.rsplit('_', 1)
		except ValueError: o = act = None
		try: gid = m.d.guild_id
		except: gid = None

		# Events with some handling needed
		if o == 'guild':
			if act in ['create', 'update']: return self.op_ev_guilds(m.d)
			elif act == 'delete': return self.op_ev_delete(gid)
		elif mt.startswith('guild_member_'):
			act = mt[13:]
			if act == 'list_update': return self.op_ev_member_ops(m.d)
			elif act in ['add', 'update']: return self.op_ev_member(m.d)
			elif act == 'remove': return self.op_ev_member(m.d, delete=True)
		elif mt == 'guild_members_chunk': return self.op_ev_member_chunk(m.d)
		elif mt == 'thread_list_sync': return self.op_ev_thread_list(m.d)
		elif o == 'guild_ban':
			if act in ['add', 'remove']: return self.op_ev_ban(m.d, act)
		elif o == 'guild_scheduled_event': return self.op_ev_sched(m.d, act)
		elif o in ['channel', 'thread']:
			if act in ['create', 'update']: return self.op_ev_chans(gid or 1, m.d)
			elif act == 'delete': return self.op_ev_delete(gid or 1, chan=m.d.id)
		elif o == 'channel_recipient': return self.op_ev_recipient(m.d, act)
		elif o == 'message':
			if act in ['create', 'update', 'delete']: return self.op_msg(m.d, act)
			elif act == 'ack': return # reading private chats from browser
		elif o == 'relationship': return self.op_ev_rel(m.d, act)
		elif mt == 'message_delete_bulk': # can maybe shorten notice-spam here somehow
			for msg_id in m.d.ids: self.op_msg(adict(id=msg_id, **m.d), 'delete')
			return
		elif mt == 'gift_code_update': return self.op_gift_code(m.d)
		elif mt.startswith('message_reaction_'): return self.op_react(m.d, mt[17:])

		# Known-ignored events
		elif o in [ 'typing', 'integration', 'channel_pins', 'webhooks', 'call',
			'presence', 'presences', 'thread_member', 'thread_members', 'stage_instance',
			'guild_emojis', 'guild_integrations', 'guild_role', 'guild_stickers',
			'guild_audit_log_entry' ]: return
		elif re.search( r'^(voice|guild_scheduled_event'
			r'|(guild_)?application_command|embedded_activity)_', mt ): return
		elif re.search( r'^user_(note|'
			r'guild_settings|settings|settings_proto)_update$', mt ): return
		elif mt in ['sessions_replace', 'invite_create']: return

		# Known stuff must be explicitly handled above to not generate "Unhandled event"
		self.log.warning('Unhandled event: {}', self._repr(m))

	def op_ev_thread_list(self, d):
		self.op_ev_chans(d.guild_id, d.threads, sync=False)

	def op_ev_member_chunk(self, d):
		user_map = dict()
		for m in d.get('members', list()):
			uid, name = self.op_ev_member(m, d.guild_id)
			user_map[uid] = name
		nonce = d.get('nonce')
		if nonce: self.ws_nonces[nonce].set_result(user_map)

	def op_ev_member_ops(self, d):
		for o in d.get('ops', list()):
			for item in o.get('items') or list():
				m = item.get('member', dict())
				if m: self.op_ev_member(m, d.guild_id, delete=o.op=='DELETE')

	def op_ev_member(self, m, gid=None, delete=False):
		if not gid: gid = m.guild_id
		if delete: return self.discord.cmd_user_cache(gid, m.user.id)
		self.discord.cmd_user_cache(gid, m.user.id, m.user.username)
		return m.user.id, m.user.username # used in queries

	def op_ev_delete(self, gid=None, chan=...):
		if not (gg := self.st.guilds.get(gid)): return
		if chan is not ...: gg.chans.pop(chan, None)
		else: self.st.guilds.pop(gid, None)

	def op_ev_guilds(self, guilds, sync=False):
		gs_new, gs_chans = {1: self.st.me}, dict()
		for g in force_list(guilds):
			if g.get('unavailable'): continue # can be sent in "ready" event
			if g.id == self.st.me.id:
				self.log.error('Skipping guild due to id=1 conflict with "me" guild: {}', g)
				continue
			gg = gs_new.setdefault( g.id,
				self.st.guilds.get(g.id, adict(id=g.id, chans=dict())) )
			if g.id not in self.st.guilds:
				prefix = self.discord.bridge.uid('guild', gg.id, kh=gg.get('kh'))
				self.log.debug('New guild: gid={} prefix={} name={!r}', g.id, prefix, g.name)
			ts_joined = g.get('joined_at') or 0
			if ts_joined: ts_joined = parse_iso8601(ts_joined)
			gg.update(
				name=g.name, ts_joined=ts_joined,
				roles=dict((r.id, r) for r in g.get('roles', list())) )
			if 'channels' in g: gs_chans[g.id, 'c'] = g.channels # missing in guild_update evs
			if 'threads' in g: gs_chans[g.id, 't'] = g.threads # only "joined" ones are listed here!
		dict_update(self.st.guilds, gs_new, sync=sync)
		for (gid, sk), chans in sorted(gs_chans.items()):
			self.op_ev_chans(gid, chans, sync=sk == 'c')

	def op_ev_chans(self, gid, chans, sync=None):
		if not (gg := self.st.guilds.get(gid)): return
		chans_updated, new_threads = self.op_ev_chans_process(gg, chans)

		rename_list = list() # sent after chan_map is updated
		for cc, name_old in self.op_ev_chans_rename_list(chans_updated, gg.chans):
			rename_list.append(self.discord.cmd_chan_rename_func(cc, name_old))

		for cc in chans_updated.values():
			if cc.id in gg.chans or cc.tid: continue # threads are logged separately below
			self.log.debug( 'New channel [id={} gid={}]:'
				' {!r} ({!r})', cc.id, gg.id, cc.name, cc.topic )

		dict_update(gg.chans, chans_updated, sync=sync)
		self.discord.cmd_chan_map_update()

		if sync is None:
			# "new_threads" is needed to avoid mentioning them on every (re-)connect
			# Notification is also needed in case msgs from these to parent chan are disabled
			for cc in new_threads:
				self.log.debug( 'New thread-channel'
					' [id={} gid={}]: {!r} ({!r})', cc.id, gg.id, cc.name, cc.topic )
				self.discord.cmd_chan_thread(cc)
		for ren_func in rename_list: ren_func() # these msgs go to old chans

	def op_ev_chans_process(self, gg, chans):
		'''Process channel data from discord into internal information.
			Earlier information from gg.chans is used as a base and for diffs, if it is there.'''
		def _gg_prefix(): # used only for error logging here
			return self.discord.bridge.uid('guild', gg.id, kh=gg.get('kh'))
		ct, chans_updated, new_threads = self.c_chan_type, dict(), list()
		for c in force_list(chans):
			name, topic = c.get('name'), c.get('topic') or ''
			cc = gg.chans.get( c.id,
				adict(tid=None, names=adict(), users=adict(), threads=adict(), last_msg_sent=adict()) )
			cc.names.update(raw=name or '', old=cc.get('name', '')) # for various renames

			if c.type in [ct.group, ct.store, ct.stage]: continue
			try: cc_type = ct(c.type)
			except ValueError:
				self.log.error( 'BUG - ignoring unknown channel'
						' type={}: guild={!r} (id={} prefix={}) name={!r} topic={!r} id={}',
					c.type, gg.name, gg.id, _gg_prefix(), name, topic, c.id )
				continue
			# Voice chats often have same name as text ones, so disambiguate via suffix
			if c.type == ct.voice: name = f'{name}.vc'
			if c.type in [ct.thread, ct.thread_news, ct.thread_private]:
				cc_parent = gg.chans.get(c.parent_id) or chans_updated.get(c.parent_id)
				if not cc_parent:
					self.log.error( 'BUG - ignoring thread sub-channel with'
							' unknown parent-chan={}: guild={!r} (id={} prefix={}) name={!r} id={}',
						c.parent_id, gg.name, gg.id, _gg_prefix(), name, c.id )
					continue
				c_tid = self.conf.discord_thread_id_prefix
				c_tid += str_norm(str_hash( c.id, 4,
					strip=(c_tid.lower() + c_tid.upper()) if len(c_tid) == 1 else '' ))
				name = self.op_ev_chans_thread_name(c_tid, name, cc_parent.name)
			else: c_tid = None
			cc_private = c.type in [ct.private, ct.private_group]

			if c_tid:
				cc.tid, cc.parent, cc_parent.threads[c_tid] = c_tid, cc_parent, cc
				if not cc.names.old: new_threads.append(cc)
			elif cc_private:
				ts, users= time.time(), c.get('recipients') or list()
				for u in users: self.discord.cmd_user_cache(gg.id, u.id, u.username)
				dict_update( cc.users,
					( (u.username, adict(name=u.username, ts=ts))
						for u in (c.get('recipients') or list()) ), sync=True )
				user_names = list(u.name for u in cc.users.values())
				name_kws = dict(id=(name_hash := str_hash(c.id, 8)))
				if any(f'{{{k}}}' in self.conf.irc_chan_private for k in ['names', 'names_or_id']):
					# {id} hash-name is not descriptive, but should stay the same for group chats
					# Caveat: channel names are case-insensitive, even if hash is not
					name_kws['names_or_id'] = name_kws['id']
					if '{names}' in self.conf.irc_chan_private or (
							len(user_names) < self.conf.irc_private_chat_min_others_to_use_id_name ):
						name_kws['names'] = self.op_ev_chans_priv_name(user_names)
						name_kws['names_or_id'] = name_kws['names']
				name = self.conf.irc_chan_private.format(**name_kws)
				if not topic:
					topic = ( f'private chat <{name_hash}>'
						f' [{len(user_names)}] - ' + ', '.join(user_names) )

			rename = self.conf.renames.get(('chan', f'@{c.id}'))
			if not rename:
				rename = self.conf.renames.get(('chan', self.discord.bridge.irc_name(name)))
			if rename:
				self.log.debug('Renaming channel/thread: {} -> {}', name, rename)
				name = rename
			cc.names.base = name # deduped later, or reverted back to this from hashed one

			cc.update(
				id=c.id, gg=gg, did=f'#{gg.id}-{c.id}', name=name, topic=topic,
				t=cc_type, private=cc_private, pos=c.get('position', -1)+1, nsfw=c.get('nsfw'),
				last_msg=c.get('last_message_id'), last_pin=c.get('last_pin_timestamp') )
			chans_updated[c.id] = cc
		return chans_updated, new_threads

	def op_ev_chans_rename_list(self, cs_new, cs_old):
		'''Detect discord channels within same guild that need renames to not clash on IRC.
			Yields (cc, name_old) tuples to report via IRC notices, updates cc.name after that.'''
		## Renames that happened on the discord side, between cs_old -> cs_new
		# cs_old should contain exactly same cc objects as cs_new for same ids
		# threads are skipped here - should be unique already, renamed with parent chans
		chans = dict((cc.id, cc) for cc in cs_old.values())
		# names restored to discord originals, to detect when hash in them is no longer needed
		names = dict(( cc.id, cc.names.base
			if cc.names.base != cc.name else cc.name ) for cc in chans.values())
		for cc in cs_new.values(): chans[cc.id], names[cc.id] = cc, cc.name

		## Find where renames need to happen to make names unique
		chan_names, chan_names_unique = irc_name_dict(), irc_name_dict()
		for cc in chans.values():
			name = names[cc.id]
			if ccs := chan_names.get(name): ccs[cc.id] = cc # name conflict
			else: chan_names[name] = {cc.id: cc}
		for name, ccs in list(chan_names.items()):
			if len(ccs) > 1: continue
			del chan_names[name]
			chan_names_unique.add(name)

		## Resolve found ambiguities by appending id-hash to names
		chan_names_hashed = irc_name_dict()
		name_fmt, name_hlen = (getattr(
			self.conf, f'discord_chan_dedup_{k}' ) for k in ['fmt', 'hash_len'])
		for name, ccs in chan_names.items():
			for cc in ccs.values():
				id_hash = cc.id
				for n in range(100):
					id_hash = str_hash(id_hash, name_hlen)
					name_hashed = name_fmt.format(name=names[cc.id], id_hash=id_hash)
					if ( name_hashed not in chan_names_hashed
						and name_hashed not in chan_names_unique ): break
				else: raise RuntimeError(f'str_hash() loop on: {id_hash!r} [len={name_hlen}]')
				names[cc.id] = name_hashed
				chan_names_hashed.add(name_hashed)

		## Detect name changes from cc.names.old, yield as renames, update cc.name/s.old
		for cc in sorted(chans.values(), key=lambda cc: bool(cc.tid)):
			src, dst = cc.name, names[cc.id]
			if dst is None: continue # thread already processed on chan rename
			thread_info = ( f' [+ {len(cc.threads)} thread]'
				if cc.threads else (' [thread]' if cc.tid else '') )
			if not irc_name_eq(src_old := cc.names.old or src, dst):
				self.log.debug('Channel rename{}: {!r} -> {!r}', thread_info, src_old, dst)
				for c_tid, cct in cc.threads.items(): # can be skipped here due to manual [renames]
					dst_thread = self.op_ev_chans_thread_name(c_tid, cct.names.base, dst)
					if not irc_name_eq(src_thread := cct.names.old or cct.name, dst_thread):
						yield cct, src_thread
				yield cc, src_old
			if not irc_name_eq(src, dst):
				self.log.debug('Channel name dedup-update{}: {!r} -> {!r}', thread_info, src, dst)
				for c_tid, cct in cc.threads.items():
					dst_thread = self.op_ev_chans_thread_name(c_tid, cct.names.raw, dst)
					if not irc_name_eq(cct.name, dst_thread): cct.name = cct.names.old = dst_thread
					names[cct.id] = None # skips dup rename msg if cct is also in chans
				cc.name = cc.names.old = dst

	def op_ev_chans_thread_name(self, c_tid, name, name_chan):
		topic, name = f'[thread {c_tid}] {name}', name[:self.conf.irc_thread_chan_name_len]
		if name: name = f'.{name}'
		return f'{name_chan}.{c_tid}{name}'

	def op_ev_chans_priv_name(self, user_names):
		'Makes channel name of limited length from possibly-truncated usernames'
		n = max_len_chan = self.conf.irc_private_chat_name_len
		min_len_user = self.conf.irc_private_chat_name_user_min_len
		while True:
			name = '+'.join(sorted(name.replace('+', '')[:n] for name in user_names))
			if n <= min_len_user or len(name) <= max_len_chan: return name[:max_len_chan]
			n -= 1

	def op_ev_recipient(self, m, act):
		# Sometimes this gets sent instead of c_msg_type.recipient_add, didn't check why
		# No guild_id passed here (always "me"), only user + channel_id
		cc, u = self.st.guilds[1].chans.get(m.channel_id), m.get('user', dict())
		if u: self.discord.cmd_user_cache(1, u.id, u.username)
		self.discord.cmd_msg_recv(cc, u.get('username'), f'--- recipient {act.lower()}')

	def op_ev_ban(self, m, act):
		name = m.get('user', dict())
		name = name.get('username') or f'@{name.get("id", "???")}'
		self.discord.cmd_guild_event(m.guild_id, f'Guild user ban: {act} {name}')

	def op_ev_rel(self, m, act):
		t, name = m.get('type'), m.get('user')
		try: t = self.c_rel_type(t).name
		except ValueError: t = f'unknown[{t}]'
		if name: name = name.username
		uid, name = m.get('id', '?'), '' if not name else f' name={name}'
		self.discord.cmd_guild_event(1, f'Relationship: [uid={uid}{name}] {act} {t}')

	def op_ev_sched(self, m, act, _sts={2: 'started', 3: 'ended'}):
		ev_st, ev_hash = m.get('status'), str_hash(m.id, 4)
		ts0, ts1 = (m.get(f'scheduled_{k}_time') for k in ['start', 'end'])
		ev_info = [m.get('name')]
		if ts0:
			ts0, ts1 = ((v and parse_iso8601(v)) for v in [ts0, ts1])
			ts_ext, ts_ext_span = f'{ts_iso8601(ts0, human=True)}', list()
			if ts1: ts_ext += f' - {ts_iso8601(ts1, human=True, strip_date=ts0)}'
			if abs(ts0 - time.time()) > 30: ts_ext_span.append(repr_duration(ts0, time.time()))
			if ts1: ts_ext_span.append(f'lasts {repr_duration(ts1, ts0, ext=False)}')
			if ts_ext_span: ts_ext += f' [{", ".join(ts_ext_span)}]'
			ev_info.append(ts_ext)
		ev_info.extend([m.get('description', ''), ' '.join(
			f'{k}=[ {v} ]' for k,v in (m.get('entity_metadata') or dict()).items() if v )])
		ev_info = ' :: '.join(filter(None, ev_info))
		if not ev_info: # dunno how to translate it, so print a kind of debug info
			ev_info = f'type={m.get("entity_type", "?")} status={m.get("status", "?")}'
		if act == 'update' and ev_st in _sts: act = f' {_sts[ev_st]}'
		else: act = f' {act}' if act != 'create' else ''
		self.discord.cmd_guild_event( m.guild_id,
			f'Scheduled event {act}: {ev_info}', ev_hash )

	def op_msg(self, m, act, cc=None, tid_prefix=''):
		gg = self.st.guilds.get(m.get('guild_id', 1))
		if not cc:
			if gg: cc = gg.chans.get(m.channel_id)
			if not cc:
				return self.log.warning( 'Dropped msg event with unknown guild/channel'
					' id: msg_id={} guild_id={} channel_id={}', m.id, m.get('guild_id'), m.channel_id )
		flake, author = None, m.get('author')
		msg_is_update = act == 'update'

		if cc.tid and self.conf.discord_thread_msgs_in_parent_chan:
			prefix = not self.conf.discord_thread_msgs_in_parent_chan_full_prefix
			prefix = cc.tid if prefix else self.discord.irc_chan_name(cc)
			self.op_msg(m, act, cc=cc.parent, tid_prefix=f'{tid_prefix}{prefix} :: ')
		if act == 'delete':
			msg_ts = ts_iso8601(self.discord.flake_parse(m.id), human=True)
			return self.discord.cmd_msg_recv( cc,
				None, f'--- message was deleted: {msg_ts} [{m.id}]' )
		if not m.get('content') and m.get('call'):
			m.content = f'{self.conf.irc_prefix_call} --- discord audio/video call notification ---'

		media_info = None
		if not author:
			# No-author update msgs are embed-annotations or status updates for earlier ones
			# E.g. YT video or twitter link info follow-up after posted link, call status update
			media_info = ( msg_is_update and
				self.conf.discord_embed_info and self.op_msg_media_info(m) )
			if media_info: # will be processed into msg below
				act, m.type = 'media', m.get('type', self.c_msg_type.media_info)
			elif msg_is_update and not set(m.keys()).difference([
				'id', 'flags', 'embeds', 'channel_id', 'guild_id', 'call' ]): return
			else: return self.log.warning('Unhandled no-author msg type: {} [{}]', self._repr(m), act)
		else: self.discord.cmd_user_cache(gg.id, author.id, author.username)

		line, tags = self.op_msg_parse(m, gg, is_update=msg_is_update)
		if not (line or media_info): return # joins/parts/pins events and such
		if msg_is_update: tags['_prefix'] = tags.get('_prefix', '') + self.conf.irc_prefix_edit
		else:
			flake, ref = m.id, m.get('referenced_message')
			if self.conf.irc_inline_reply_quote_len > 0 and ref:
				ref_user = ref.get('author', dict()).get('username')
				ref_user = f'<{ref_user}>' if ref_user else ''
				ref_line, ref_tags = self.op_msg_parse(ref, gg)
				ref = str_cut(ref_line, self.conf.irc_inline_reply_quote_len)
				if ref_user and ref:
					line = f'-- re:{ref_user} {ref}\n{line.strip()}'
					tags.update((k,v) for k,v in ref_tags.items() if not k.startswith('_'))

		if self.conf.discord_embed_info:
			if act != 'media':
				if re.search(r'\b(https?://|(www\.|m\.)?youtube\.|youtu\.be)\b', line):
					while len(self.st.embed_info) >= self.conf.discord_embed_info_buffer:
						del self.st.embed_info[next(iter(self.st.embed_info.keys()))]
					self.st.embed_info[m.id] = author
				if not media_info: media_info = self.op_msg_media_info(m)
			if media_info:
				(author, em_lines), n = media_info, self.conf.discord_embed_info_len
				tags['_prefix'] = ( tags.get('_prefix', '') +
					self.conf.irc_prefix_embed.format(str_hash(m.id, 3)) )
				line = '\n'.join([line, *(str_cut(line, n) for line in em_lines)]).strip()
			if not (author and line): return

		if tid_prefix: tags['_prefix'] = tags.get('_prefix', '') + tid_prefix
		skip_mirrored_msg_in_monitor = (
			tid_prefix and not self.conf.discord_thread_msgs_in_parent_chan_monitor )
		self.discord.cmd_msg_recv(
			cc, author.username, line.strip(), tags, flake=flake,
			nonce=m.get('nonce'), skip_monitor=skip_mirrored_msg_in_monitor )

	def op_msg_media_info(self, m):
		lines, author, embeds = list(), self.st.embed_info.get(m.id), m.get('embeds')
		if not (author and embeds): return
		for n, em in enumerate(embeds):
			emt, title, desc = (em.get(k, '') for k in ['type', 'title', 'description'])
			em_src = em.get('author', dict()).get('name')
			if emt == 'rich': # there are many types of these
				if em.get('footer', dict()).get('text') == 'Twitter':
					media = dict()
					for mt in 'image', 'video':
						url = em.get(mt)
						if url: url = url.get('url') or url.get('proxy_url')
						if url: media[mt] = url
					if em_src:
						if media and re.search(r'^\s*https://t\.co/\S+\s*$', desc):
							for mt, url in media.items():
								lines.append(f'Twitter {mt} :: {em_src} :: {url}')
							media.clear()
						else: lines.append(f'Twitter msg :: {em_src} :: {desc}')
					elif title: lines.append(f'Twitter acc :: {title} [ {desc} ]')
					for mt, url in media.items(): lines.append(f' {mt} :: {url}')
				elif not {'video', 'thumbnail', 'provider'}.difference(em.keys()): emt = 'video'
			if emt == 'video':
				host = em.get('provider', dict()).get('name')
				info = ' :: '.join(filter(None, ( ['-clip-', title, desc]
					if host == 'YouTube' and not em_src else [em_src, title or desc] )))
				if info:
					host = '' if not host else f' ({host})'
					lines.append(f'Video{host} :: {info}')
		if not lines: return
		return author, list(re.sub(r'\s*\n+\s*', ' // ', line) for line in lines)

	def op_msg_parse(self, m, gg, is_update=None):
		# Must produce non-empty message for any relevant msg type
		# Most message types are protocol notifications that have their "content" discarded
		# Also used for parsing history query responses, not just events
		tags, mt, mt_nx, line = dict(), m.get('type', ''), False, (m.get('content') or '').strip()
		line, mtc = line.replace('\u200b', ''), self.c_msg_type # unicode zero-width-space junk
		try:
			if not mt and m.get('author', dict()).get('bot'):
				if m.get('embeds'): mt = mtc.bot_embeds
				elif is_update and not line: mt = mtc.bot_empty # updates from bots clearing embeds?
			else:
				mt = mtc(int(mt))
				if mt not in [mtc.default, mtc.reply]: line = ''
		except ValueError: # new unknown msg type - check how to handle
			self.log.warning('Unhandled msg type [{!r}]: {}', mt, self._repr(m))
			if line: tags['_prefix'] = f'[msg-type={mt!r}] '
			mt_nx = True
		if line: tags.update(self.op_msg_parse_tags(line, m, gg.chans, gg.get('roles')))

		if mt == mtc.bot_embeds:
			for n, em in enumerate(m.embeds):
				pre = self.conf.irc_prefix_embed.format(tuple_hash((m.id, n), 3))
				if em.get('author'):
					info = ' '.join(em.author.get(k, '') for k in ['name', 'url']).strip()
					if info: line += f'\n{pre}[author] {info}'
				if em.get('title'): line += f'\n{pre}-- {em.title.strip()} --'
				if em.get('description'):
					info = (dl.strip() for dl in em.description.splitlines())
					for info in filter(None, info): line += f'\n{pre}{info}'

		for att in m.get('attachments') or list():
			url = att.get('url')
			if not url:
				self.log.warning('Unhandled msg attachment type: {}', self._repr(att))
				continue
			line += f'\n{self.conf.irc_prefix_attachment}{url}'
		for stk in m.get('stickers') or m.get('sticker_items') or list():
			name, desc = stk.get('name'), stk.get('description')
			line += f'\n{self.conf.irc_prefix_sticker}{name}'
			if desc: line += f' ({desc})'
		uis = m.get('components')
		if uis:
			def _tags(uis, types=dict(enumerate(['row', 'btn', 'menu'], 1))):
				line = list()
				for ui in uis:
					tag = types.get(ui.type, f'ui{ui.type}')
					if ui.get('label'): tag += f' {repr(ui.label)}'
					if ui.get('placeholder'): tag += f' {repr(ui.placeholder)}'
					if ui.get('emoji'): tag += f' :{ui.emoji.name}:'
					if ui.get('url'): tag += f' url={ui.url}'
					if ui.get('options'):
						for opt in ui.options:
							tag += ' ({})'.format(
								opt.get('label', '').replace(' ', '_')
								or f":{opt.get('emoji', dict()).get('name', 'x')}:" )
					line.append(
						f'<{tag}/>' if not ui.get('components') else
						f'<{tag}>' + _tags(ui.components) + f'</{tag}>' )
				return ' '.join(line)
			line += f'\n{self.conf.irc_prefix_uis}' + _tags(m.components)

		if not mt_nx and not line: # represent some non-text/embed events
			if mt == mtc.recipient_add: line = f'[+recipient]'
			elif mt == mtc.recipient_remove: line = f'[-recipient]'
			elif mt == mtc.channel_name_change: line = f'[channel renamed]'
			elif mt == mtc.default:
				self.log.warning( 'Discarded basic text-msg'
					' without contents (processing bug?): {}', self._repr(m) )
		return line.strip(), tags

	def op_msg_parse_tags(self, line, m, chans, roles):
		'Returns string-replacement pairs for discord tags detected within message'
		# https://discord.com/developers/docs/reference#message-formatting
		tags, tags_raw = dict(), list(re.finditer(r'<(@!?|#|a?:[^:]+:|@&|t:)(\d+)(:\w)?>', line))
		m_chans = dict((c.id, f'#{c.name}') for c in (m.get('mention_channels') or list()))
		if tags_raw:
			mt, ts_fmts = self.c_msg_tags, self.c_msg_tags_ts_fmts
			users = dict((m.id, m.username) for m in force_list(m.get('mentions')))
			for m in tags_raw:
				k_src, t, k = m.group(0), m.group(1), m.group(2)
				if t in ['@', '@!']: v = mt.user, users.get(k, f'[{k}]')
				elif t == '@&':
					v = mt.role, ( f'[{roles[k].name}]'
						if roles and k in roles else '[role:{}]'.format(str_hash(k, 3)) )
				elif t == '#': v = mt.chan, chans[k].did if k in chans else (m_chans.get(k) or f'#[{k}]')
				elif t.lstrip('a').startswith(':'): v = mt.emo, t.lstrip('a')
				elif t == 't:':
					ts, ts_fmt = float(k), ts_fmts.get(m.group(3)) or ts_fmts['f']
					v = mt.ts, '[' + str( ts_fmt(ts) if callable(ts_fmt)
						else dt.datetime.fromtimestamp(ts).strftime(ts_fmt) ) + ']'
				else: v = mt.other, f'{t}{k}'
				tags[k_src] = v
		return tags

	def op_react(self, m, act):
		if self.conf.irc_disable_reactions: return
		cc, gg = None, self.st.guilds.get(m.get('guild_id', 1))
		if gg: cc = gg.chans.get(m.channel_id)
		if not cc: return
		try: # emo_id seem to be only passed if it's not unicode emoji
			emo_id, emo = m.emoji.get('id'), m.emoji.get('name') or ''
			if emo_id and len(emo) != 1: emo = f'[{emo_id}]' if not emo else f':{emo}:'
		except KeyError: emo = ''
		if act == 'remove_all': act, emo = '', f'-all'
		if emo:
			if act == 'add': act, emo = '', f'+{emo}'
			elif act == 'remove': act, emo = '', f'-{emo}'
			else: emo = f' {emo}'
		msg_ts = ts_iso8601(self.discord.flake_parse(m.message_id), human=True)
		try: u = m.member.user
		except: nick = None # notice from irc_nick_sys
		else:
			self.discord.cmd_user_cache(gg.id, u.id, u.username)
			nick = u.username
		self.discord.cmd_msg_recv(cc, nick, f'--- reacts [{msg_ts}]: {act}{emo}')

	def op_gift_code(self, m):
		cc, gg = None, self.st.guilds.get(m.get('guild_id', 1))
		if gg: cc = gg.chans.get(m.channel_id)
		if not cc: return
		self.discord.cmd_msg_recv( cc, None,
			f'--- gift-code: uses={m.uses} sku={m.sku_id} code={m.code}' )



class RDIRCDError(Exception): pass

class RDIRCD:

	class c:
		chan_sys_types = 'control', 'debug'
		chan_sys_control_topic = 'rdircd: control channel, type "help" for more info'
		chan_sys_debug_topic = 'rdircd: debug logging channel, type "help" for more info'
		chan_monitor_topic = ( 'rdircd: read-only'
			' catch-all channel with messages from everywhere' )
		chan_monitor_topic_guild_tpl = 'rdircd: read-only catch-all channel for discord [ {} ]'
		chan_leftover_topic = ( 'rdircd: read-only channel for any'
			' discord messages in channels that IRC client is not connected to' )
		chan_leftover_topic_guild_tpl = (
			'rdircd: read-only msgs for non-joined channels of discord [ {} ]' )

	c_chan_type = enum.Enum('c_chan_type', 'sys mon nc proxy')

	def __init__(self, conf):
		self.conf, self.log = conf, get_logger('rdircd.bridge')
		if ' ' in (self.conf.irc_nick_sys or ' '):
			raise RDIRCDError('Invalid irc-nick-sys value: {self.conf.irc_nick_sys!r}')
		self._repr = ft.partial(str_cut, max_len=self.conf.debug_msg_cut, repr_fmt=True)

	async def __aenter__(self):
		self.server_ver = self.conf.version
		self.server_ts = dt.datetime.utcnow()
		self.server_host = os.uname().nodename
		self.irc_conns, self.irc_conns_max = adict(), 0
		self.irc_auth_tbf = token_bucket(self.conf.irc_auth_tbf)
		self.irc_names_timeout = parse_duration(self.conf.irc_names_timeout)
		self.irc_msg_queue = asyncio.Queue()
		self.irc_chans_sys = adict() # populated when building channel map
		self.tasks = StacklessContext(self.log)
		self.cache = adict( chan_map=None, uid=dict(),
			irc_subs_gen=0, irc_subs=irc_name_dict(), # replacements in irc msgs
			# Xid_chan - lookup caches for monitor/leftover/discord irc chan-names for that id
			# None is used as an id for global monitor/leftover channel
			gid_mon_chan=dict(), gid_nc_chan=dict(), did_chan=dict(),
			d2i=irc_name_dict(), i2d=irc_name_dict() ) # for all irc <-> discord names
		self.uid_len, self.uid_seed = (self.conf.get(f'irc_uid_{k}') for k in ['len', 'seed'])
		if not self.uid_seed:
			for k in '/etc/machine-id', '/var/lib/dbus/machine-id':
				try: self.uid_seed = str_hash(pl.Path(k).read_text().strip())
				except OSError: continue
			else: self.uid_seed = f'rdircd.{self.server_host}'
		boot_id = pl.Path('/proc/sys/kernel/random/boot_id').read_text().strip()
		self.uid_start = '.'.join( str_hash(v, c)
			for c, v in zip([3, 3, 6], [self.uid_seed, boot_id, os.urandom(6)]) )
		self.cmd_delay(self.irc_msg_queue_proc)
		return self

	async def __aexit__(self, *err):
		if self.irc_msg_queue: self.irc_msg_queue.put_nowait(StopIteration)
		if self.tasks: await self.tasks.close()

	async def segfault_after_delay(self, delay):
		'Folks keep mentioning on IRC that daemon is not crashing, this should fix it'
		import ctypes
		await asyncio.sleep(delay)
		ctypes.string_at(0) # should reliably crash the process

	def uid( self, t, v, kh=None,
			hash_len=None, alias_key='{kh}', alias_default=None ):
		'''Return unique id (kh/key-hash) for specific object type-key and value.
			That will be either short hash or a user-assigned alias.
			kh can specify pre-defined hash value to only lookup alias for that.
			alias_key/default is to query other aliases associated with same kh.'''
		ck = t, v, alias_key
		if ck not in self.cache.uid:
			if kh is None:
				if not (kh := self.cache.uid.get(tv := f'{t}\0{v}')):
					kh = self.cache.uid[tv] = str_hash(
						tv, hash_len or self.uid_len, self.uid_seed )
					if kh in self.cache.uid: # raise irc uid-len if this happens
						raise ValueError( f'Unique-id hash collision [ {t}={v} ]: key={kh}'
							f' len/seed=[ {self.uid_len} {self.uid_seed} ] other={self.cache.uid[kh]}' )
					self.cache.uid[kh] = ck
			self.cache.uid[ck] = self.conf.renames.get(
				(t, str_norm(alias_key.format(kh=kh))), alias_default or kh )
		return self.cache.uid[ck]

	async def run(self):
		loop = asyncio.get_running_loop()
		ircd = await loop.create_server(
			IRCProtocol.factory_for_bridge(self),
			self.conf.irc_host, self.conf.irc_port,
			family=self.conf.irc_host_af, start_serving=False, ssl=self.conf.irc_tls )

		if self.conf.debug_mean_minutes_to_segfault > 0:
			mins_to_segfault = int( 1 + random.random()
				* self.conf.debug_mean_minutes_to_segfault * 2 )
			self.log.debug('Priming segfault timer ({:,d} min)...', mins_to_segfault)
			self.tasks.add(self.segfault_after_delay(mins_to_segfault * 60))

		self.log.debug('Initializing discord...')
		try:
			async with Discord(self) as discord:
				self.discord = discord
				self.log.debug('Starting ircd...')
				ircd_task = self.tasks.add(ircd.serve_forever())
				if self.conf.discord_auto_connect:
					self.log.debug('Auto-connecting discord...')
					loop.call_soon(discord.connect)
				else: self.log.debug('Note: discord session auto-connect disabled')
				await ircd_task
		except DiscordAbort as err:
			self.log.error('Discord init failure - {}', err_fmt(err))
		self.log.debug('Finished')

	async def run_async(self):
		async with self: await self.run()


	def irc_conn_new(self, irc):
		self.irc_conns[id(irc)] = irc
		self.irc_conns_max = max(self.irc_conns_max, len(self.irc_conns))
	def irc_conn_lost(self, irc): self.irc_conns.pop(id(irc), None)
	def irc_conn_stats(self):
		stats = adict(
			servers=len(self.discord.st.get('guilds', dict())) or 1,
			chans=len(self.cmd_chan_map()),
			total=0, total_max=self.irc_conns_max, unknown=0, auth=0, op=0 )
		for conn in self.irc_conns.values():
			stats.total += 1
			if conn.st.auth: stats.auth += 1
			else: stats.unknown += 1
		return stats
	def irc_conn_names(self):
		for conn in self.irc_conns.values():
			if conn.st.auth: yield conn.st.nick

	def irc_name(self, name, casefold=False, _irc_remap=dict([
			*((n, f'°{n:02d}') for n in range(32)),
			*((ord(a), b) for a, b in zip(' ,:@!+<>', '·„¦∂¡×◄►')) ])):
		'Return IRC name for a Discord name'
		# Must be deterministic but ideally not create collisions by stripping too much
		# General idea is to replace all irc-problematic chars by lookalike unicode
		if name not in self.cache.d2i:
			name_irc, sub_chars = '', '°×·„∂¦◄►'
			name_clean = re.sub(rf'[{sub_chars}]', '', name)
			if name_clean != name: name = name_clean + '×' + self.uid('name', name)
			name_irc = name.translate(_irc_remap)
			self.cache.d2i[name], self.cache.i2d[name_irc] = name_irc, name
		name = self.cache.d2i[name]
		if casefold: name = irc_casefold_rfc1459(name)
		return name

	def irc_name_revert(self, name_irc):
		'Return Discord name for an IRC name'
		# Relies on a i2d cache being set by earlier irc_name() call for this name
		# Which is done for every channel/user, notably in cmd_chan_map
		return self.cache.i2d.get(name_irc)

	def irc_discord_info(self, name):
		'Return info{gg, cc} object for IRC name for discord channel or None'
		if not (c := self.cmd_chan_map().get(name)): return
		if not (cc := c.get('cc')): return # system and monitor channels
		return adict(cc=cc, gg=cc.gg)

	async def irc_topic_cmd(self, conn, name, line=''):
		chan = conn.chan_spec(name)
		notice_cmd = ft.partial(
			conn.cmd_msg_chan, self.conf.irc_nick_sys, chan, notice=True )
		cmd = line.split(None, 1)
		if not line.strip() or cmd[0] in ['h', 'help']:
			return notice_cmd('\n'.join([ '--- Topic-commands:',
				'  set {topic...} - set topic, as usual irc /topic command would do.',
				'  info - show some internal guild/channel information, like IDs and such for renames.',
				'  log [state] - replay history since "state" point (default: last rdircd stop).',
				'    "state" value can be either a number, state-id, relative or iso8601 timestamp.',
				'    Where number indicates last Nth state recorded in the config (0 - current).',
				'    E.g. "log 1" (same as just "log") will replay messages in the channel,',
				'     starting from last ev before last rdircd shutdown (saved under [state] in ini).',
				'    Timestamp examples: 2019-01-05T2:00, 2019-01-08 12:30:00, 2h, 1d 5h 30m, 1mo5d.',
				'    Relative timespan units: y/yr/year, mo/month,',
				'      w/week, d/day, h/hr/hour, m/min/minute, s/sec/second.',
				'  log list - list recorded state ids/timestamps, most recent one last.', '---' ]) )
		try:
			if cmd[0] == 'set':
				raise IRCBridgeSignal('Changing topic is not implemented')
			elif cmd[0] == 'info':
				if not (info := self.irc_discord_info(name)):
					raise IRCBridgeSignal(f'Not a discord channel: {chan}')
				notice_cmd('--- Protocol information on this guild/channel:')
				notice_cmd('Guild:')
				notice_cmd(f'  id: {info.gg.id}')
				notice_cmd(f'  name: {info.gg.name}')
				notice_cmd(f'  joined-at: {ts_iso8601(info.gg.ts_joined)}')
				notice_cmd(f'  existing roles [ {len(info.gg.roles):,d} ]:')
				for role in info.gg.roles.values():
					notice_cmd(f'    id={role.id} name={role.name}')
				notice_cmd('Channel:')
				notice_cmd(f'  id: {info.cc.id}')
				notice_cmd(f'  name on discord: {info.cc.names.raw or ""}')
				notice_cmd(f'  name/alias without irc encoding: {info.cc.name}')
				notice_cmd(f'  name/alias encoded for irc: {self.irc_name(info.cc.name)}')
				notice_cmd(f'  topic: {info.cc.topic or ""}')
				notice_cmd(f'  type: {info.cc.t.name} [{info.cc.t.value}]')
				notice_cmd(f'--- end of info')
			elif cmd[0] == 'log':
				state = ('1' if len(cmd) == 1 else cmd[1]) if len(cmd) <= 2 else None
				state_list = sorted((v,k) for k,v in self.conf.state.items())
				if state == 'list':
					if not state_list: notice_cmd('No state timestamps recorded yet.')
					else:
						notice_cmd('Recorded state timestamps:')
						for n, (v, k) in enumerate(state_list):
							n = len(state_list) - n - 1
							notice_cmd(f'  [{n}] {k} = {ts_iso8601(v)}')
					return
				ts = None
				if state.isdigit():
					n = -1 - int(state)
					if not self.conf.state_get(self.uid_start): n += 1
					if not n: return
					try: ts, k = state_list[n]
					except IndexError: raise IRCBridgeSignal(f'No state with index {state}')
				elif state in self.conf.state:
					try: ts = self.conf.state[state]
					except KeyError: raise IRCBridgeSignal(f'No state {state!r}')
				else:
					try: ts = parse_iso8601(state)
					except ValueError:
						try: ts = parse_duration(state)
						except ValueError: pass
						else: ts = time.time() - ts
				if ts:
					if not (info := self.irc_discord_info(name)):
						raise IRCBridgeSignal(f'Not a discord channel: {chan}')
					notice_cmd(f'--- Replaying new messages since {ts_iso8601(ts)}')
					msg_list = await self.discord.cmd_history(info.gg, info.cc, ts)
					if not msg_list: return notice_cmd('--- no new messages')
					for m in msg_list:
						line = f'[{ts_iso8601(m.ts, human=True)}] {m.line}'
						self.cmd_msg_discord(info.cc, m.nick, line, tags=m.tags, conn=conn)
					return notice_cmd(f'--- end of replay [{len(msg_list)}]')
				raise IRCBridgeSignal(f'Invalid log-cmd parameters: {line}')
			else: raise IRCBridgeSignal(f'Unrecognized channel-topic cmd: {line}')
		except IRCBridgeSignal as err:
			notice_cmd(f'topic-cmd-error: {err}')
			raise

	def irc_msg(self, conn, chan, line):
		'Called with a new msg, posted by IRC user in any channel'
		name = conn.chan_name(chan)
		if sys_type := self.irc_chans_sys.get(name):
			return self.cmd_chan_sys(sys_type, conn, chan, line)
		if not (line := self.irc_msg_translate_preq(line)).strip(): return
		if not self.irc_msg_queue:
			conn.cmd_msg_chan( self.conf.irc_nick_sys,
				chan, f'ERR {{irc-msg-queue-failed}}: {line}', notice=True )
		else: self.irc_msg_queue.put_nowait(
			adict(conn=conn, chan=chan, name=name, line=line) )

	def irc_msg_translate_preq(self, line):
		# "/me msg" -> "\1ACTION msg\1" - https://tools.ietf.org/id/draft-oakley-irc-ctcp-01.html
		return re.sub(r'^\x01ACTION ?(.*)\x01$', r'_\1_', line)

	def irc_msg_translate_postq(self, info, line):
		# Apply configured [replacements], if any
		if not (repls := self.conf.replacements): return line
		if self.cache.irc_subs_gen != repls.ø_gen:
			self.cache.irc_subs_gen = repls.ø_gen
			(subs := self.cache.irc_subs).clear()
			for k, v in repls.items():
				try:
					pre, comm = k.rsplit('.', 1)
					sub_re, sub = v.split(' -> ', 1)
					sub_re = re.compile(sub_re)
					if sub == '<block!>': sub = None
				except Exception as err:
					self.log.warning( 'Failed to parse line in'
						' [replacements] section [ {!r} = {!r} ]: {}', k, v, err_fmt(err) )
					continue
				if pre not in subs: subs[pre] = list()
				subs[pre].append(adict(pre=pre, re=sub_re, sub=sub, comm=comm))
		for s in it.chain( (subs := self.cache.irc_subs).get('*', list()),
				subs.get(self.uid('guild', info.gg.id, kh=info.gg.get('kh')), list()) ):
			if s.sub is not None: line = s.re.sub(s.sub, line)
			elif s.re.search(line): raise IRCBridgeSignal(f'blocked-by-rule[{s.pre}.{s.comm}]')
		return line

	async def irc_msg_queue_proc(self):
		'''Process msgs from IRC in strict order from irc_msg_queue.
			If any of them fails to be confirmed-delivered,
				discards all msgs after it too, signaling errors about each.
			Idea is that it's better to make user re-send messages
				than to deliver only some of them, and/or doing it out of order.
			Special edit/delete message-commands are processed here as well.'''
		try:
			while True:
				m = await self.irc_msg_queue.get()
				if m is StopIteration: break

				try:
					if not (info := self.irc_discord_info(m.name)):
						raise IRCBridgeSignal('no-matching-chan')
					if edit := self.conf._discord_msg_edit_re.search(m.line):
						await self.discord.cmd_msg_edit_last(
							info.cc, edit.group('aaa'), edit.group('bbb') )
					elif self.conf._discord_msg_del_re.search(m.line):
						await self.discord.cmd_msg_del_last(info.cc)
					else:
						line = self.irc_msg_translate_postq(info, m.line)
						await self.discord.cmd_msg_send(info.cc, line)

				except IRCBridgeSignal as err:
					m.conn.cmd_msg_chan( self.conf.irc_nick_sys,
						m.chan, f'ERR {{{err}}}: {self._repr(m.line)}', notice=True )
					while True: # flush queue to ensure ordering
						try: m = self.irc_msg_queue.get_nowait()
						except asyncio.QueueEmpty: break
						if m is StopIteration: break
						m.conn.cmd_msg_chan( self.conf.irc_nick_sys,
							m.chan, f'ERR-flush {{{err}}}: {self._repr(m.line)}', notice=True )
					if m is StopIteration: break

		finally: self.irc_msg_queue = None


	def cmd_delay(self, delay, func=None):
		if func is None: delay, func = 0, delay
		if delay and not isinstance(delay, (int, float)):
			if delay == 'irc_auth': delay = next(self.irc_auth_tbf)
			else: raise ValueError(delay)
		if delay: self.tasks.add(asyncio.sleep(delay), func)
		else: self.tasks.add(aio_await_wrap(func))

	@iter_gather(irc_name_dict)
	def cmd_conn_map(self):
		for conn in self.irc_conns.values():
			if conn.st.nick: yield (conn.st.nick, conn)

	def cmd_conn(self, name=None):
		'Get IRC connection for sending a direct message'
		try:
			if not name: return next(iter(self.irc_conns.values()))
			return self.cmd_conn_map()[name]
		except (KeyError, StopIteration): return

	@iter_gather(list)
	def cmd_chan_conns(self, name_or_cc):
		'Get list of irc connections for users in a channel'
		if isinstance(name_or_cc, str): name = name_or_cc
		elif not (name := self.cache.did_chan.get(name_or_cc.did)): return
		for conn in self.irc_conns.values():
			chan_name = conn.chan_name(name)
			if chan_name not in conn.st.chans:
				if not self.conf._irc_chan_auto_join_re.search(chan_name): continue
				if not conn.st.auth: continue
			yield conn

	@iter_gather(list)
	def cmd_chan_names_discord(self, name):
		'''List nicks from discord, in most recently
			active first order, doing auto-cleanup on these.'''
		if not (info := self.irc_discord_info(name)): return
		users, ts_cutoff = info.cc.get('users', dict()), time.time() - self.irc_names_timeout
		for u in sorted(users.values(), key=op.itemgetter('ts'), reverse=True):
			if u.ts < ts_cutoff: users.pop(u.name, None)
			else: yield u.name, self.irc_name(u.name)

	@iter_gather(list)
	def cmd_chan_names(self, name):
		'Returns persistent mutable list of users (/names) for specified channel.'
		conn_nicks = irc_name_dict.value_map(
			conn.st.nick for conn in self.cmd_chan_conns(name) if conn.st.nick )
		yield from sorted(conn_nicks.values())
		for nick_discord, nick_irc in self.cmd_chan_names_discord(name):
			if nick_irc not in conn_nicks: yield nick_irc


	def cmd_chan_map(self):
		'''Returns cached adict of {chan-name: {name, topic, did, ts_created}},
			updating misc minor .cache.* maps used elsewhere in the process.'''
		if cm0 := self.cache.chan_map: cm = cm0
		else:
			cache_drop = ft.partial(self.cache.update, chan_map=None)
			cache_track = lambda d, keys=None: d._track(keys, cb=cache_drop)
			cm_list = list(self._cmd_chan_map_build(cache_track))
			cm = self.cache.chan_map = irc_name_dict(cm_list)
			if len(cm) < len(cm_list):
				cm_repeats = ' '.join( k for k, n in
					cs.Counter(k for k, v in cm_list).items() if n > 1 )
				raise RDIRCDError( 'Duplicate channel-name(s) produced'
					f' by guild/channel naming (mis-)configuration: {cm_repeats}' )
		cm.ø_online = self.discord.online
		if not cm0 and cm.ø_online: self.cmd_chan_map_sync(cm)
		return cm

	def cmd_chan_map_sync(self, cm=None):
		'Sends IRC topic updates, marks channels as working/removed'
		if not cm: cm = self.cmd_chan_map()
		for conn in self.irc_conns.values(): conn.cmd_chan_list_sync(cm)

	def _cmd_chan_map_build(self, cache_track):
		# Name-keys returned here are used in irc_name_dict, so don't need casemapping
		cache_track(self.discord.st.guilds)
		gg_uid = lambda gg,**kws: self.uid('guild', gg.id, kh=gg.get('kh'), **kws)
		gg_info = list(
			adict( gg=gg, prefix=gg_uid(gg),
				name_fmt=gg_uid(gg, alias_key='{kh}.chan-fmt', alias_default='{prefix}.{name}') )
			for gg in sorted(self.discord.st.guilds.values(), key=op.itemgetter('ts_joined')) )
		sys_chan_ts = self.server_ts.timestamp()

		# System debug/control channels
		self.irc_chans_sys.clear()
		for sys_type in self.c.chan_sys_types:
			name = self.conf.irc_chan_sys.format(type=sys_type)
			self.irc_chans_sys.update({name: sys_type, f'ø_{sys_type}': name})
			yield (name, adict( t=self.c_chan_type.sys,
				name=name, ts_created=self.server_ts.timestamp(),
				topic=getattr(self.c, f'chan_sys_{sys_type}_topic') ))

		# Order monitor/leftover channels at the top
		def _agg_chan(k, gi=None, _kc=dict(mon='monitor', nc='leftover')):
			kc = _kc[k]
			name = ( getattr(self.conf, f'irc_chan_{kc}') if not gi else
				getattr(self.conf, f'irc_chan_{kc}_guild').format(prefix=gi.prefix) )
			getattr(self.cache, f'gid_{k}_chan')[gi and gi.gg.id] = name
			if not name: return
			topic = ( getattr(self.c, f'chan_{kc}_topic') if not gi else
				getattr(self.c, f'chan_{kc}_topic_guild_tpl').format(gi.gg.name) )
			yield (name, adict( name=name, topic=topic,
				t=getattr(self.c_chan_type, k), ts_created=sys_chan_ts ))
		yield from _agg_chan('mon')
		yield from _agg_chan('nc')
		for gi in gg_info:
			yield from _agg_chan('mon', gi)
			yield from _agg_chan('nc', gi)

		# Normal channels for each discord-guild
		for gi in gg_info:
			cache_track(gi.gg, 'name chans ts_joined')
			for cc in sorted( gi.gg.chans.values(),
					key=lambda cc: (cc.get('pos') or 999, cc.name) ):
				cache_track(cc, 'name topic pos nsfw')
				tags, name = '', gi.name_fmt.format(
					prefix=gi.prefix, name=self.irc_name(cc.name) )
				if cc.nsfw: tags += '[nsfw] '
				if cc.t is cc.t.voice: tags += '[voice] '
				elif cc.t is cc.t.forum: tags += '[forum] '
				topic = ' // '.join(filter(None, map(str.strip, (cc.topic or '').splitlines())))
				if not topic: topic = f'<no-topic> {cc.name}'
				topic = f'{gi.gg.name.strip()}: {tags}{topic}'.strip()
				self.cache.did_chan[cc.did] = name
				yield (name, adict(
					t=self.c_chan_type.proxy, cc=cc, did=cc.did,
					name=name, topic=topic, ts_created=gi.gg.ts_joined ))

	def cmd_chan_sys(self, sys_type, conn, chan, line):
		if sys_type not in self.c.chan_sys_types: return
		getattr(self, f'cmd_chan_sys_{sys_type}')(conn, chan, line)

	def cmd_chan_sys_log_counts(self):
		counts = self.conf._debug_counts
		counts = (' '.join( '{}={:,d}'.format(k, counts[k]) for n, k in
			sorted(((n, k.lower()) for n, k in logging._levelToName.items()), reverse=True)
			if counts[k] > 0 ) + f' all={counts["all"]:,d}').strip()
		return counts

	def cmd_chan_sys_control(self, conn, chan, line_raw):
		if not (line := line_raw.strip().lower().split()): return
		cmd, send = line[0], ft.partial(conn.cmd_msg_chan, self.conf.irc_nick_sys, chan)
		if cmd in ['h', 'help']:
			self._cmd_chan_sys_status(send)
			return send('\n'.join([
				'',
				'Commands:',
				'  status - (alias: st) show whether discord is connected and working',
				'  connect - (alias: on) connect/login to discord',
				'  disconnect - (alias: off) disconnect from discord',
				'',
				'  set [-s|--save] [option value] - set config option value,',
					'     saving it to the last ini file if -s/--save is specified.',
					'    Run without args to get full list of options with their current values.',
					'    Not all values changed this way will have an effect without restart.',
				'',
				'  reload - (alias: re) re-read configuration from same ini files as on start.',
					'    Same as with "set" command, some changes don\'t take effect at runtime.',
				'',
				'  unmonitor [-s|--save] [-r|--rm] [ [#]channel ... ] - (alias: um)',
					'     skip specified channel name(s)/prefix(es) in all monitor/leftover channels.',
					'    Adds line to a [filters] ini config section, to skip any msgs',
					'     from specified discord channel, in monitor/leftover channels,',
					'     updating last ini file if -s/--save option is specified',
					'    Channel name should be same as prefix printed there. Leading # is optional.',
					'    Chan-name can have "+threads" suffix to ignore all threads in there, if any.',
					'    Removes channel from last config-file instead with -r/--rm option.',
					'    Run without args to print a list of current filters for these channels.',
				'',
				'  repl [-s|--save] [-r|--rm] [ prefix.comment [ = re -> sub ] ] - (alias: re)',
					'     add/remove regexp-replacements/blocks for outgoing messages.',
					'    Same idea as w/ "unmonitor" above - adds/removes line(s) for [replacements].',
					'    Run without arguments to print current contents of that config section.',
				'',
				'Only immediate response to sent commands is logged here - no noise over time.' ]))
		elif cmd in ['status', 'st']: return self._cmd_chan_sys_status(send)
		try:
			if cmd in ['connect', 'on']:
				send('discord: connection started')
				self.discord.connect()
			elif cmd in ['disconnect', 'off']:
				send('discord: disconnecting')
				self.discord.disconnect()
			elif cmd in ['unmonitor', 'um']: self._cmd_chan_sys_unmonitor(send, line)
			elif cmd in ['repl', 're']: self._cmd_chan_sys_repl(send, line_raw)
			elif cmd == 'set': self._cmd_chan_sys_set(send, line, line_raw)
			elif cmd in ['reload', 're']:
				try: conf_paths = self.conf.read_from_file()
				except Exception as err:
					return send(f'ERROR: failed to reload config files - {err_fmt(err)}')
				send('Reloaded configuration from files (with overrides in that order):')
				for p in conf_paths: send(f'  {p}')
			else: send(f'Unknown command: {cmd}')
		except Exception as err:
			send(f'ERROR: BUG - "{cmd}" command failed: {err_fmt(err)}')
			raise

	def _cmd_chan_sys_status(self, send):
		'Implementation for status display in control-channel'
		st, ts = self.discord.st, time.monotonic()
		sess_st, sess_id = st.get('state', 'none'), st.get('session_id', '')
		sess_time, conn_time = (
			(v and repr_duration(ts, v, ext=None))
			for v in (st.get(k, '') for k in ('session_ts', 'connect_ts')) )
		if sess_st != 'ready': conn_time = ''
		send('\n'.join([ 'Status:',
			f'  discord-state: {sess_st}',
			f'  session-id: {sess_id}',
			f'  session-time: {sess_time}',
			f'  connection-time: {conn_time}',
			f'  connection-gateway: {self.conf.discord_gateway}',
			f'  log-msg-counts: {self.cmd_chan_sys_log_counts()}' ]))

	def _cmd_chan_sys_set(self, send, line, line_raw):
		'Implementation for control-channel "set" command'
		sec_re = re.compile('^({})_(.*)$'.format(
			'|'.join(map(re.escape, self.conf._conf_sections)) ))
		if len(line) == 1:
			send('Current config options/values:')
			val_types = [str, bool, int, float]
			for k in sorted(dir(self.conf)):
				if not sec_re.search(k): continue
				k_ini, v = k.replace('_', '-'), self.conf.get(k, raw=True)
				for vt in val_types:
					if isinstance(v, vt): break
				else: continue
				v = ['no', 'yes'][v] if vt is bool else repr(v)
				if vt is str and k == 'auth' or 'password' in k: v = '<hidden>'
				send(f'  {k_ini} = {v} [{vt.__name__}]')
			send( 'Note: string values must be quoted when setting'
				' them (using python str literal rules), e.g.: set irc-prefix-edit \'[ed] \'' )
			return send( 'Note: not all option changes take effect'
				' without reconnect or full restart, save them to config for latter' )
		conf_save = line[1] in ['-s', '--save']
		line = line_raw.lstrip().split(None, 2 + int(conf_save))[1 + int(conf_save):]
		if not 1 <= len(line) <= 2:
			return send('ERROR: "set" command needs "option [value]" arguments')
		v_conf = None
		try:
			k_ini, v_raw = line if len(line) != 1 else (line[0], '""')
			k, v = k_ini.replace('-', '_'), v_raw
			v_conf, sec = self.conf.get(k, raw=True), sec_re.search(k)
			if not sec: raise KeyError(k)
			vt, (sec, k_sec) = type(v_conf), sec.groups()
			if isinstance(v_conf, str):
				v = ast.literal_eval(v)
				if not isinstance(v, str): raise ValueError(v)
			v = self.conf.get_val_conv_func(v_conf)(v)
			if vt is not type(v): raise TypeError(v)
		except Exception as err:
			vt = f' ({vt.__name__})' if v_conf is not None else ''
			return send(f'ERROR: failed to parse {k_ini} = [{v_raw}]{vt} - {err_fmt(err)}')
		self.conf.set(k, v)
		if conf_save:
			save = self.conf.update_file_section(sec, k_sec)
			save = f' [saved to a file: {save}]'
		else: save = ''
		send(f'Updated conf value: {k_ini} = {v!r}{save}')

	def _cmd_chan_sys_unmonitor(self, send, line):
		'Implementation for control-channel "unmonitor" command'
		upd, chans, opts, rm, save = dict(), line[1:], set(line[1:3]), False, False
		if opts.intersection(['-r', '--rm']): rm, chans = True, chans[1:]
		if opts.intersection(['-s', '--save']): save, chans = True, chans[1:]
		if not chans:
			send(f'Current monitor/leftover channel filters [{len(self.conf.filters)}]:')
			for c in self.conf.filters: send(f'  {c}')
			return
		for c in chans:
			c = c.lstrip('#')
			if rm:
				if c not in self.conf.filters:
					send(f'ERROR: There was no filter for channel [ {c} ]')
				else: upd[c] = None
			elif c in self.conf.filters:
				send(f'ERROR: Filter was/is already set for channel [ {c} ]')
			else: upd[c] = ''
		if not upd: return send('Nothing to update, nothing changed')
		for k, v in upd.items():
			if v is None: del self.conf.filters[k]
			else: self.conf.filters[k] = v
		if not save:
			return send( 'Updated runtime filters (not saved to ini):'
				f' {len(upd)} change(s), {len(self.conf.filters)} total' )
		try: p = self.conf.update_file_section('filters', upd, key_set=True)
		except OSError as err:
			return send(f'ERROR: Failed to update configuration file: {err_fmt(err)}')
		send(
			f'Updated filters in config file [ {p.name} ]:'
			f' {len(upd)} change(s), {len(self.conf.filters)} total' )

	def _cmd_chan_sys_repl(self, send, line_raw):
		'Implementation for control-channel "repl" command'
		repls, rm, save = self.conf.replacements, False, False
		try:
			line = line_raw.split(None, 1)
			if len(line) == 1: line, opts = '', set()
			else: line, opts = line[1], set(line[1].split()[:2])
			if opts.intersection(['-r', '--rm']): rm, line = line.split(None, 1)
			if opts.intersection(['-s', '--save']): save, line = line.split(None, 1)
			if not line.strip(): pre = line = None
			elif '=' not in line: pre, line = line.strip(), '<block!>'
			else: pre, line = line.split('=', 1); pre, line = pre.strip(), line.lstrip()
		except Exception as err:
			return send(f'ERROR: Failed to process re-spec [ {line_raw} ]: {err_fmt(err)}')
		if rm:
			try: del repls[pre]
			except KeyError:
				send(f'ERROR: No matching replacement [ {pre} ]')
				pre = None
		if not pre:
			send(f'Current replacement/block regexps [{len(repls)}]:')
			for pre, line in repls.items(): send(f'  {pre} = {line}')
			return
		if not rm: repls[pre], upd = line, {pre: line}
		else: upd = {pre: None}
		if not save: return send(f'Updated runtime replacement rules [ {pre} ]')
		try: p = self.conf.update_file_section('replacements', upd)
		except OSError as err:
			return send(f'ERROR: Failed to update configuration file: {err_fmt(err)}')
		return send(f'Updated [replacements] section in config file [ {p.name} ]')

	def cmd_chan_sys_debug(self, conn, chan, line):
		line_src, line = line, line.strip().lower().split()
		pt_n, pt_cut = (self.conf.get(f'debug_chan_proto_{k}') for k in ['tail', 'cut'])
		if not line: return
		send = ft.partial(conn.cmd_msg_chan, self.conf.irc_nick_sys, chan)
		if line[0] in ['h', 'help', 'st', 'status']:
			level = proto_log_info = '???'
			proto_log_shared = ['no', 'yes'][bool(log_proto_root.propagate)]
			if self.conf._debug_chan:
				level = logging.getLevelName(self.conf._debug_chan.level).lower()
			if self.conf._debug_proto:
				proto_log_info = logging.getLevelName(self.conf._debug_proto.level).lower()
				proto_log_info = ( 'disabled' if proto_log_info == 'warning'
					else f'enabled, file={self.conf._debug_proto.get_file()}' )
			return send('\n'.join(f'-- {line}' for line in [
				'This channel is for logging output, with level=warning by default,',
				'  unless --debug is specified on command line, or [debug] verbose=yes in ini.',
				f'Status:',
				f'  log level: {level}',
				f'  log msg counts: {self.cmd_chan_sys_log_counts()}',
				f'  protocol log: {proto_log_info}',
				f'  protocol log shared: {proto_log_shared}',
				'Recognized commands here:',
				'  level warning - (alias: w) only dump warnings and errors here',
				'  level info - (alias: i) set level=info (default) logging in this channel',
				'    That mostly adds connection stuff - disconnects, reconnects, auth, etc.',
				'  level debug - (alias: d) enable level=debug logging in this channel',
				'    Includes protocol info (if shared), events, messages and everything else.',
				'  level error, level critical - more quiet than other levels above',
				'  proto {file} - enable irc/discord protocol logging to specified file',
				'  proto off - (alias: px) disable irc/discord protocol logging',
				'  proto share/unshare - (alias: ps/pu)',
				'    whether to dump protocol logging (level=debug) to regular logs',
				f'  proto tail [n] [cut] - (alias: pt) dump "n" (default={pt_n}) tail lines',
				f'    of protocol log file (if enabled), limited to "cut" length (default={pt_cut}).' ]))
		with cl.suppress(KeyError):
			line = dict(
				i='level info', d='level debug', w='level warning',
				px='proto off', ps='proto share', pu='proto unshare', pt='proto tail'
			)[line[0]].split() + line[1:]
		cmd, arg = line[0], line[1] if len(line) >= 2 else None
		if cmd == 'level' and len(line) == 2:
			level = getattr(logging, arg.upper(), None)
			if level is not None and self.conf._debug_chan:
				arg_old = logging.getLevelName(self.conf._debug_chan.level)
				self.conf._debug_chan.setLevel(level)
				send(f'-- logging level: {arg_old.lower()} -> {arg.lower()}')
			else: send(f'-- failed to change logging level: level or logger unavailable')
		elif cmd == 'proto':
			if arg == 'off':
				if self.conf._debug_proto:
					self.conf._debug_proto.setLevel(logging.WARNING)
				else: arg = 'unavailable'
				send(f'-- protocol log: {arg}')
			elif arg in ['share', 'unshare']:
				share = arg == 'share'
				send(f'-- protocol log shared: {str(share).lower()}')
				log_proto_root.propagate = share
			elif arg == 'tail':
				if not self.conf._debug_proto: send(f'-- protocol log disabled, nothing to show')
				else:
					if len(line) > 2: pt_n = int(line[2])
					if len(line) > 3: pt_cut = int(line[3])
					lines = file_tail(self.conf._debug_proto.get_file(), pt_n)
					send(f'-- protocol log tail [{len(lines)}/{pt_n}:{pt_cut}]:')
					for line in lines: send(f'--- {str_cut(line, pt_cut)}')
					send(f'-- protocol log tail end')
			elif len(line) >= 2:
				path = line_src.strip().split(None, 1)[-1] # preserve spaces and case
				if self.conf._debug_proto:
					self.conf._debug_proto.set_file(path)
					self.conf._debug_proto.setLevel(logging.DEBUG)
					arg = f'file={path}'
				else: arg = 'unavailable'
				send(f'-- protocol log: {arg}')

	def cmd_log(self, line):
		if not (name := self.irc_chans_sys.get('ø_debug')): return # before chan_map init
		for conn in self.cmd_chan_conns(name):
			conn.cmd_msg_chan(self.conf.irc_nick_sys, name, line)

	async def cmd_info(self, conn, t, id_str):
		t = {'#': 'channels', '@': 'users', '%': 'guilds'}[t]
		info_url, send = ( f'{t}/{id_str}',
			ft.partial(conn.cmd_msg_self, self.conf.irc_nick_sys) )
		info = await self.discord.cmd_info_dump(info_url)
		send(f'--- [{info_url}] info follows'); send(info); send(f'--- [{info_url}] end')

	def cmd_msg_monitor( self, nick, msg,
			prefix='', notice=True, gid=None, leftover_skip=None ):
		'Sends message to monitor/leftover IRC channel(s)'
		lines_n = len(lines := msg.splitlines())
		m, n = self.conf.irc_len_monitor, self.conf.irc_len_monitor_lines
		if len(lines) > n: lines = list(line for line in lines if line.strip())
		lines = list(f'{prefix}{str_cut(s, m, len_bytes=True)}' for s in lines[:n+1])
		if len(lines) > n:
			lines = lines[:n]
			lines[-1] += f' ... [{len(lines)}/{lines_n} lines]'
		nc_conns_skip = set(leftover_skip or list())
		for ct in 'mon', 'nc':
			chans, cache = list(), getattr(self.cache, f'gid_{ct}_chan')
			if gid and (name := cache.get(gid)): chans.append(name)
			if name := cache[None]: chans.append(name)
			for name in chans:
				for conn in self.cmd_chan_conns(name):
					if ct == 'nc':
						if conn in nc_conns_skip: continue
						nc_conns_skip.add(conn)
					for line in lines: conn.cmd_msg_chan(nick, name, line, notice=notice)

	def cmd_msg_discord( self, cc, nick=None, line=None, name_irc=None,
			tags=None, ts=None, conn=None, notice=None, skip_monitor=False ):
		'''Sends annotated Discord message to all proxy/monitor IRC channels.
			conn= limits sending to proxy-chan of one irc conn, e.g. for history-replay.
			name_irc= is used to set destination irc proxy-channel to use instead of cc.'''
		# Must be synchronous wrt all discord data,
		#  as it can be called right before channel delete/rename.
		if ts: self.conf.state_set(self.uid_start, ts)
		prefix, name = '', name_irc or self.cache.did_chan.get(cc.did)
		if nick:
			if nick not in cc.users: cc.users[nick] = adict(name=nick)
			cc.users[nick].ts = ts or time.time()
		if conn: # e.g. log-replay request for this one irc client
			skip_monitor, conns_joined = True, [conn]
		else:
			conns_joined = self.cmd_chan_conns(name) if name else list()
			if not conns_joined and not self.cmd_conn(): return # no irc clients

		if tags:
			mt, prefix = DiscordSession.c_msg_tags, tags.pop('_prefix', prefix)
			for k, (tt, v) in tags.items():
				if tt == mt.user: v = f'@{self.irc_name(v)}'
				elif tt == mt.chan:
					v = IRCProtocol.chan_spec(self.cache.did_chan.get(v, v))
				line = line.replace(k, v)
		if nick: nick = self.irc_name(nick)
		else:
			nick = self.conf.irc_nick_sys
			if notice is None: notice = True
		if not notice:
			if self.conf.irc_prefix_all_private and cc.private:
				prefix = f'{self.conf.irc_prefix_all_private}{prefix}'
			if self.conf.irc_prefix_all: prefix = f'{self.conf.irc_prefix_all}{prefix}'

		if name:
			# Relay message to direct-proxy IRC channel
			for conn, line in it.product(conns_joined, line.splitlines()):
				conn.cmd_msg_chan(nick, name, f'{prefix}{line}', notice=notice)
		else: # fallback pseudo-channel name to use with monitors
			self.log.warning( 'Failed to resolve channel did'
				' {!r} [cc={}] for line: [{}] {}', cc.did, cc, nick, tags, nick, line )
			name = f'ERR [ {cc.gg.name} ] {cc.name}'

		# Relay message to all relevant monitor/leftover channels
		if skip_monitor: return
		if name in self.conf.filters or f'{name}+threads' in self.conf.filters: return
		if cc.tid:
			if any( # thread-name format hardcoded in op_ev_chans
				re.search('^' + re.escape(f'{k[:-8]}.{cc.tid}') + r'(\.|$)', name)
				for k in self.conf.filters if k.endswith('+threads') ): return
			if ( self.conf.discord_thread_msgs_in_parent_chan
					and not self.conf.discord_thread_msgs_in_parent_chan_monitor ):
				# Skip thread-msg in leftover if it's been sent to joined parent channel
				conns_joined += self.cmd_chan_conns(cc.parent)
		self.cmd_msg_monitor( nick, line,
			prefix=f'{IRCProtocol.chan_spec(name)} :: {prefix}',
			notice=notice, gid=cc.gg.id, leftover_skip=conns_joined )

	def cmd_msg_rename_func(self, cc):
		'''Returns cmd_msg_discord func to send rename-notice to old channel.
			Runs before discord-id cache updates to new channel name to use old name_irc.'''
		return ft.partial( self.cmd_msg_discord,
			cc, name_irc=self.cache.did_chan.get(cc.did) )



class RDIRCDConfig(RDIRCDConfigBase):

	ws_dump_filter = None
	ws_dump_file = 'dump.json'
	# ws_dump_filter = adict(op=0, t='READY')

	state_tracked = True
	_state_section = _state_offsets = _state_file = _state_file_ts = None
	_debug_chan = _debug_proto = _debug_counts = None

	def __init__(self):
		self.state, self.renames = dict(), dict()
		self.filters, self.replacements = irc_name_dict(), adict()
		self.log = get_logger('rdircd.state')
		self.replacements._track( cb=lambda _a=
			self.replacements.attrs: _a.update(gen=_a.get('gen', 0) + 1) )
		for k in dir(self):
			if k.startswith('_conv_'): k = k[6:]
			else: continue
			self.set(k, self.get(k))

	def __repr__(self): return repr(vars(self))
	def get(self, *k, raw=False):
		k = '_'.join(k).replace('-', '_')
		if not raw:
			with cl.suppress(AttributeError): return getattr(self, f'_{k}')
		return getattr(self, k)
	def set(self, k, v):
		k = k.replace('-', '_')
		k_conv, v_conv = f'_{k}', getattr(self, f'_conv_{k}', None)
		if v_conv: setattr(self, k_conv, v_conv(v))
		setattr(self, k, v)

	def val_to_opt(self, v):
		if isinstance(v, bool): v = ['no', 'yes'][v]
		if v and isinstance(v, str):
			if v[0] in ' \t': v = f'\\{v}'
			if v[-1] in ' \t': v += '\\'
		return str(v)

	def opt_to_val_func(self, v, conf_get=lambda *a,**k: a[0]):
		if v is str or isinstance(v, str):
			def str_get(*a):
				v = str(conf_get(*a, raw=True))
				if v.endswith('\\'): v = v[:-1]
				if len(v) >= 2 and v[0] == '\\' and v[1] in ' \t': v = v[1:]
				return v
			return str_get
		elif v is bool or isinstance(v, bool):
			bool_map = {
				'1': True, 'yes': True, 'y': True, 'true': True, 'on': True,
				'0': False, 'no': False, 'n': False, 'false': False, 'off': False }
			def bool_get(*a):
				v = conf_get(*a)
				try: return bool_map[str(v).strip().lower()]
				except KeyError: raise ValueError(v)
			return bool_get
		elif v is int or isinstance(v, int): return lambda *a: int(re.sub(r'[ _]', '', conf_get(*a)))
		elif v is float or isinstance(v, float): return lambda *a: float(conf_get(*a))

	def read(self, func, section, k, conf_k=None, section_old=None):
		if not conf_k: conf_k = f'{section}_{k}'.replace('-', '_')
		for k in set([k, k.replace('-', '_'), k.replace('_', '-')]):
			try:
				self.set(conf_k, func(section_old or section, k))
				return True
			except configparser.NoSectionError: pass
			except configparser.NoOptionError: pass

	def pprint(self, title=None, empty_vals=False, comments=None):
		cat, comms, chk = None, comments or dict(), re.compile(
			'^({})_(.*)$'.format('|'.join(map(re.escape, self._conf_sections))) )
		if title: print(f';; {title}')
		if '_notes' in comms: print(comms['_notes'])
		conf_sec_order = ' '.join(self._conf_sections)
		conf_opt_order = ''.join(f' {k} ' for k in comms).replace('-', '_')
		for k in sorted( dir(self),
				key=lambda k: (conf_sec_order.find(
					k.split('_', 1)[0] ), conf_opt_order.find(f' {k} '), k) ):
			if not (m := chk.search(k)): continue
			if (v := self.get(k, raw=True)) is None: continue # internal stuff
			if not empty_vals and not v: continue
			if cat != (cat_chk := m.group(1).replace('_', '-')):
				cat = cat_chk
				print(f'\n[{cat}]')
			k = m.group(2).replace('_', '-')
			if comm := comms.get(k_full := f'{cat}-{k}'):
				if comm.startswith(f'; {k_full}'): comm = f'; {k}' + comm[len(k_full) + 2:]
				print(comm)
			print(f'{k} = {self.val_to_opt(v)}')
		for sk in 'renames', 'replacements', 'filters':
			if not (sec := getattr(self, sk)): continue
			print(f'\n[{sk}]')
			for k, v in sec.items():
				print(''.join([
					'.'.join(k) if isinstance(k, tuple) else k,
					'' if sk == 'filters' and not v else f' = {self.val_to_opt(v)}' ]))

	def read_from_file(self, *conf_paths):
		if conf_paths: self._conf_path_list = conf_paths
		else: conf_paths = self._conf_path_list
		conf_file = configparser.ConfigParser(allow_no_value=True)
		conf_file.optionxform = lambda k: k
		conf_file.read(conf_paths)
		self._conf_path = conf_paths[-1] # one to update via set and state stuff
		self._conf_sections_old_found.clear()
		for k, k_new in list(self._conf_sections_old.items()):
			if k_new not in self._conf_sections: continue # special sections
			if self.update_from_file_section(
					conf_file, section=k_new, prefix=f'{k_new}_', section_old=k ):
				# Warnings will be issued for existing keys after logging is configured later
				self._conf_sections_old_found.add(k)
		for k in self._conf_sections:
			self.update_from_file_section(conf_file, section=k, prefix=f'{k}_')
		self.read(conf_file.getboolean, 'state', 'tracked')
		self._state_section = conf_file.has_section('state')
		if self.state_tracked and self._state_section:
			for k, v in conf_file['state'].items():
				if k == 'tracked': continue
				self.state[k] = parse_iso8601(v)
		for new, sk in enumerate(['aliases', 'renames']):
			if not conf_file.has_section(sk): continue
			if not new: self._conf_sections_old_found.add(sk)
			self.renames.update(
				(tuple(str_norm(k).split('.', 1)), v) for k, v in conf_file[sk].items() )
		if conf_file.has_section('filters'):
			self.filters.update((k, v or '') for k, v in conf_file['filters'].items())
		if conf_file.has_section('replacements'):
			val_conv = self.opt_to_val_func(str)
			self.replacements.update( (k, val_conv(v or ''))
				for k, v in conf_file['replacements'].items() )
		return conf_paths

	def update_from_file_section(self,
			config, section='default', prefix=None, section_old=None ):
		if section_old: section_old = section_old.replace('_', '-')
		section_old_warn, section = None, section.replace('_', '-')
		for k in dir(self):
			if prefix:
				if not k.startswith(prefix): continue
				conf_k, k = k, k[len(prefix):]
			elif k.startswith('_'): continue
			else: conf_k = k
			v = getattr(self, conf_k)
			get_val = self.opt_to_val_func(v, config.get)
			if not get_val: continue # other types cannot be specified in config
			if self.read( get_val, section, k, conf_k,
				section_old=section_old ) and section_old: section_old_warn = True
		return section_old_warn


	def _get_section_updates(self, section, keys, key_set=False):
		'Returns {key: (val_str, line_re)} to replace/add in specified ini section'
		sec_k, sec_prefix = str_norm(section), section.lower().replace('-', '_') + '_'
		if keys is None: keys = list(k for k in vars(self).keys() if k.startswith(sec_prefix))
		elif isinstance(keys, str): keys = keys.split()
		elif callable(getattr(keys, 'items', None)): keys = list(keys.items())
		for n, k in enumerate(keys):
			if isinstance(k, tuple): k, v = k
			else: v = ...
			k = k.replace('_', '-')
			if not k.startswith(sec_prefix): k = sec_prefix + k
			if v is ...: v = self.get(k, raw=True)
			k = k[len(sec_prefix):]
			if v is None: pass
			elif v := self.val_to_opt(v): v = f' = {v}'
			elif not key_set: v = ' ='
			keys[n] = k, v, re.compile(r'(?i)^' + re.escape(k.replace('-', '[-_]')) + r'\s*(=|$)')
		return dict((k, (v, rx)) for k, v, rx in keys)

	def update_file_section(self, section, keys=None, path=None, key_set=False):
		'''Safely replaces last config file, updating some keys in specified section there.
			keys=None or a str/list of keys stores/updates specified section keys to a file.
				None value set for the key removes it from config section.
			key_set=True will print empty-string values as "key" instead of default "key =".'''
		section = section.replace('_', '-')
		sec_k, sec_re = str_norm(section), re.compile(r'(?i)^\[\s*(\S+)\s*\]$')
		if not path: path = self._conf_path
		if isinstance(path, str): path = pl.Path(path)
		keys = self._get_section_updates(section, keys, key_set=key_set)
		with path.open() as src, safe_replacement(path) as dst:
			## Pass-through existing config file, except for target section lines
			lines, sec, sec_parse = list(), list(), False
			for n, line in enumerate(src):
				line = line.rstrip()
				if m := sec_re.search(line.strip()):
					if sec_k == str_norm(m.group(1)):
						sec_parse = True
						if sec: line = '' # drop duplicate headers
					else: sec_parse = False
				if sec_parse: sec.append(line)
				else: lines.append(line)
			while sec and not sec[-1]: sec.pop()
			if not sec: sec.append(f'[{section}]')
			if lines and lines[-1]: lines.append('')
			for line in lines: dst.write(f'{line}\n')
			## Update target section
			for n, line in enumerate(sec): # replace updated key(s)
				for k, (v, rx) in keys.items():
					if not rx.search(line): continue
					if v is None: line = None
					else: line, keys[k] = f'{k}{v}', (None, rx)
					break
				if line is not None: dst.write(f'{line}\n')
			for k, (v, rx) in keys.items(): # dump all other keys as-is
				if v is None: continue
				dst.write(f'{k}{v}\n')
		self._state_source_flush()
		return path

	### Timestamps in [state] section are updated in-place,
	###  overwriting short timestamp values without tmp files.
	### File should be safe to edit manually regardless, due to ctime checks.

	def _state_source_flush(self):
		self._state_file = self._state_offsets = None

	def _state_source_get(self):
		if self._state_file and self._state_file_ts:
			try: ts = os.stat(self._state_file.name).st_ctime
			except OSError: ts = None
			if ts != self._state_file_ts: self._state_source_flush()
		if not self._state_file: self._state_file = open(self._conf_path, 'rb+')
		if self._state_offsets is None: self._state_offsets = self._state_offsets_read()
		return self._state_file, self._state_offsets

	def _state_offsets_read(self, section='state'):
		section, src = section.replace('_', '-'), self._state_file
		sec_re, sec_k = re.compile(r'(?i)^\[\s*(\S+)\s*\]$'), str_norm(section)
		src.seek(0)
		offsets, parse = dict(), False
		val_re = re.compile(b'^\s*([\w\d]\S+)\s*=\s*(\S+)\s*$')
		for line in iter(src.readline, b''):
			if m := sec_re.search(line.decode().strip()):
				if sec_k == str_norm(m.group(1)): parse = True
				else: parse = False
				continue
			if parse:
				if not (m := val_re.search(line)): continue
				k, v = m.group(1), m.group(2)
				pos = src.tell() - len(line) + m.start(2)
				offsets[k.decode()] = pos
				# src.seek(pos)
				# v_chk = src.read(len(v))
				# assert v_chk == v, [pos, v_chk, v]
				# src.readline()
		return offsets

	def state_get(self, k, t='last-msg'): return self.state.get(f'{t}.{k}')

	def state_set(self, k, ts, t='last-msg', sync=False):
		if not self.state_tracked: return
		if not self._state_section: self.update_file_section('state', 'tracked')
		k, v = f'{t}.{k}', ts_iso8601(ts)
		if k not in self.state:
			self.update_file_section('state', {k: v})
			self.state[k] = ts
			return self.state_cleanup()
		if self.state[k] > ts: return
		src, offsets = self._state_source_get()
		n, v = offsets[k], v.encode()
		src.seek(n)
		parse_iso8601(src.read(len(v)).decode(), validate=True)
		src.seek(n)
		src.write(v)
		src.flush()
		if sync: os.fdatasync(src.fileno())
		self.state[k], self._state_file_ts = ts, os.fstat(src.fileno()).st_ctime

	def state_cleanup(self, keep_max=10):
		keys = sorted((v,k) for k,v in self.state.items())
		if len(keys) <= keep_max: return
		src, offsets = self._state_source_get()
		offsets = sorted( ((offsets[k], k) for v,k in
			keys[:len(keys) - keep_max]), reverse=True )
		with src, safe_replacement(src.name, 'wb') as dst:
			src.seek(0)
			for line in iter(src.readline, b''):
				if offsets and offsets[-1][0] < src.tell():
					n, k = offsets.pop()
					del self.state[k]
					continue
				dst.write(line)
		self._state_source_flush()



def parse_conf_code_comments(lines):
	'Parses "class RDIRCDConfigBase" in this script, returns {var: comment} dict'
	def line_proc(line):
		var = line.split(None, 1)[0] if re.search(r'^\S+\s+=', line) else ''
		var, var_code = var.replace('_', '-'), var
		try: pre, comm = line.split('#', 1)
		except ValueError: comm = ''
		if comm.lstrip().startswith('XXX:'): comm = ''
		if comm.startswith(' '): comm = comm[1:]
		return var, var_code, comm
	comms, comm_buffer = dict(), list()
	for ls in lines:
		var, var_code, comm = line_proc(ls)
		if var_code.startswith('_'): break
		if not var:
			if comm: comm_buffer.append(comm)
		else:
			if comm_buffer:
				comm = ((comm or '') + '\n' + '\n'.join(comm_buffer)).strip()
			if comm:
				if var and re.search('^' + re.escape(var_code) + r'\s+', comm):
					comm = var + comm[len(var_code):]
				if comm.split(None, 1)[0].istitle():
					word1, *comm = comm.split(None, 1)
					comm = ' '.join([word1.lower(), *comm])
				if not comm.startswith(var): comm = f'{var}: {comm}'
			comms[var] = comm
		if comm_buffer and (not comm or var):
			if not comms: comms['_notes'] = '\n'.join(comm_buffer)
			comm_buffer.clear()
	comms.update(
		(k, '\n'.join(f'; {line}' for line in comm.split('\n')))
		for k, comm in comms.items() if comm )
	return comms

def main(args=None, conf=None):
	if not conf: conf = RDIRCDConfig()

	import argparse, textwrap
	dd = lambda text: re.sub( r' \t+', ' ',
		textwrap.dedent(text).strip('\n') + '\n' ).replace('\t', '  ')
	parser = argparse.ArgumentParser(
		usage='%(prog)s [options]',
		formatter_class=argparse.RawTextHelpFormatter,
		description='Reliable personal discord-client <-> irc-server translation daemon.')

	group = parser.add_argument_group('Configuration file(s)')
	group.add_argument('-c', '--conf', metavar='file', action='append', help=dd(f'''
		Path to configuration file to use.
		It will get updated with OAuth2 credentials ([auth] section)
			and some state info ([state] section), so has to be writable.
		Can be specified mutliple times to use multiple config files,
			in which case only last one will be updated and has to be writable.
		Default: {conf._conf_path}'''))
	group.add_argument('--conf-dump', action='store_true', help=dd('''
		Print all configuration settings, which will be used with
			currently detected (and/or specified) configuration file(s), and exit.'''))
	group.add_argument('--conf-dump-defaults', action='store_true', help=dd('''
		Print all default settings which would be used if no configuration
			file(s) were overriding these, with some extra comments, and exit.'''))
	group.add_argument('--conf-dump-state', action='store_true', help=dd('''
		Print "state" section from the config file,
			with keys corresponding to app startups and iso8601
			time values of last received message for that run.'''))
	group.add_argument('-H', '--conf-pw-scrypt', action='store_true', help=dd('''
		Prompt/read password from stdin, generate and print
			scrypt-hashed string of it to stdout for use in config file(s) and exit.
		Such hash should be used with "password-hash" option under [irc] section.
		If stdin is not a terminal, only first line is read from there as a password.
		scrypt uses random 16B salt and N=2^15 r=8 p=1 parameters (min 32M memory).'''))

	group = parser.add_argument_group('Interfaces')
	group.add_argument('-i', '--irc-bind', metavar='host(:port)', help=dd(f'''
		Address/host (to be resolved via gai) and port to bind IRC server to.
		When specifying port after raw IPv6 address,
			enclose the latter in [], for example - [::]:6667.
		Default: {conf.irc_host}:{conf.irc_port} or whatever is in --conf file.'''))
	group.add_argument('-s', '--irc-tls-pem-file', metavar='file', help=dd('''
		Path to a file with TLS certificate and key
			to use tls-wrapped connection(s) instead of plain irc protocol.
		Same as IRC "tls-pem-file" config option (default-disabled).
		If file is specified but missing, it will be auto-generated by default.'''))
	group.add_argument('-S', '--irc-tls-pem-gen-subj',
		metavar='subject', default=conf.irc_tls_pem_gen_subj, help=dd('''
			Generate self-signed TLS cert using "openssl req" with specified subject line.
			Will never overwrite -c/--irc-tls-pem file, if it exists already.
			Use empty value to disable this. Default subject line: %(default)s'''))

	group = parser.add_argument_group('Logging and debug opts')
	group.add_argument('-d', '--debug',
		action='store_true', help='Verbose operation mode.')
	group.add_argument('--debug-asyncio',
		action='store_true', help='Run asyncio event loop in debug mode. Very verbose.')
	group.add_argument('-t', '--proto-cut', type=int, metavar='n', help=dd('''
		Truncate long strings in protocol dumps to specified length.
		Set to <=0 to disable truncation (default).'''))
	group.add_argument('-p', '--proto-log', metavar='file', help=dd('''
		File to dump full non-truncated protocol logs to.
		Max file size and rotation options can be configured via --conf file.'''))
	group.add_argument('-l', '--debug-log', metavar='file', help=dd('''
		Separate file for debug-level logging, regardless of levels set elsewhere.
		Max file size and rotation options can be configured via --conf file.'''))

	opts = parser.parse_args(sys.argv[1:] if args is None else args)

	if opts.conf_dump_defaults:
		try: # parse config comments from this script
			lines = list()
			if __file__.endswith('.pyc'): raise OSError
			with open(__file__, errors='replace') as src:
				if src.read(3) != '#!/': raise OSError
				for line in src:
					ls = line.strip()
					if not (lines or ls == 'class RDIRCDConfigBase:'): continue
					if lines and ls and not line.startswith('\t'): break
					lines.append(ls)
		except OSError: comms = None
		else: comms = parse_conf_code_comments(lines)
		return conf.pprint(
			'Default configuration options', empty_vals=True, comments=comms )

	if opts.conf_pw_scrypt:
		if sys.stdin.isatty():
			import getpass
			pw = getpass.getpass().encode()
		else: pw = sys.stdin.buffer.readline().rstrip(b'\r\n')
		return print(pw_hash(pw))

	conf_user_paths = list(map(
		os.path.expanduser, opts.conf or [conf._conf_path] ))
	for n, p in enumerate(conf_user_paths):
		mode = os.R_OK
		if n == len(conf_user_paths) - 1: mode |= os.W_OK
		if not os.access(p, mode):
			parser.error(f'Specified config file missing or inaccessible: {p}')
	conf_user_paths = conf.read_from_file(*conf_user_paths)

	if opts.conf_dump:
		return conf.pprint('Current configuration options')
	if opts.conf_dump_state:
		print('state_timestamps:')
		for v, k in sorted((v,k) for k,v in conf.state.items()):
			print(f'  {k}: {ts_iso8601(v)}')
		return

	if opts.debug: conf.debug_verbose = True
	if opts.debug_asyncio: conf.debug_asyncio_logs = True
	if opts.debug_log: conf.debug_log_file = opts.debug_log
	if opts.proto_cut: conf.debug_proto_cut = opts.proto_cut
	if opts.proto_log: conf.debug_proto_log_file = opts.proto_log

	log_fmt = '{name} {levelname:5} :: {message}'
	if conf.debug_verbose: log_fmt = '{asctime} :: ' + log_fmt
	log_fmt = logging.Formatter(log_fmt, style='{')
	log_handler = logging.StreamHandler(sys.stderr)
	log_handler.setLevel( logging.DEBUG
		if conf.debug_verbose else logging.WARNING )
	log_handler.setFormatter(log_fmt)
	log_handler.addFilter(log_empty_filter)
	logging.root.addHandler(log_handler)
	logging.root.setLevel(0)
	log = get_logger('main')

	log_handler = LogLevelCounter()
	logging.root.addHandler(log_handler)
	conf._debug_counts = log_handler.counts

	if conf.debug_log_file:
		log_handler = LogFileHandler(
			conf.debug_log_file,
			maxBytes=conf.debug_log_file_size,
			backupCount=conf.debug_log_file_count )
		log_handler.setLevel(logging.DEBUG)
		log_handler.setFormatter(logging.Formatter(
			'{asctime}.{msecs:03.0f} :: {name} {levelname:5} :: {message}',
			datefmt='%Y-%m-%dT%H:%M:%S', style='{' ))
		logging.root.addHandler(log_handler)

	log_proto_root.propagate = conf.debug_proto_log_shared
	log_handler = conf._debug_proto = LogFileHandler(
		conf.debug_proto_log_file or '/dev/null',
		maxBytes=conf.debug_proto_log_file_size,
		backupCount=conf.debug_proto_log_file_count )
	log_handler.setLevel( logging.DEBUG
		if conf.debug_proto_log_file else logging.WARNING )
	log_handler.setFormatter(LogProtoFormatter(
		'%(asctime)s :: %(reltime)s :: %(name)s :: %(message)s' ))
	log_handler.addFilter(log_proto_debug_filter)
	log_proto_root.addHandler(log_handler)

	def _handle_exception(err_t, err, err_tb):
		log.error('Unhandled error: {}', err_fmt(err), exc_info=(err_t, err, err_tb))
	sys.excepthook = _handle_exception

	for p in conf_user_paths: log.debug('Loaded configuration file: {}', p)
	for k_old, k_new in conf._conf_sections_old.items():
		if k_old not in conf._conf_sections_old_found: continue
		k_old, k_new = (k.replace('_', '-') for k in [k_old, k_new])
		log.warning( 'Old and deprecated section'
			' in config file(s) - {!r} - replace it with {!r}', k_old, k_new )

	if conf.irc_password:
		if conf.irc_password_hash:
			parser.error( 'Both password= and password-hash= config-file'
				' options cannot be used at the same time, use latter one only.' )
		log.warning( 'Plaintext password= option is used in'
			' configuration file(s) - use password-hash= instead, if possible' )
		conf.irc_password_hash = pw_hash(conf.irc_password)
		conf.irc_password = ''
	if conf.irc_password_hash:
		try:
			if pw_hash('hunter2', conf.irc_password_hash):
				parser.error('Using "hunter2" as password is explicitly prohibited, sorry.')
		except ValueError as err: parser.error(f'Invalid password-hash value - {err}')

	host, port, family = opts.irc_bind or conf.irc_host, conf.irc_port, conf.irc_host_af
	if host.count(':') > 1: host, port = str_part(host, ']:>', port)
	else: host, port = str_part(host, ':>', port)
	if '[' in host: family = socket.AF_INET6
	host, port = host.strip('[]'), int(port)
	try:
		addrinfo = socket.getaddrinfo( host, str(port),
			family=family, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP )
		if not addrinfo: raise socket.gaierror(f'No addrinfo for host: {host}')
	except (socket.gaierror, socket.error) as err:
		parser.error( 'Failed to resolve irc socket parameters (address, family)'
			' via getaddrinfo: {!r} - [{}] {}'.format((host, port), err.__class__.__name__, err) )
	sock_af, sock_t, sock_p, _, sock_addr = addrinfo[0]
	log.debug(
		'Resolved irc host:port {!r}:{!r} to endpoint: {} (family: {}, type: {}, proto: {})',
		host, port, sock_addr, *(sockopt_resolve(pre, n)
			for pre, n in [('af_', sock_af), ('sock_', sock_t), ('ipproto_', sock_p)]) )
	assert ( sock_t == socket.SOCK_STREAM
		and sock_p == socket.IPPROTO_TCP ), [sock_t, sock_p]
	conf.irc_host_af, (conf.irc_host, conf.irc_port) = sock_af, sock_addr[:2]

	for k in 'file', 'gen_subj':
		if (v := getattr(opts, k := f'irc_tls_pem_{k}')) is not None: setattr(conf, k, v)
	if conf.irc_tls_pem_file:
		import ssl
		p_pem = pl.Path(conf.irc_tls_pem_file)
		if conf.irc_tls_pem_gen_subj and not p_pem.exists():
			import subprocess as sp
			p_key, p_crt = (p_pem.with_name(
				p_pem.name + f'.new.{k}' ) for k in ['key', 'crt'])
			try:
				log.debug('Generating new TLS cert/key file: {}', p_pem)
				sp.run([ 'openssl', 'req', '-new', '-x509', '-nodes', '-keyout',
					p_key, '-out', p_crt, '-subj', conf.irc_tls_pem_gen_subj ], check=True)
				p_pem.touch(0o600)
				try: p_pem.write_text('\n'.join([p_key.read_text(), p_crt.read_text()]))
				except: p_pem.unlink(); raise
			finally: p_key.unlink(missing_ok=True); p_crt.unlink(missing_ok=True)
		conf.irc_tls = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
		conf.irc_tls.load_cert_chain(p_pem, p_pem)

	async def _run_rdircd():
		loop, rdircd = asyncio.get_running_loop(), RDIRCD(conf)

		log_handler = conf._debug_chan = LogFuncHandler(rdircd.cmd_log)
		log_handler.setLevel(logging.DEBUG if conf.debug_verbose else logging.WARNING)
		log_handler.setFormatter(logging.Formatter(
			'{name} {levelname:5} :: {message}', style='{' ))
		log_handler.addFilter(log_empty_filter)
		log_handler.addFilter(log_proto_debug_filter)
		logging.root.addHandler(log_handler)

		rdircd_task = asyncio.create_task(rdircd.run_async())
		for sig in signal.SIGINT, signal.SIGTERM:
			loop.add_signal_handler(sig, rdircd_task.cancel)
		with cl.suppress(asyncio.CancelledError): return await rdircd_task

	log.debug('Starting eventloop...')
	return asyncio.run(_run_rdircd(), debug=conf.debug_asyncio_logs)
	log.debug('Finished')

if __name__ == '__main__': sys.exit(main())
