#!/usr/bin/env python3
'''
'''

# limit: 10240

import requests
import subprocess
import re
import os
from os.path import join, isfile, basename, dirname
import sys
import io
from itertools import islice
from glob import iglob
from collections import namedtuple, defaultdict
from functools import partial
import json
import time
import cmd
import traceback
import gzip
import zipfile
import autopep8
import multiprocessing
import pickle
import ast
import atexit
import pprint
import shutil
from editdistance import eval as distance
from difflib import unified_diff, HtmlDiff
try:
    import pygments
    import pygments.lexers
    import pygments.formatters
    if not all((os.isatty(1), os.getenv("TERM"), os.getenv("TERM") != "dumb")):
        raise ModuleNotFoundError("Not a proper terminal")
    def highlight(text, lexer, formatter):
        return pygments.highlight(text, getattr(pygments.lexers, lexer)(), getattr(pygments.formatters, formatter)(style=pygSTYLE))
except ModuleNotFoundError:
    def highlight(text, lexer, formatter):
        return(text)

TMPHTML="/tmp/ejstat.html"
TMPCSV="/tmp/ejstat.csv"
SITE = "ejudge.cs.msu.ru"
urlROLE = "new-master"
UNSITE = "uneex.org"
urlLECT = "LecturesCMC/PythonIntro2020"
CONTEST = "148"
actGET = "152"
actLOGOUT = "74"
dWORK = os.getcwd()
# TODO refactor cached n* → c*
nCRED = "credentials"
nPATHS = "paths"
nLOAD = "download"
nRUNS = "runs.csv"
nAUDIT = "audit"
nDATES = "dates"
nTASKS = "tasks.zip"
nFORMAT = "formatted.zip"
nSOURCE = "source"
nPREP = "prep.pickle"
nHISTORY = "history"
nDIFFHTML = "diff"
nDISTS = "dists"
nCONFIG = "config"
nREPORT = "report"
nATTACHMENTS = "attachments"
namesKEEP = [nCRED, nDATES, nHISTORY, nCONFIG]
namesRESET = [nPATHS, nLOAD, nRUNS, nTASKS, nFORMAT, nPREP, nDISTS]
dayHALF = 7
dayBONUS = 0.25
secDAY = 24*60*60
SKIPSCORE = 0.66
SCORELINE = "CCCCBBBBAAA"
sepFORMAT = "_@_"
zipMETHOD = zipfile.ZIP_DEFLATED
# TODO filter taskskip completely off the account
taskSKIP = ["HelloWorld"]
ADMINS = ["FrBrGeorge"]
distMIN = 7
distMAX = 100
sizeMAXDIFF = (0.5, 2)
sizeMAXCLUST = 7
pygSTYLE = 'native'
utTIME, utFAILS, utSTATUS, utPENALTY, utRUNID, utDIST, utNEIGH = range(7)

jsondump = partial(json.dump, indent="    ")

# TODO Config/args?
def loadconfig():
    if isfile(cfg := join(_MeDir, nCONFIG)):
        with open(cfg) as f:
            res = list(json.load(f))
            dummies = set(res[0] if len(res)>0 else taskSKIP)
            admins = res[1] if len(res)>1 else ADMINS
            fairlist = res[2] if len(res)>2 else {}
            indiv = res[3] if len(res)>3 else {}
        fairset = {k: set(v) for k, v in fairlist.items()}
    return dummies, admins, fairset, indiv

def saveconfig():
    Fairlist = {k: list(v) for k, v in Fairset.items()}
    with open(cfg := join(_MeDir, nCONFIG), "w") as f:
        return jsondump((list(Dummies), Admins, Fairlist, Individual), f)

def fairs(task, user=None):
    if user:
        Fairset.setdefault(task, set()).add(user)
    else:
        return Fairset.get(task, set())

