#!/usr/bin/env python3
'''
'''
from collections import Counter
from dataclasses import dataclass, field
from difflib import unified_diff, HtmlDiff
from editdistance import eval as distance
from itertools import chain
from multiprocessing import Pool
from pathlib import Path
from pygments.formatters import Terminal256Formatter
from pygments import highlight
from pygments.lexers import PythonLexer, DiffLexer
from pygments.styles.native import NativeStyle
import ast
import atexit
import cmd
import csv
import getpass
import gzip
import os
import pickle
import re
import readline
import requests
import shlex
import shutil
import sshtoken
import subprocess
import sys
import time
import tomli_w
import tomllib
import traceback

CONTEST = "Python2022"
reSPACEQ = re.compile(r'([\s"]+)')


@dataclass
class files:
    pwd: Path = Path().resolve()
    config: Path = pwd / ".ejst.conf"
    archive: Path = pwd / "var" / "archive" / "uuid"
    cache: Path = Path("~").expanduser() / ".cache" / CONTEST
    history: Path = cache / "history"
    token: Path = cache / "token"
    runs: Path = cache / "runs.csv"
    sourcefiles: Path = cache / "sources.toml"
    standings: Path = cache / "standings.html"
    distances: Path = cache / "distances.pickle"
    report: Path = cache / "report"
    diffdir: Path = cache / "diffs"


@dataclass
class _glob:
    allruns: list[dict] = field(default_factory=list)
    runs: list[dict] = field(default_factory=list)
    sourcefiles: dict[str] = field(default_factory=dict)
    standings: list[dict] = field(default_factory=list)
    users: dict[str] = field(default_factory=dict)
    scores: dict[str] = field(default_factory=dict)
    tasks: list[str] = field(default_factory=list)
    areas: dict[str] = field(default_factory=dict)
    logins: dict[str] = field(default_factory=dict)
    maxscore: int = 0


glob = _glob()

config = {
    "contest": f"{CONTEST}",
    "prompt": f"{CONTEST}> ",
    "moinroot": "/var/www/wiki/uneex",
    "moinpage": "data/pages/LecturesCMC(2f)PythonIntro2022(2f)HomeworkGradePaste(2f)report",
    "moinstatic": f"static/{CONTEST}",
    "moinlogin": "moin",
    "moinurl": "uneex.org",
    "login": "scoreviewer",
    "sshkey": "ED25519",
    "ejudge": "ejudge.cs.msu.ru",
    "ejuser": "frbrgeorge",
    "contest_id": "230",
    "min_score": 2 / 3,
    "max_distance": 0.01,
    "max_sizediff": 0.2,
    "common_tasks": ["HelloWorld", ],
    "agrade_tasks": ["InitParam", "TarFile", "DeStruct", "SpiralString"],
    "scores": [66, 77, 88],
    "common_downscore": 51,
    "same_downscore": 26,
    "paste_downscore": 0,
    "copypaste": {},
    "independent": {},
    "manual": {},
}

ejudge = {
    "interface": "new-master",
    "role": "1",
    "locale_id": "0",
    "stat": "94",
    "get": "152",
    "getxml": "155",
    "default": ("User_Name", "Run_Id", "Prob", "Status", "Score"),
    "cookies": None,
    "adm_url": None,
}


def ejget(action):
    if type(action) is str and action.isalpha():
        action = ejudge[action]
    url = re.sub(r"[&]action=\d+|$", f"&action={action}", ejudge["adm_url"])
    req = requests.get(url, cookies=ejudge["cookies"])
    return req.text


def getruns():
    request = requests.post(f"https://{config['ejudge']}/{ejudge['interface']}", data={
        "login": config["login"],
        "password": sshtoken.get_token(files.token.as_posix()).decode(),
        "contest_id": config["contest_id"],
        "role": ejudge["role"],
        "locale_id": ejudge["locale_id"],
    })
    ejudge["cookies"], ejudge["adm_url"] = request.cookies, request.url
    return ejget("get"), ejget("stat")


def auditpick(text, label):
    fmt = f"\n{label}: "
    return text[(st := text.index(fmt) + len(fmt)): text.index("\n", st + 1)]