dVAR=join(dWORK, "var")
URL = f"https://{SITE}/"
_Me = basename(sys.argv[0])
_MeDir = dirname(os.path.realpath(sys.argv[0]))
_Id = f"{_Me}.{SITE}.{CONTEST}"
dCACHE = join(os.environ["HOME"],".cache",_Id)
HFile = None

Dummies, Admins, Fairset, Individual = loadconfig()

def dodiff(a, b, n1, n2):
    res = list(unified_diff(a.split('\n'), b.split('\n'), n1, n2, lineterm=""))
    return res

def dohtmldiff(a, b, n1, n2):
    differ = HtmlDiff()
    return differ.make_file(a.split('\n'), b.split('\n'), n1, n2)

def debug(*ap, **an):
    print(*ap, file=sys.stderr, **an)
    sys.stderr.flush()

def date(secs):
    return time.strftime("%d %b", time.localtime(secs))

def ymd(y_m_d):
    return time.mktime(time.strptime(y_m_d, '%Y-%m-%d'))

def Cached(name, rw, mode="t", check=False, **kwargs):
    fname = join(dCACHE, name)
    if not isfile(fname) or os.stat(fname).st_size==0:
        with open(fname, mode+"w") as f:
            if not f.tell():
                print(f"Creating {name}")
                rw(f, True, **kwargs)
    with open(fname, mode+"r") as f:
        return rw(f, False, **kwargs)

def rwCred(f, new):
    if new:
        login = input("Login: ") or "scoreviewer"
        passwd = input("Password: ") or "password"
        return f.write(f"{login}\n{passwd}\n")
    else:
        return f.read().strip().split('\n')

def rwRuns(f, new):
    global Request
    if new:
        Admin, Passwd = Cached(nCRED, rwCred)
        Request = requests.post(URL+urlROLE, data = {
             "login": Admin,
              "password": Passwd,
              "contest_id": CONTEST,
              "role": "1" }
              )
        uRuns = re.sub(r"[&]action=\d+|$", f"&action={actGET}", Request.url)
        rRuns = requests.get(uRuns, cookies=Request.cookies)
        return f.write(rRuns.text)
    else:
        return f.read()

def rwDownload(f, new):
    if new:
        return f.write("done")
    else:
        TaskDates = Cached(nDATES, rwTaskDates)
        Runs = Cached(nRUNS, rwRuns)
        return Runs, TaskDates

def processData(lines, dates, sep=";"):
    L = lines.strip().split("\n")
    names = ["Path"] + L[0].split(sep) + ["junk"]
    Runline = namedtuple("Runline", names)
    idx = names.index("Run_Id")
    Runs = {}
    for l in islice(L, 1, None):
        line = l.split(sep)
        i = line[idx-1]
        if i not in Paths:
            print(f"New run: {i}", file=sys.stderr)
            continue
        run = Runline(Paths[i], *l.split(sep))
        Runs[run.Run_Id] = run
    return Runs, dates

def rwPaths(f, new):
    global Paths
    if new:
        Paths = {}
        for faudit in iglob(join(dVAR,"**",nAUDIT), recursive=True):
            with open(faudit) as fa:
                for l in fa:
                    cmd, data = l.strip().split(": ", 1)
                    if cmd == "Run-id":
                        Paths[data] = dirname(faudit)
                        break
        return jsondump(Paths, f)
    else:
        return json.load(f)

def rwTaskDates(f, new):
    if new:
        TaskDates = {}
        U = f"http://{UNSITE}/{urlLECT}" # ?action=raw"
        r = requests.get(U+"?action=raw")
        for l in r.text.split("\n"):
            if res := re.match(r"\|\|.*\[\[(/\d\d_[^|]+).*<<Date.*<<Date[(]([^T]+).*\|\|", l):
                page, date = res.groups()
                rr = requests.get(U+page+"?action=raw")
                for ll in rr.text.split("\n"):
                    if task := re.search(rf"<<EJCMC.\s*{CONTEST}\s*,\s*(\w+)", ll):
                        TaskDates[task.groups()[0]] = ymd(date)
        return jsondump(TaskDates, f)
    else:
        return json.load(f)

def runId(U, T):
    return Users[U]['Tasks'][T][utRUNID]

def rwTasks(f, new):
    Tasks = {T:{} for T in TaskDates}
    if new:
        zf = zipfile.ZipFile(f, "w" , zipMETHOD)
        for U, A in Members.items():
            for T in A['Tasks']:
                runid = A['Tasks'][T][utRUNID]
                try:
                    P = Paths[runid]
                except:
                    print(f"Error: no {runid} run", file=sys.stderr)
                    continue

                if isfile(fn := join(P, nSOURCE)):
                    ft = open(fn)
                elif isfile(fn := join(P, nSOURCE+".gz")):
                    ft = gzip.open(fn)
                else:
                    print(f"Error: no source in {Runs[runid].Path}", file=sys.stderr)
                    continue
                with ft:
                    data = ft.read()
                    Tasks[T][U] = data if type(data) is str else data.decode()
                # TODO date
                zf.writestr(f"{T}/{U}/{runid}.py", Tasks[T][U])
        return zf.close()
    else:
        zf = zipfile.ZipFile(f, "r")
        for entry in zf.infolist():
            if entry.is_dir(): continue
            T, U, p = entry.filename.split("/")
            Tasks[T][U] = zf.read(entry.filename).decode()
        return Tasks

def fixcode(prog):
    return autopep8.fix_code(prog, options={'aggressive': 3})

def rwFormat(f, new):
    Format = {T:{} for T in Tasks}
    if new:
        pool = multiprocessing.Pool()
        jobs = [prog for T, users in Tasks.items() for U, prog in users.items()]
        # TODO syntax bomb time protection
        res = list(reversed(pool.map(fixcode, jobs)))
        pool.close()
        
        for T, users in Tasks.items():
            for U, prog in users.items():
                Format[T][U] = res.pop()

        zf = zipfile.ZipFile(f, "w" , zipMETHOD)
        for T, users in Format.items():
            for U, prog in users.items():
                zf.writestr(f"{T}/{len(prog):05}{sepFORMAT}{int(runId(U, T)):0{lenRun}}{sepFORMAT}{U}.py", Format[T][U])

        return zf.close()
    else:
        zf = zipfile.ZipFile(f, "r")
        for entry in zf.infolist():
            if entry.is_dir(): continue
            T, prog = entry.filename.split("/")
            l, r, U = prog.split(sepFORMAT)
            U = U[:-3]
            Format[T][U] = zf.read(entry.filename).decode()
        return Format

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)))}
reASTBR = re.compile(r"[\[\]\{\}\(\)\,\ ]+")

def astformat(node):
        if isinstance(node, ast.AST):
            args = []
            for field in node._fields:
                    value = getattr(node, field)
                    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 preparate(prog):
    return astformat(ast.parse(prog, "p.py"))

def rwPrep(f, new):
    Prep = {}
    if new:
        pool = multiprocessing.Pool()
        jobs = [prog for T, users in Format.items() for U, prog in users.items()]
        res = list(reversed(pool.map(preparate, jobs)))
        pool.close()

        for T, users in Tasks.items():
            for U, prog in users.items():
                Prep.setdefault(T, {})[U] = preparate(prog)
        pickle.dump(Prep, f)
    else:
        return pickle.load(f)

def smartdist(p1, p2, i, T, u):
    if sizeMAXDIFF[0] < len(p1)/len(p2) < sizeMAXDIFF[1] and T not in Dummies:
        return distance(p1, p2), i, u
    return distMAX, i, u