def getsourcefiles():
    sources = {}
    for path in files.archive.glob("??/??/*"):
        txt = (path / "audit").read_text()
        runid = auditpick(txt, "Run-id")
        if (path / "source").is_file():
            sources[runid] = (path / "source").as_posix()
        elif (path / "source.gz").is_file():
            sources[runid] = (path / "source.gz").as_posix()
    return sources


AstK = ['None '] + [s for s in dir(ast) if s[0].isalpha()]
AstD = {key: chr(char + 65) for key, char in zip(AstK, range(len(AstK)))}


def astformat(node):
    if isinstance(node, ast.AST):
        args = []
        for fld in node._fields:
            value = getattr(node, fld)
            args.append(astformat(value))
        return '%s%s' % (AstD[node.__class__.__name__], ''.join(args))
    elif isinstance(node, list):
        return '%s' % ''.join(astformat(x) for x in node)
    return '@'


def readsource(args):
    fname, user, task, runid = args
    if fname.endswith(".gz"):
        source = gzip.open(fname, "rb").read()
    else:
        source = open(fname, "rb").read()
    astree = ast.parse(source, "p")
    prep = astformat(astree)
    return user, task, ast.unparse(astree), prep, runid


def readsources():
    minscore = glob.maxscore * config["min_score"]
    glob.users = {st["User"]: st for st in glob.standings if st["Score"] and int(st["Score"]) >= minscore}
    vals = []
    for run in glob.allruns:
        if run["Run_Id"] in glob.sourcefiles:
            if run["Stat_Short"] == "OK" and run["User_Name"] in glob.users:
                vals.append((glob.sourcefiles[run["Run_Id"]], run["User_Name"], run["Prob"], run["Run_Id"]))
        else:
            print(f'ERROR: No {run["Run_Id"]} source, update is needed!')
    glob.scores, glob.areas = {}, {}
    with Pool() as pool:
        for user, task, source, prep, runid in pool.map(readsource, vals):
            if user:
                glob.areas.setdefault(task, {})[user] = \
                    glob.scores.setdefault(user, {})[task] = \
                    [int(glob.users[user][task]), source, prep, set(), runid]
    for user in glob.scores:
        for task in glob.tasks:
            if task not in glob.scores[user]:
                glob.scores[user][task] = [int(glob.users[user][task] or 0), "", "", set(), 0]


def calcdist(args):
    return *args, distance(*args[-2:]) / (len(args[-1]) + len(args[-2]))


def calcdistances():
    poolargs = []

    for task, area in glob.areas.items():
        if task in config["common_tasks"]:
            continue
        users = list(area.items())
        for i, (user, (score, source, prep, flags, runid)) in enumerate(users):
            if task in config["independent"].get(user, []):
                continue
            for j in range(i + 1, len(users)):
                user2, (score2, source2, prep2, flags2, runid2) = users[j]
                if task in config["independent"].get(user2, []):
                    continue
                w = abs(1 - (len(prep) + len(prep2)) / (2 * max(len(prep), len(prep2))))
                if all((score, source, score2, source2, w < config["max_sizediff"])):
                    poolargs.append((task, user, user2, prep, prep2))
    print(f"Calculating {len(poolargs)} distances…", end=" ")
    with Pool() as pool:
        for task, user, user2, prep, prep2, dist in pool.map(calcdist, poolargs):
            if dist < config["max_distance"]:
                glob.areas[task][user][3].add((dist, user2))
                glob.areas[task][user2][3].add((dist, user))
    print("done!")


def listtask(task):
    ranged = sorted((len(attrs[3]), min(attrs[3], default=(100, "")), user, attrs[0])
                    for user, attrs in glob.areas[task].items())
    weight = sum(r[0] for r in ranged)
    return weight, ranged