def measureClusters():
    Clusters = {}
    pool = multiprocessing.Pool()
    jobs = [prog for T, users in Tasks.items() for U, prog in users.items()]
    res = list(reversed(pool.map(autopep8.fix_code, jobs)))
    for T, preps in Prep.items():
        Clus = [] # clusters: [ [U1, U2, …], [U3, U4, …], …]

        for U, prep in preps.items():
            if Clus and U not in fairs(T):
                jobs = [(preps[u], prep, i, T, u) for i,c in enumerate(Clus) for u in c]
                dist, i, u = min(pool.starmap(smartdist, jobs))
            else:
                dist, i, u = distMAX, 0, None
            if dist >= distMIN:
                Clus.append([U])
            else:
                Clus[i].append(U)
            Members[U]['Tasks'][T][utDIST:utNEIGH+1] = dist, u
            if u and Members[u]['Tasks'][T][utDIST] > dist:
                Members[u]['Tasks'][T][utDIST:utNEIGH+1] = dist, U

        Clusters[T] = Clus
    pool.close()
    return Clusters, Members

def rwClusters(f, new):
    if new:
        return jsondump(measureClusters(), f)
    else:
        return json.load(f)

def scoredelay(date, full, gap):
    full += int(secDAY*dayBONUS)
    return (date <= full)*2 + (date <= full + gap*24*60*60) + 1

def createUsers(Runs):
    global Users
    Users = {}
    for run in Runs.values():
        if run.User_Login not in Users:
            Users[run.User_Login] = {
                "UID": int(run.User_Id),
                "Login": run.User_Login,
                "Name": run.User_Name or run.User_Login,
                "Tasks": {},
                "Solved": 0,
                "Score": 0,
                "Penalty": 0,
            }
        utasks = Users[run.User_Login]["Tasks"]
        if run.Prob not in utasks:
            # time, fails, deadline status, after penalty, runid, distance, closest-user
            utasks[run.Prob] = [0, 0, 0, 0, None, distMAX, None]
        if run.Stat_Short == "OK":
            if not utasks[run.Prob][utTIME] or utasks[run.Prob][utTIME] > int(run.Time):
                score = scoredelay(1,1,1) if run.Prob in taskSKIP else scoredelay(int(run.Time), TaskDates[run.Prob], dayHALF)
                utasks[run.Prob][utTIME] = int(run.Time)
                utasks[run.Prob][utSTATUS] = utasks[run.Prob][utPENALTY] = score
                utasks[run.Prob][utRUNID] = run.Run_Id
        else:
            utasks[run.Prob][utFAILS] += 1

def userPaste(U):
    return {T:(res[utNEIGH], res[utDIST]) for T, res in Members[U]['Tasks'].items() if res[utDIST] < distMIN }

def scoreUsers(Users):
    for user, stat in Users.items():
        stat["Score"] = 0
        # Delete never OK probes
        stat["Tasks"] = {k: v for k, v in stat["Tasks"].items() if v[utRUNID] is not None}
        for task in stat["Tasks"].values():
            stat["Score"] += task[utSTATUS]
            if task[2]: # XXX 2 is ut*?
                stat["Solved"] += 1

def calcformats():
    global lenLogin, lenName, lenTask, lenRun
    lenLogin = len(max((run.User_Login for run in Runs.values()), key=len))
    lenName = len(max((run.User_Name for run in Runs.values()), key=len))
    lenTask = len(max(TaskDates, key=len))
    lenRun = len(str(len(Runs)))

def calcscorelimits():
    global Maxscore, Minscore, Grades
    Maxscore = scoredelay(1,1,1)*len(TaskDates)
    Minscore = int(Maxscore * SKIPSCORE)
    Grades = dict(reversed([(SCORELINE[i], int(Minscore+(Maxscore-Minscore)*i/len(SCORELINE))) for i in range(len(SCORELINE))]))
    Grades['Max'], Grades['Min'] = Maxscore, Minscore

def ruscore(user, zachot = False):
    if user not in Members: return ""
    score = Members[user]['Penalty']
    if score >= Grades['A']: return "зач" if zachot else "отл"
    if score >= Grades['B']: return "зач" if zachot else "хор"
    if score >= Grades['C']: return "" if zachot else "удовл"
    return ""