def downscore():
    # TODO copypaste / independent
    counter = equal = 0
    for user, tasks in glob.scores.items():
        for task, attrs in tasks.items():
            if task in config["common_tasks"]:
                attrs[0] = min(attrs[0], config["common_downscore"])
            if attrs[3]:
                attrs[0] = min(attrs[0], config["same_downscore"])
                counter += 1
                for weight, buddy in attrs[3]:
                    if attrs[1] == glob.scores[buddy][task][1]:
                        equal += 1
                        attrs[0] = config["paste_downscore"]
                        break

    for user, tasks in config["copypaste"].items():
        for task in tasks:
            glob.scores[user][task][0] = config["paste_downscore"]
            for weight, buddy in glob.scores[user][task][3]:
                glob.scores[buddy][task][0] = config["paste_downscore"]
    for user, tasks in config["manual"].items():
        for task, score in tasks.items():
            glob.scores[user][task][0] = score
    return counter, equal


def select(where, *fields, **pattern):
    for run in where.values() if isinstance(where, dict) else where:
        if run | pattern == run:
            if fields:
                yield {key: run[key] for key in fields}
            else:
                yield run


def showselect(where, *fields, **pattern):
    for run in select(where, *fields, **pattern):
        print(*run.values())


def selectset(where, field, **pattern):
    return set(tuple(run.values())[0] for run in select(where, field, **pattern))


def selectitem(where, *fields, error=True, **pattern):
    res = list(select(where, *fields, **pattern))
    if len(res) != 1:
        msg = f"Got {len(res)} selection of {pattern} instead of one"
        if error:
            raise ValueError(msg)
        print(msg, file=sys.stderr)
    if len(fields) == 1:
        return list(res[0].values())[0] if res else None
    else:
        return list(res[0].values()) if res else [None] * len(fields)


def score(task, person):
    for run in select(glob.runs, "default", {"Prob": task, "User_Name": person}):
        print(*run.values())


def readruns():
    glob.allruns = list(csv.DictReader(files.runs.open(newline=""), delimiter=";"))
    for run in glob.allruns:
        if not run["User_Name"]:
            run["User_Name"] = run["User_Login"]
        glob.logins[run["User_Name"]] = run["User_Login"]
    glob.tasks = list(selectset(glob.allruns, "Prob"))
    glob.maxscore = (len(glob.tasks) - len(config["common_tasks"])) * 100 + \
        len(config["common_tasks"]) * config["common_downscore"]


def readstandings():
    standings = files.standings.read_text()
    htmltable = re.search(r'<table[^\n]*class="standings">(.*)</table>', standings, re.M | re.S).groups()[0]
    table = []
    for record in re.finditer(r'<tr[^>]+>(.*?)</tr>', htmltable, re.M | re.S):
        table.append([])
        for res in re.finditer(r'<t[dh][^>]+>(?:<[^>]+>)*(.*?)(?:<[^>]+>)*</t[dh]>', record.groups()[0]):
            val = res.groups()[0].replace('&nbsp;', '')
            table[-1].append(val)
    glob.standings = [dict(zip(table[0], table[i])) for i in range(1, len(table))]


def gettoken():
    return sshtoken.get_token(files.token.as_posix(), config["sshkey"]).decode()


def createtoken():
    sshtoken.store_token(getpass.getpass("Password: "), files.token.as_posix(), config["sshkey"])


def dumpdistances():
    dists = {(user, task): attrs[3] for user, tasks in glob.scores.items() for task, attrs in tasks.items()}
    with files.distances.open("wb") as f:
        pickle.dump(dists, f)


def loaddistances():
    with files.distances.open("rb") as f:
        dists = pickle.load(f)
    for (user, task), pasters in dists.items():
        glob.scores[user][task][3] |= pasters


def grade(user):
    score = int(100 * sum(attrs[0] for attrs in glob.scores[user].values()) / glob.maxscore)
    solved = len([attrs[0] for attrs in glob.scores[user].values() if attrs[0] > config["paste_downscore"]])
    tasks = set(task for task, attrs in glob.scores[user].items() if attrs[0] > config["paste_downscore"])
    for gr, grname in zip(config["scores"] + [glob.maxscore + 1], ("НЕУД", "'''УДОВЛ'''", "'''ХОР'''", "'''ОТЛ'''")):
        if score < gr:
            break

    if grname == "'''ОТЛ'''" and not tasks.issuperset(set(config["agrade_tasks"])):
        grname = "'''ХОР+'''"
    passed = "'''ЗАЧЁТ'''" if solved == len(glob.tasks) or score >= config["scores"][1] else "незач"
    return grname, passed, int(score), int(solved)