def judgeMembers():
    for U in Members:
        for T, res in Members[U]['Tasks'].items():
            if res[utDIST] < distMIN:
                if not res[utNEIGH]:
                    print(f"{U} {T} NO")
                else:
                    him = Members[res[utNEIGH]]['Tasks'][T]
                    res[utPENALTY] = him[utPENALTY] = 1
    for U in Individual:
        for T in Individual[U]:
            Members[U]['Tasks'][T][utPENALTY] = Individual[U][T]
    for U, stat in Members.items():
        stat["Penalty"] = 0
        for T in stat["Tasks"].values():
            stat["Penalty"] += T[utPENALTY]


def genMoin(path):
    Page, Attach = defaultdict(list), []
    Page[""].append(f"= {CONTEST} results =")
    Page[""].append(f"|| '''User''' || '''Name ''' || '''Grade''' || '''Pass''' || '''Score/Max''' ||") 
    for U, A in sorted(Users.items()):
        if U in Members: A = Members[U]
        N, oc, za, P, S = A['Name'], ruscore(U), ruscore(U, 1), A['Penalty'], A['Score']
        Page[""].append(f"|| [[#{U}|{U}]] || <<Anchor(_{U})>>{N} || {oc}|| {za} || {P}/{S} ||") 
        Page[U].append(f"=== {A['Name']} ===")
        Page[U].append(f"<<Anchor({U})>>[[#_{U}|Back]]")
        for T, (time, fails, actual, score, runid, dist, neigh) in A['Tasks'].items():
            if dist < distMIN:
                aname = f"diff_{T}_{U}_{neigh}.html"
                Attach.append((aname, Format[T][U], Format[T][neigh], T+"/"+U, T+"/"+neigh))
                diff = f"[[{path}/{aname}|{neigh} = {dist}]]"
            else:
                diff = ""
            time, day = date(time), date(TaskDates[T])
            Page[U].append(f"|| `{T}` || '''{score}'''/{actual} || {time}/{day} || {runid} || {diff} ||")
    repdir = join(dCACHE, nREPORT)
    if(os.path.isdir(repdir)):
        shutil.rmtree(repdir)
    os.makedirs(join(repdir, nATTACHMENTS))
    fcontent = join(repdir, "content.moin")
    with open(fcontent, "w") as f:
        f.writelines([s+'\n' for t in Page.values() for s in t])
    for att, t1, t2, u1, u2 in Attach:
        diff = dohtmldiff(t1, t2, u1, u2)
        with open(join(repdir, nATTACHMENTS, att), "w") as f:
            f.write(diff)
    print(fcontent)

def init():
    global Runs, Paths, TaskDates, Members, Tasks, Format, Prep, Clusters
    t = time.time()
    os.makedirs(dCACHE, exist_ok=True)
    Paths = Cached(nPATHS, rwPaths)
    Runs, TaskDates = processData(*Cached(nLOAD, rwDownload))
    createUsers(Runs)
    calcformats()
    calcscorelimits()
    scoreUsers(Users)
    Members = {k: v for k, v in Users.items() if v['Score'] >= Minscore }
    Tasks = Cached(nTASKS, rwTasks, "b")
    Format = Cached(nFORMAT, rwFormat, "b")
    Prep = Cached(nPREP, rwPrep, "b")
    Clusters, Members = Cached(nDISTS, rwClusters)
    judgeMembers()
    # TODO Final score HTML tree
    print(f"Time elapsed: {time.time()-t:.1f}")

def erase(*Targets, Full=False, Empty=False):
    for entry in iglob(join(dCACHE,"*"), recursive=False):
        if isfile(entry) and (not Empty or os.stat(entry).st_size==0) \
                         and (Full or basename(entry) not in namesKEEP) \
                         and (not Targets or basename(entry) in Targets):
            print(f"Erasing {basename(entry)}")
            os.unlink(entry)