def makereport(commonmark="*"):
    def rep(*args):
        match args:
            case [nick, name]:
                report.append(f"<<Anchor({nicks[nick]})>>[[#_{nicks[nick]}|back]]\n== {name} ==")
            case [nick, name, grad, passgr, score, solved]:
                report.append(f"|| [[#{nicks[nick]}|{nick}]] || <<Anchor(_{nicks[nick]})>>{name}"
                              f" || {grad} || {passgr} || {score} || {solved} ||")
            case [user, task, score, weight, buddy]:
                common = commonmark if task in config["common_tasks"] else ""
                if buddy:
                    source, source2 = glob.scores[user][task][1], glob.scores[buddy][task][1]
                    runid, runid2 = glob.scores[user][task][4], glob.scores[buddy][task][4]
                    diffname = f"{task}_{runid}_{runid2}.diff.html"
                    attachments[diffname] = \
                        HtmlDiff().make_file(source.split('\n'), source2.split('\n'),
                                             f"{user} {task} {runid}", f"{buddy} {task} {runid2}")
                    diffurl = f"{config['moinstatic']}/report/{diffname}"
                    blink = f"[[#{nicks[glob.logins[buddy]]}|{buddy}]] '''([[{diffurl}|distance {weight}]])'''"
                else:
                    blink = ""
                report.append(f"|| `{task}`{common} || '''{score}''' || {blink} ||")
            case [str(text)]:
                report.append(text)
            case _:
                print("Unable to report", *args)

    nicks = {nick: nick.replace(" ", "_").replace("@", "-") for nick in glob.logins.values()}
    attachments = {}
    report = []
    agrade_tasks = " ".join(f"[[../../Homework_{t}|{t}]]" for t in config["agrade_tasks"])
    rep(f"""== Текущие лимиты ==
Один балл таблицы — это {glob.maxscore/100} баллов EJudge.

||<|3> ⩾ {config['scores'][2]} || '''ОТЛ''' / '''ЗАЧЁТ''', если все обязательные задачи решены ||
|| '''ХОР+''' / '''ЗАЧЁТ''', если не решена хотя бы одна из обязательных задач: ||
||<)> {agrade_tasks} ||
|| ⩾ {config['scores'][1]} || '''ХОР''' / '''ЗАЧЁТ''' ||
|| ⩾ {config['scores'][0]} || '''УДОВЛ''' / незачёт ||
|| < {config['scores'][0]} || НЕУД / незачёт ||

Про копипасту:
 * «Почти похожие» решения — не более s,,alike,,={config['same_downscore']}
 * Полностью совпадающие решения и подтверждённая копипаста — s,,equal,,={config['paste_downscore']}
 * «Слишком простые задачи» — не более s,,raw,,={config['common_downscore']} (отмечены «'''{commonmark}'''»))
== {time.strftime("%F")} ==
|| '''User''' || '''Name ''' || '''Grade''' || '''Pass''' || '''Score''' || '''Solved''' ||""")

    for user in sorted(glob.users, key=lambda x: glob.logins[x]):
        rep(glob.logins[user], user, *grade(user))
    for user, tasks in glob.scores.items():
        rep(glob.logins[user], user)
        for task, attrs in tasks.items():
            rep(user, task, attrs[0], *min(attrs[3], default=(100500, "")))
        grname, passed, score, solved = grade(user)
        rep(f"|| {solved} / {len(glob.tasks)} || {score} || {grname}/{passed} ||")

    if files.report.is_dir():
        shutil.rmtree(files.report.as_posix())
    if files.diffdir.is_dir():
        shutil.rmtree(files.diffdir.as_posix())
    (files.diffdir).mkdir(parents=True)
    (files.report / "revisions").mkdir(parents=True)
    (files.report / "revisions" / "00000001").write_text("\n".join(report) + "\n")
    (files.report / "current").write_text("00000001\n")
    for name, html in attachments.items():
        (files.diffdir / name).write_text(html)
    return (files.report / "current").as_posix()