class Shell(cmd.Cmd):
    intro = f"\n\t{CONTEST} contest +{dayBONUS} day bonus deadline\n"
    prompt = "Python2020> "
    curuser, curtask = None, None

    def do_users(self, arg):
        '''
        users       List actual users
        users MIN   List users who reach at list MIN score
                    MIN is A, B, C, Min, Max or number
        users ALL   List all users
        '''
        Staff = Users if arg in ("ALL", "all") else Members
        Total, Min = 0, Grades.get(arg, int(arg) if arg.isdigit() else 0)
        for S in sorted(Staff.values(), key=lambda u: u['Penalty']):
            if S['Penalty'] >= Min:
                Total += 1 
                print(f"{S['Login']:{lenLogin}}: {ruscore(S['Login'])}/{ruscore(S['Login'], 1)}: {S['Penalty']:3}/{S['Score']} {S['Name']}")
        print(f"{Total=}")

    def complete_users(self, text, line, begidx, endidx):
        return [grade for grade in ['ALL']+Grades if grade.startswith(text)]
    
    def do_user(self, arg):
        '''
        user USER           Show any USER statistics
        user USER TASK N    Set individual USER/TASK score to N
        user USER TASK -    Unset individual USER/TASK score
        '''

        if len(arg.split()) == 3:
            u, t, n = arg.split()
            if u in Members and t in Tasks:
                if n.isdigit():
                    Individual.setdefault(u, {})[t] = int(n)
                    saveconfig()
                elif n.startswith('-'):
                    if u in Individual and t in Individual[u]:
                        del Individual[u][t]
                    saveconfig()
                else:
                    print(f"Invalid score {n}")
            else:
                print(f"Cannot set {u} score for {t}")
            return

        arg = self.curuser = arg if arg in Users else self.curuser or list(Users)[0]
        U = Members[arg] if arg in Members else Users[arg]
        print(f"\t{U['Name']} ({U['Login']}):")
        for T in sorted(U['Tasks'], key=lambda t: U['Tasks'][t][utPENALTY], reverse=True):
            time, fails, actual, score, runid, dist, neigh = U['Tasks'][T]
            t1, t2, dt = date(TaskDates[T]), date(time), int((time-TaskDates[T])/60/60/24)
            print(f"{T:<{lenTask}}: {score}/{actual} ({dt} = {t1} - {t2}) {fails=:<2}")
        print(f"{arg in Members and 'PASSED' or 'FAILED'}: Total {U['Solved']}/{len(TaskDates)}, score: {U['Penalty']}/{Maxscore}")
        if arg in Individual:
            print("Individual scores:")
            for t in Individual[arg]:
                print("\t", t, Individual[arg][t])
        if arg in Members:
            if pastes := userPaste(arg):
                print()
                for T, (u, d) in pastes.items():
                    print(f"{T}: {u}, distance {d}")
            print(f"\t{ruscore(arg)} / {ruscore(arg, 1)}")

    def complete_user(self, text, line, begidx, endidx):
        cmd = line[:begidx].split()
        if len(cmd) == 1:
            return [name for name in Users if name.startswith(text)]
        if len(cmd) == 2:
            return [name for name in Tasks if name.startswith(text)]
        if len(cmd) == 3:
            return list('01234')

    def do_compare(self, arg):
        '''
        compare USER1 USER2     Compare and show distances of any solution
        '''
        if len(arg.split()) != 2: return
        U, H = arg.split()
        for u in U, H:
            if u not in Members:
                print(f"Unknown user '{u}'")
                return

        D = sorted((distance(Prep[T][U], Prep[T][H]), T) for T in Tasks if U in Prep[T] and H in Prep[T])
        print("{U} {H} distances:", *reversed(D), sep="\n")

    def complete_compare(self, text, line, begidx, endidx):
        return [name for name in Members if name.startswith(text)]

    def do_manualscores(self, arg):
        '''
        List individual scores. Use "user task score" to modify
        '''
        for U in Individual:
            print(f"{U}:", end=" ")
            for T, s in Individual[U].items():
                print(f"{T} {s};", end=" ")
            print()

    def do_info(self, arg):
        '''
        Show overaLL info
        '''
        Actual = sum(len(T['Tasks']) for T in Members.values())
        print(f"{CONTEST=} Tasks: {len(TaskDates)} Tries: {len(Runs)}")
        print(f"{Grades=}")
        print(f"Users: {len(Users)}/{len(Members)} (tasks in question: {Actual})")

    def do_reset(self, arg):
        '''
        Re-create databases
        '''
        erase(*arg.split())
        init()

    def complete_reset(self, text, line, begidx, endidx):
        return [name for name in namesRESET if name.startswith(text)]
    
    def do_set(self, arg):
        '''
        set VAR=[VALUE]     Display ejstat.py global namespace object VAR od set it to VALUE
        '''
        name, *val = arg.split("=")
        if val:
            globals()[name] = eval(val[0])
        elif name:
            print(f"{name}={globals()[name]}")
        else:
            for name, val in globals().items():
                print(f"{name}={repr(val)[:60]}")

    def complete_set(self, text, line, begidx, endidx):
        return [name for name in globals() if name.startswith(text) and not name.startswith("_") and not callable(globals()[name])]
    
    def do_tasks(self, arg):
        '''
        Show all tasks along with copypaste clusters
        '''
        for T, Clus in Clusters.items():
            print(f"\n\t{T}:")
            if max(map(len, Clus)) >= sizeMAXCLUST:
                res = "COMMON"
            else:
                res = "\n".join(f"{len(C)}: "+" ".join(C) for C in Clus if len(C)>1)
            if res: print(res)

    def do_task(self, arg):
        '''
        task [TASK]            Show TASK statistics
        task TASK USER         Show USER solution of TASK
        task TASK USER1 USER1  Show diffference beween USER1 ans USER2 solutions
        '''
        if not arg:
            arg = self.curtask or list(TaskDates)[-1]
        cmd = arg.split()
        if cmd[0] not in Tasks: return print(f"No {cmd[0]} task")
        self.curtask = cmd[0]
        print(f"\n\t{self.curtask}:")
        if len(cmd) == 1:
            print("1:", *(C[0] for C in Clusters[cmd[0]] if len(C) == 1))
            print("\n".join(f"{len(C)}: "+" ".join(C) for C in Clusters[cmd[0]] if len(C)>1))
            return
        users = Format[cmd[0]]
        if cmd[1] not in users: return print(f"No {cmd[1]} user")
        elif len(cmd) == 2:
            print(highlight(users[cmd[1]], "Python3Lexer", "Terminal256Formatter"))
            return
        if cmd[2] not in users: return print(f"No {cmd[2]} user")
        elif len(cmd) == 3:
            r1, r2 = runId(cmd[1], cmd[0]), runId(cmd[2], cmd[0])
            if int(r1) > int(r2):
                r1, r2, cmd[1], cmd[2] = r2, r1, cmd[2], cmd[1]
            res = dodiff(users[cmd[1]], users[cmd[2]], f"{r1}-{cmd[1]}", f"{r2}-{cmd[2]}")
            dist = distance(Prep[cmd[0]][cmd[1]], Prep[cmd[0]][cmd[2]])
            if res:
                print(highlight("\n".join(res), "DiffLexer", "TerminalFormatter"))
            if res and distance:
                print(f"Distance: {dist}")
            else:
                print("!!! IDENTICAL !!!")
        elif len(cmd) >= 4:
            dfile = join(dCACHE, f"{nDIFFHTML}-{cmd[1]}-{cmd[2]}.html")
            diff = dohtmldiff(users[cmd[1]], users[cmd[2]], cmd[1], cmd[2])
            with open(dfile, "w") as f:
                f.write(diff)
            print(f"{dfile}")
            if len(cmd)> 4:
                subprocess.Popen(["firefox", dfile])

    def complete_task(self, text, line, begidx, endidx):
        pos = len(cmd := line[:begidx].split())
        if pos == 1:
            return [name for name in Tasks if name.startswith(text)]
        if pos in (2, 3):
            return [name for name in Format[cmd[1]] if name.startswith(text)]
        if pos >= 4:
            return ["html"]

    def do_report(self, arg):
        '''
        report PATH     Generate Moin-style text report and diff files in PATH
        '''
        genMoin(arg)

    def do_who(self, arg):
        '''
        who TEXT        Search users with name TEXT
        '''
        parts = arg.lower().split()
        for U, A in Users.items():
            if all(s in A['Name'].lower() for s in parts) or any(s in U for s in parts):
                print(f"{U}: {A['Name']}")

    def do_config(self, arg):
        '''
        config                  Print current configuration
        config dummy [[-]TASK]  Print dummy tasks or add/remove TASK from dummies
        config fair [[-]TASK [USER]]
                                Print fair clusters or update them
        '''
        global Dummies
        cmd = arg.split()
        if len(cmd) < 2:
            if not cmd or cmd[0]=="dummy":
                print("Dummies:", *Dummies)
            if not cmd or cmd[0]=="fair":
                print("Fair clusters:")
                pprint.pprint(Fairset)
            if not cmd or cmd[0]=="individual":
                print("Individual scores:")
                pprint.pprint(Individual)
            return
        if remove := cmd[1].startswith("-"):
            cmd[1] = cmd[1][1:]
        if cmd[1] not in Tasks:
            print(f"Unknown task {cmd[1]}")
        elif len(cmd) >= 2 and cmd[0] == "dummy":
            if remove:
                Dummies -= {cmd[1]}
            else:
                Dummies.add(cmd[1])
            saveconfig()
        elif cmd[0] == "fair":
            if len(cmd) == 2:
                print(f"Fair in {cmd[1]}:", *fairs(cmd[1]))
            else:
                if cmd[2] not in Users:
                    print(f"Unknown user {cmd[2]}")
                else:
                    if remove:
                        if fa := fairs(cmd[1]) and cmd[2] in fa:
                            fa.remove(cmd[2])
                    else:
                        fairs(cmd[1], cmd[2])
                saveconfig()
        else:
            print(f"Unknown command {arg}")

    def complete_config(self, text, line, begidx, endidx):
        cmd = line[:begidx].split()
        if len(cmd) == 1:
            return [name for name in ("dummy", "fair") if name.startswith(text)]
        elif len(cmd) == 2:
            return [name for name in Tasks if name.startswith(text)]
        elif cmd[1] == 'fair':
            return [name for name in Members if name.startswith(text)]

    def do_eval(self, arg):
        '''
        eval EXPR       Print arbitrary eval(EXPR) on ejstat.py global namespace
        '''
        print(eval(arg))

    def complete_eval(self, text, line, begidx, endidx):
        return [name for name in globals() if name.startswith(text)]

    def preloop(self):
        global HFile
        if not HFile:
            import readline
            HFile = join(dCACHE, nHISTORY)
            if isfile(HFile):
                readline.read_history_file(HFile)
                readline.set_history_length(1000)
            atexit.register(readline.write_history_file, HFile)

    def emptyline(self):
        pass

    def do_EOF(self, arg):
        return True

def main():
    erase(Full="!!!" in sys.argv, Empty="!" not in sys.argv)
    init()
    while True:
        try:
            Shell().cmdloop()
        except Exception as E:
            traceback.print_tb(E.__traceback__)
            print(f"{type(E).__name__}: {E}")
        except KeyboardInterrupt as E:
            print("^C")
        else:
            break

main()