def publish():
    remotmoin = f"{config['moinlogin']}@{config['moinurl']}:{config['moinroot']}/{config['moinpage']}"
    subprocess.check_output(["rsync", "-avP", "--delete", files.report.as_posix() + "/", remotmoin])
    remotestatic = f"{config['moinlogin']}@{config['moinurl']}:{config['moinroot']}/{config['moinstatic']}/report/"
    subprocess.check_output(["rsync", "-avP", "--delete", files.diffdir.as_posix() + "/", remotestatic])


def init(sync=False):
    if not files.cache.is_dir():
        files.cache.mkdir()
    configread()
    if not files.token.is_file():
        print("WARNING: no password token is stored, creating one", file=sys.stderr)
        createtoken()
    if sync or not files.runs.is_file() or not files.standings.is_file():
        if not sync:
            print(f"WARNING: no {files.runs.name} is stored, downloading", file=sys.stderr)
        runs, standings = getruns()
        files.runs.write_text(runs)
        files.standings.write_text(standings)
    readruns()
    readstandings()
    if sync or not files.sourcefiles.is_file():
        tomli_w.dump(getsourcefiles(), files.sourcefiles.open("wb"))
    with files.sourcefiles.open("br") as f:
        glob.sourcefiles = tomllib.load(f)
    readsources()
    if sync or not files.distances.is_file():
        calcdistances()
        dumpdistances()
    loaddistances()
    print(*downscore(), "rewrites / copypastes in question")


def configread():
    if not files.config.is_file():
        print(f"WARNING: no {files.config.as_posix()} file, creating default one")
        configwrite()
    with files.config.open("br") as f:
        config.update(tomllib.load(f))


def configwrite():
    with files.config.open("bw") as f:
        tomli_w.dump(config, f)


def taskdiff(task, user, user2):
    try:
        source, source2 = glob.areas[task][user][1], glob.areas[task][user2][1]
    except KeyError as E:
        print("Not found:", E)
        return
    source, source2 = glob.areas[task][user][1], glob.areas[task][user2][1]
    res = "\n".join(unified_diff(source.split("\n"), source2.split("\n"), user, user2, lineterm=''))
    print(highlight(res, DiffLexer(), Terminal256Formatter(style=NativeStyle)))


def hyst(counter, title="", blocks=" ▁▂▃▄▅▆▇█", height=None):
    mn, mx = min(counter), max(counter)
    Mn, Mx = min(counter.values()), max(counter.values())
    if title:
        print("   ", title.center(mx - mn))
    if height is None:
        height = Mx - Mn + 1
    for i in range(height, -1, -1):
        print(f"{round(Mn + i * (Mx - Mn) / height):>3}: ", end="")
        for j in range(mn, mx + 1):
            h = counter[j] * height / (Mx - Mn)
            c = blocks[0] if h < i else (blocks[-1] if h >= i + 1 else blocks[round((h - i) * (len(blocks)))])
            print(c, end="")
        print()
    print("    ", mn, " " * (mx - mn - len(f"{mx} {mn}")), mx)


def isnumbers(*strings):
    for string in strings:
        try:
            res = float(string)
        except ValueError:
            res = None
            yield res
        else:
            yield int(res) if res.is_integer() else res


class Shell(cmd.Cmd):
    prompt = config["prompt"]

    @staticmethod
    def shplit(string):
        try:
            return shlex.split(string)
        except ValueError as E:
            print(E)
            return string.split()

    @staticmethod
    def match(text, select, *add, shift=0, prefix=""):
        vals = tuple(filter(None, select))
        return [prefix + key[shift:] for key in vals + add if key[shift:] and key.startswith(text.strip('"'))]

    @staticmethod
    def locateword(text, line, begidx, endidx):
        s = line[:endidx]
        if s.count('"') % 2:
            s += '"'
        return len(shlex.split(s)) - (s[-1] != " ")

    @staticmethod
    def getword(text, line, begidx, endidx):
        s = line[:endidx]
        if s.count('"') % 2:
            s += '"'
            return shlex.split(s)[-1]
        return text

    def do_config(self, arg):
        '''Configuration
\t\tShow all config parameters
<param>\t\tShow config parameter
<param>\t<value>\tSet config parameter
token\t\tSet password silently from stdin
        '''
        match self.shplit(arg):
            case []:
                for cname in config:
                    print(f"\t{cname}:\t{config[cname]}")
            case ["token"]:
                createtoken()
            case [param]:
                if param.startswith("-"):
                    if param[1:] in config:
                        del config[param[1:]]
                elif param in config:
                    print(f"{config[param]}")
                else:
                    print(f"ERROR: unknown '{param}' parameter")
            case [param, *values]:
                if param.startswith("!"):
                    param = param[1:]
                elif param not in config:
                    print(f"ERROR: unknown '{param}' parameter")
                numbers = tuple(isnumbers(*values))
                if None in numbers:
                    if isinstance(config[param], list):
                        config[param] = values
                    else:
                        config[param] = shlex.join(values)
                else:
                    config[param] = numbers if len(values) > 1 else numbers[0]

    def complete_config(self, text, line, begidx, endidx):
        match self.locateword(text, line, begidx, endidx):
            case 1:
                return self.match(text, config, "token")
            case 2:
                res = config.get(line.split()[1], "UNKNOWN")
                return [" ".join(res) if isinstance(res, list) else res]

    def do_write(self, arg):
        '''Write some changed data'''
        match arg:
            case "config":
                configwrite()

    def complete_write(self, text, line, begidx, endidx):
        fnames = ["config"]
        return self.match(text, fnames)

    def do_task(self, arg):
        '''List task statistics

\t\tList all tasks
task\t\tShow task statistic
task user\tShow user's task details
task user user2\tCompare task solutions
        '''
        if not arg:
            res = sorted((listtask(task), task) for task in glob.tasks)
            for (weight, ranged), task in res:
                print(f"{task}: {len(ranged)} total, copypaste weight {weight}")
        match self.shplit(arg):
            case [task]:
                if task in glob.tasks:
                    weight, ranged = listtask(task)
                    for total, dist, user, score in ranged:
                        print(f"{user}: total {total}, min {dist} / score {score}")
                    print(f"Copypaste weight {weight}")
            case [task, user]:
                if task in glob.tasks and user in glob.users:
                    for dist, paster in glob.areas[task][user][3]:
                        print(f"{paster}: {dist}")
                    print(highlight(glob.areas[task][user][1], PythonLexer(), Terminal256Formatter(style=NativeStyle)))
            case [task, user, user2]:
                taskdiff(task, user, user2)

    def complete_task(self, text, line, begidx, endidx):
        match self.locateword(text, line, begidx, endidx):
            case 1:
                return self.match(text, glob.tasks)
            case _:
                w = self.getword(text, line, begidx, endidx)
                return self.match(w, glob.users, shift=len(w) - len(text))

    def do_manual(self, arg):
        '''Alias for 'score manual'''
        self.do_score("manual")

    def do_score(self, arg):
        '''Show user scores

\t\tShow all users' scores
'manual'\t\tShow manual defined scores
user\t\tShow all user scores
user task\tShow detailed user task score
user task score\tSet manual user task score
user task -\tClear manual user task score
user task user2\tCompare solutions
        '''
        if not arg:     # TODO calculate
            for user, score in glob.users.items():
                print(f"{user}: {score['Score']}")
            return
        user, *tasks = self.shplit(arg)
        if user == "manual":
            for u in config["manual"]:
                for task, score in config["manual"][u].items():
                    print(f"{u} / {task} = {score}")
            return
        match tasks:
            case []:
                print(f"\t{user}")
                res = [f"{task}: {score[0]}" for task, score in glob.scores[user].items()]
                self.columnize(sorted(res))
                s = sum(int(score[0]) for score in glob.scores[user].values())
                print(f"\t{s}/{glob.users[user]['Score']}")
                if user in glob.scores:
                    print("Result:", *grade(user))
            case ["manual"]:
                for task, score in config["manual"][user].items():
                    print(f"{user} / {task} = {score}")
            case [task]:
                showselect(glob.allruns, *ejudge["default"], Prob=task, User_Name=user)
                print(f"Run: {glob.users[user][task]}")
                for dist, paster in glob.scores[user][task][3]:
                    print(f"{paster}: {dist}")
                print("Score:", glob.scores[user][task][0])
            case [task, score] if score.isdigit():
                config["manual"].setdefault(user, {})[task] = int(score)
                if int(score) > config["same_downscore"]:
                    config["independent"].setdefault(user, []).append(task)
            case [task, "-"]:
                if task in config["manual"].get(user, {}):
                    del config["manual"][user][task]
                    downscore()
            case [task, user2]:
                taskdiff(task, user, user2)

    def complete_score(self, text, line, begidx, endidx):
        match self.locateword(text, line, begidx, endidx):
            case 1:
                w = self.getword(text, line, begidx, endidx)
                return self.match(w, glob.users, "manual", shift=len(w) - len(text))
            case 2:
                return self.match(text, glob.tasks, "manual")
            case 3:
                w = self.getword(text, line, begidx, endidx)
                return self.match(w, glob.users, shift=len(w) - len(text))

    def do_stat(self, arg):
        """Show statistices"""
        print("Tasks by number of solutions:")
        ts = [f"{t}: {s}" for t, s in sorted((len(users), task) for task, users in glob.areas.items())]
        self.columnize(ts)
        print(f"Users: {len(glob.users)} / accounted {len(glob.scores)}")
        grades = Counter(grade(user)[0].strip("'") for user in glob.scores)
        passes = Counter(grade(user)[1].strip("'") for user in glob.scores)
        scores = Counter(grade(user)[2] for user in glob.scores)
        solved = Counter(grade(user)[3] for user in glob.scores)
        print(", ".join(f"{k}:{v}" for k, v in grades.items()))
        print(", ".join(f"{k}:{v}" for k, v in passes.items()))
        hyst(solved, title="Solved", height=8)
        hyst(scores, title="Scores", height=8)
        grsl = [(grade(user)[0].strip("'"), grade(user)[3]) for user in glob.scores]
        for gr in 'ОТЛ', 'ХОР+', 'ХОР', 'УДОВЛ':
            m = min((g, sl) for g, sl in grsl if g == gr)
            M = max((g, sl) for g, sl in grsl if g == gr)
            print(f"{gr}: min {m} / max {M}")

    def do_select(self, arg):
        '''[internal] Browse list of dicts'''
        obj, *selectors = self.shplit(arg)
        names = [par for par in selectors if "=" not in par]
        if names == ["default"]:
            names = ejudge["default"]
        filters = dict(par.split("=") for par in selectors if "=" in par)
        if len(names) == 1:
            print(*sorted(selectset(getattr(glob, obj), *names, **filters)))
        else:
            showselect(getattr(glob, obj), *names, **filters)

    def complete_select(self, text, line, begidx, endidx):
        if self.locateword(text, line, begidx, endidx) == 1:
            return self.match(text, glob.__dataclass_fields__.keys())
        if "=" not in line.split()[-1] or text == "" and line[begidx - 1] != "=":
            suffix = "=" in line and "=" or ""
            where = getattr(glob, line.split()[1])
            words = next(iter(where.values())) if isinstance(where, dict) else where[0]
            return [s + suffix for s in self.match(text, words, "default")]
        root, (field, val) = line.split()[1], line.split()[-1].split("=")
        variants = sorted(selectset(getattr(glob, root), field))
        return self.match(val, variants)

    def do_copypaste(self, arg):
        '''Display or register user+task as proved copypaste

?\t\tDisplay all useras with close enough solutions
\t\tDisplay all proved copypaste
user\t\tDisplay user's proved copypaste
user ?\t\tDisplay user's close enough solutions
user task\tRegister task as copypasted by user
user -task\tUnrgister task as copypasted by user
        '''
        self._do_user_tasks(arg, "copypaste")

    def _do_user_tasks(self, arg, table):
        if not arg:
            for user, tasks in config[table].items():
                print(f"{user}:", *tasks)
            return
        match self.shplit(arg):
            case ["?"]:
                plist = sorted((sum(len(a[3]) for a in tasks.values()), user) for user, tasks in glob.scores.items())
                for pastes, user in plist:
                    print(f"{user}: {pastes} / {grade(user)[0]}")
            case [user]:
                if user in glob.users:
                    print(*config[table].get(user, []))
            case [user, "?"]:
                for task, attrs in glob.scores[user].items():
                    for weight, buddy in attrs[3]:
                        print(f"{task}: {weight} = {buddy}")
            case [user, str(task)] if task.startswith("-"):
                if user in glob.users and task[1:] in config[table].get(user, []):
                    config[table][user].remove(task[1:])
                    if config[table][user] == []:
                        del config[table][user]
            case [user, str(task)]:
                if user in glob.users and task not in config[table].setdefault(user, []):
                    config[table][user].append(task)
        downscore()

    def complete_copypaste(self, text, line, begidx, endidx):
        match self.locateword(text, line, begidx, endidx):
            case 1:
                w = self.getword(text, line, begidx, endidx)
                return self.match(w, glob.users, shift=len(w) - len(text))
            case 2:
                return self.match(text, glob.tasks)

    def do_independent(self, arg):
        '''Display or register user+task as proved independent

\t\tDisplay all tasks proved independent
user\t\tDisplay user's  tasks proved independent
user task\tRegister task as independent by user
user -task\tUnrgister task as independent by user
        '''
        self._do_user_tasks(arg, "independent")

    def complete_independent(self, text, line, begidx, endidx):
        return self.complete_copypaste(text, line, begidx, endidx)

    def do_report(self, arg):
        '''Create a Moin report with HTML attachments. Use report publish for pablish also'''
        print(makereport())
        if arg.endswith("publish"):
            publish()

    def complete_report(self, text, line, begidx, endidx):
        return ["publish"]

    def do_sync(self, arg):
        '''Synchronize statistics and git'''
        remote = f"cd ~/{config['contest']} && git pull &&"\
                 "git add . && git commit -a -m ejst && git push && git pull || :"
        subprocess.check_output(["ssh", "-q", f"{config['ejuser']}@{config['ejudge']}", f"{remote}"], stdin=None)
        subprocess.check_output(["git", "pull"])
        init(sync=True)

    def do_search(self, arg):
        '''Search for something

string\tSearch for user name or user login
number\tSearch for run number
        '''
        match self.shplit(arg):
            case [str(substr)] if substr.isdigit():
                prob, status, name = selectitem(glob.allruns, "User_Name", "Prob", "Status", error=False, Run_Id=substr)
                if name:
                    print(f"{substr}: {prob} / {status} / {name}")
            case [str(substr)]:
                for user, login in glob.logins.items():
                    if substr.lower() in user.lower() or substr.lower() in login.lower():
                        print(f"{user}: {login}")

    def complete_search(self, text, line, begidx, endidx):
        if text.isdigit():
            runids = (str(glob.scores[user][task][4]) for user in glob.users for task in glob.tasks)
            return self.match(text, runids)
        elif text:
            return [w for w in chain(*glob.logins.items()) if text.lower() in w.lower()]

    def do_eval(self, arg):
        print(eval(arg))

    def complete_eval(self, text, line, begidx, endidx):
        if (dot := text.rfind(".")) > 0:
            return self.match(text[dot + 1:], dir(eval(text[:dot])), prefix=text[:dot + 1])
        else:
            return self.match(text, globals())

    def do_shell(self, arg):
        """Alias of "! [command [arguments]]". Run shell or execute command"""
        if arg:
            res = subprocess.run(self.shplit(arg), stdin=sys.stdin, capture_output=True)
            print(res.stdout.decode())
            if res.stderr:
                print(res.stderr.decode())
        else:
            subprocess.call([os.environ["SHELL"], "-l"])

    def do_EOF(self, arg):
        """Press Ctrl+D to exit"""
        return True

    def emptyline(self):
        pass

    def preloop(self):
        if files.history.is_file():
            readline.read_history_file(files.history.as_posix())
            readline.set_history_length(1000)
        atexit.register(readline.write_history_file, files.history.as_posix())


def main():
    init()
    for errors in range(100):
        try:
            Shell().cmdloop()
        except Exception as E:
            traceback.print_tb(E.__traceback__)
            print(f"[{errors}] {type(E).__name__}: {E}", file=sys.stderr)
        except KeyboardInterrupt:
            print("^C", file=sys.stderr)
        else:
            break
    else:
        print("Too many exceptions, exiting", file=sys.stderr)


if __name__ == "__main__":
    main()
