#!/usr/bin/env python3
import argparse
import base64
import hashlib
import http.client
import json
import os
import secrets
import struct
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urlsplit
from urllib.request import Request, urlopen

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

API = "https://bt7.api.mega.co.nz/cs"
SITE = "https://transfer.it"
UA = "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
MB = 1048576
API_ERRORS = {
    -1: "EINTERNAL",
    -2: "EARGS",
    -3: "EAGAIN",
    -4: "ERATELIMIT",
    -5: "EFAILED",
    -6: "ETOOMANY",
    -7: "ERANGE",
    -8: "EEXPIRED",
    -9: "ENOENT",
    -10: "ECIRCULAR",
    -11: "EACCESS",
    -12: "EEXIST",
    -13: "EINCOMPLETE",
    -14: "EKEY",
    -15: "ESID",
    -16: "EBLOCKED",
    -17: "EOVERQUOTA",
    -18: "ETEMPUNAVAIL",
    -19: "ETOOMANYCONNECTIONS",
}


class TiError(Exception):
    pass


class UploadExpired(TiError):
    pass


def b64e(data):
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def b64d(text):
    if isinstance(text, str):
        text = text.encode()
    return base64.urlsafe_b64decode(text + b"=" * ((-len(text)) % 4))


def a32b(words):
    return struct.pack(">%dI" % len(words), *words)


def b2a32(data):
    if len(data) % 4:
        data += b"\x00" * (4 - len(data) % 4)
    return list(struct.unpack(">%dI" % (len(data) // 4), data))


def rand_a32(count):
    return list(struct.unpack(">%dI" % count, secrets.token_bytes(count * 4)))


def aes_ecb(key, data):
    enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
    return enc.update(data) + enc.finalize()


def attr_key(words):
    w = list(words) + [0] * max(0, 8 - len(words))
    return a32b([w[0] ^ w[4], w[1] ^ w[5], w[2] ^ w[6], w[3] ^ w[7]])


def encrypt_attr(attrs, words):
    raw = b"MEGA" + json.dumps(attrs, separators=(",", ":")).encode()
    raw += b"\x00" * ((-len(raw)) % 16)
    enc = Cipher(algorithms.AES(attr_key(words)), modes.CBC(b"\x00" * 16)).encryptor()
    return b64e(enc.update(raw) + enc.finalize())


def decrypt_attr(text, words):
    raw = b64d(text)
    if len(raw) % 16:
        raw += b"\x00" * (16 - len(raw) % 16)
    dec = Cipher(algorithms.AES(attr_key(words)), modes.CBC(b"\x00" * 16)).decryptor()
    plain = dec.update(raw) + dec.finalize()
    if not plain.startswith(b"MEGA"):
        return {}
    body = plain[4:].rstrip(b"\x00").rstrip()
    end = body.rfind(b"}")
    if end < 0:
        return {}
    try:
        value = json.loads(body[: end + 1].decode())
    except (ValueError, UnicodeDecodeError):
        return {}
    return value if isinstance(value, dict) else {}


def mac_of_chunk(data, ukey):
    key = a32b(ukey[:4])
    nonce = a32b(ukey[4:6])
    if not data:
        return nonce + nonce
    padded = data + b"\x00" * ((-len(data)) % 16)
    enc = Cipher(algorithms.AES(key), modes.CBC(nonce + nonce)).encryptor()
    return (enc.update(padded) + enc.finalize())[-16:]


def encrypt_chunk(data, ukey, offset):
    key = a32b(ukey[:4])
    nonce = a32b(ukey[4:6])
    iv = nonce + struct.pack(">Q", offset // 16)
    enc = Cipher(algorithms.AES(key), modes.CTR(iv)).encryptor()
    ciphertext = enc.update(data) + enc.finalize()
    return ciphertext, mac_of_chunk(data, ukey)


def condense(macs, ukey):
    acc = b"\x00" * 16
    key = a32b(ukey[:4])
    for mac in macs:
        acc = aes_ecb(key, bytes(a ^ b for a, b in zip(acc, mac)))
    return acc


def build_filekey(ukey, macs):
    mac = condense(macs, ukey) if macs else b"\x00" * 16
    m = struct.unpack(">4I", mac)
    meta = (m[0] ^ m[1], m[2] ^ m[3])
    return [
        ukey[0] ^ ukey[4],
        ukey[1] ^ ukey[5],
        ukey[2] ^ meta[0],
        ukey[3] ^ meta[1],
        ukey[4],
        ukey[5],
        meta[0],
        meta[1],
    ]


def chunk_sizes(size):
    chunks = []
    pos = 0
    index = 0
    while pos < size:
        index += 1
        step = index * 131072 if index <= 8 else MB
        step = min(step, size - pos)
        chunks.append((pos, step))
        pos += step
    return chunks


def derive_password(xh, password):
    raw = b64d(xh)
    salt = raw[-6:] * 3
    return b64e(hashlib.pbkdf2_hmac("sha256", password.strip().encode(), salt, 100000, 32))


def human_size(value):
    value = float(value)
    for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
        if value < 1024 or unit == "TiB":
            if unit == "B":
                return "%d %s" % (int(value), unit)
            return "%.1f %s" % (value, unit)
        value /= 1024


def human_time(seconds):
    if seconds <= 0 or seconds > 359999:
        return "--:--"
    seconds = int(seconds)
    if seconds >= 3600:
        return "%d:%02d:%02d" % (seconds // 3600, seconds % 3600 // 60, seconds % 60)
    return "%02d:%02d" % (seconds // 60, seconds % 60)


class Progress:
    def __init__(self, label, total, width=24):
        self.label = (label[:24] + "...") if len(label) > 27 else label
        self.total = total
        self.width = width
        self.done = 0
        self.start = time.time()
        self.samples = [(self.start, self.done)]
        self.last_render = 0.0
        self.last_len = 0
        self.last_speed = 0.0
        self.active = False

    def _speed(self, now):
        self.samples.append((now, self.done))
        while len(self.samples) > 2 and now - self.samples[0][0] > 5.0:
            self.samples.pop(0)
        t0, b0 = self.samples[0]
        delta = now - t0
        if delta >= 0.3:
            self.last_speed = (self.done - b0) / delta
        return self.last_speed

    def average(self):
        elapsed = time.time() - self.start
        if elapsed <= 0:
            return 0.0, 0.0
        return elapsed, self.done / elapsed

    def render(self, force=False):
        now = time.time()
        if not force and now - self.last_render < 0.1:
            return
        self.last_render = now
        speed = self._speed(now)
        frac = (self.done / self.total) if self.total else 1.0
        frac = max(0.0, min(1.0, frac))
        filled = int(self.width * frac)
        bar = "=" * filled + (">" if filled < self.width else "") + "-" * max(0, self.width - filled - (1 if filled < self.width else 0))
        eta = (self.total - self.done) / speed if speed > 0 and self.total else 0
        _, avg = self.average()
        line = "%s [%s] %5.1f%% %s/%s %s/s avg %s/s ETA %s" % (
            self.label,
            bar,
            frac * 100.0,
            human_size(self.done),
            human_size(self.total),
            human_size(speed),
            human_size(avg) if self.done else "0 B",
            human_time(eta),
        )
        pad_len = max(0, self.last_len - len(line))
        sys.stderr.write("\r" + line + " " * pad_len)
        sys.stderr.flush()
        self.last_len = len(line)
        self.active = True

    def add(self, count):
        self.done += count
        self.render()

    def set(self, done):
        self.done = done
        self.samples = [(time.time(), done)]
        self.last_speed = 0.0
        self.render(force=True)

    def close(self):
        if self.active:
            self.render(force=True)
            sys.stderr.write("\n")
            sys.stderr.flush()
            self.active = False


_API_LOCK = threading.Lock()
_API_CONN = None
_API_PARTS = urlsplit(API)


def _api_drop():
    global _API_CONN
    conn = _API_CONN
    _API_CONN = None
    if conn is not None:
        try:
            conn.close()
        except OSError:
            pass


def _api_connection():
    global _API_CONN
    if _API_CONN is None:
        _API_CONN = http.client.HTTPSConnection(
            _API_PARTS.hostname, _API_PARTS.port or 443, timeout=60
        )
    return _API_CONN


def api_request(payload, sid=None, xh=None, pw=None):
    params = {"id": str(secrets.randbelow(1 << 30))}
    if xh is not None:
        params["x"] = xh
        if pw:
            params["pw"] = pw
    elif sid:
        params["sid"] = sid
    body = json.dumps(payload if isinstance(payload, list) else [payload]).encode()
    path = _API_PARTS.path + "?" + urlencode(params)
    headers = {
        "Content-Type": "text/plain;charset=UTF-8",
        "Origin": SITE,
        "Referer": SITE + "/",
        "User-Agent": UA,
    }
    with _API_LOCK:
        for attempt in range(5):
            try:
                conn = _api_connection()
                conn.request("POST", path, body=body, headers=headers)
                resp = conn.getresponse()
                raw = resp.read()
                status = resp.status
            except (OSError, http.client.HTTPException) as exc:
                _api_drop()
                if attempt < 4:
                    time.sleep(0.5 * (attempt + 1))
                    continue
                raise TiError("API connection failed: %s" % exc)
            if status != 200:
                _api_drop()
                if status >= 500 and attempt < 4:
                    time.sleep(1 + attempt)
                    continue
                raise TiError("HTTP %s from API: %s" % (status, raw[:200].decode(errors="replace")))
            try:
                data = json.loads(raw)
            except ValueError:
                _api_drop()
                raise TiError("invalid API response: %r" % raw[:200])
            code = None
            if isinstance(data, int):
                code = data
            elif isinstance(data, list) and len(data) == 1 and isinstance(data[0], int):
                code = data[0]
            if code is not None and code < 0:
                if code == -3 and attempt < 4:
                    time.sleep(1 + attempt)
                    continue
                raise TiError("API error %d (%s)" % (code, API_ERRORS.get(code, "unknown")))
            return data[0] if isinstance(payload, dict) else data
    raise TiError("API request failed")


def create_session():
    master = rand_a32(4)
    password_key = rand_a32(4)
    ssc = rand_a32(4)
    key_blob = aes_ecb(a32b(password_key), a32b(master))
    ts_blob = a32b(ssc) + aes_ecb(a32b(master), a32b(ssc))
    user = api_request({"a": "up", "k": b64e(key_blob), "ts": b64e(ts_blob)})
    if not isinstance(user, str):
        raise TiError("up returned unexpected data")
    res = api_request({"a": "us", "user": user})
    if not isinstance(res, dict) or not res.get("tsid"):
        raise TiError("us returned unexpected data")
    return user, res["tsid"]


def create_transfer(sid, title):
    word_key = rand_a32(4)
    attrs = encrypt_attr({"name": title, "mtime": int(time.time())}, word_key)
    res = api_request({"a": "xn", "at": attrs, "k": b64e(a32b(word_key))}, sid=sid)
    if isinstance(res, list) and len(res) == 2 and res[0] == 0 and isinstance(res[1], list):
        res = res[1]
    if not (isinstance(res, list) and len(res) == 2 and all(isinstance(v, str) for v in res)):
        raise TiError("xn returned unexpected data: %r" % (res,))
    return res[0], res[1]


def request_upload_url(sid, size):
    for ssl in (2, 1):
        res = api_request({"a": "u", "s": size, "ssl": ssl}, sid=sid)
        if isinstance(res, list) and res:
            res = res[0]
        if isinstance(res, dict) and res.get("p"):
            return res["p"]
    raise TiError("u returned no upload URL")


class ChunkPoster:
    def __init__(self, url):
        parts = urlsplit(url)
        self.scheme = parts.scheme or "https"
        self.host = parts.hostname
        self.port = parts.port or (443 if self.scheme == "https" else 80)
        self.path = parts.path.rstrip("/")
        self.local = threading.local()

    def _connection(self):
        conn = getattr(self.local, "conn", None)
        if conn is None:
            if self.scheme == "https":
                conn = http.client.HTTPSConnection(self.host, self.port, timeout=180)
            else:
                conn = http.client.HTTPConnection(self.host, self.port, timeout=180)
            self.local.conn = conn
        return conn

    def _drop(self):
        conn = getattr(self.local, "conn", None)
        self.local.conn = None
        if conn is not None:
            try:
                conn.close()
            except OSError:
                pass

    def post(self, offset, data):
        target = self.path + "/" + str(offset)
        last = None
        for attempt in range(4):
            try:
                conn = self._connection()
                conn.request("POST", target, body=data, headers={"User-Agent": UA, "Origin": SITE})
                resp = conn.getresponse()
                body = resp.read()
            except (OSError, http.client.HTTPException) as exc:
                last = exc
                self._drop()
                if attempt < 3:
                    time.sleep(0.5 * (attempt + 1))
                    continue
                raise TiError("chunk POST failed at %d: %s" % (offset, exc))
            if resp.status != 200:
                self._drop()
                if resp.status in (400, 403, 404, 410):
                    raise UploadExpired("upload session rejected chunk %d (HTTP %s)" % (offset, resp.status))
                if resp.status >= 500 and attempt < 3:
                    last = TiError("HTTP %s" % resp.status)
                    time.sleep(1 + attempt)
                    continue
                raise TiError("chunk POST failed at %d: HTTP %s %s" % (
                    offset, resp.status, body[:120].decode(errors="replace")))
            stripped = body.strip()
            if stripped and stripped.lstrip(b"-").isdigit():
                code = int(stripped)
                if code < 0:
                    if code == -3 and attempt < 3:
                        time.sleep(1 + attempt)
                        continue
                    raise TiError("chunk %d rejected: %d" % (offset, code))
            return body
        raise TiError("chunk POST failed at %d: %s" % (offset, last))


def upload_pools(sid):
    res = api_request({"a": "usc"}, sid=sid)
    while isinstance(res, list) and len(res) == 1 and isinstance(res[0], list):
        res = res[0]
    pools = []
    for entry in res if isinstance(res, list) else []:
        if (isinstance(entry, list) and len(entry) >= 2
                and isinstance(entry[0], str) and isinstance(entry[1], str)):
            pools.append(entry)
    if not pools:
        raise TiError("usc returned no upload pools")
    return pools


def probe_host(host):
    started = time.time()
    conn = http.client.HTTPSConnection(host, 443, timeout=2)
    try:
        conn.connect()
        return time.time() - started
    except OSError:
        return None
    finally:
        try:
            conn.close()
        except OSError:
            pass


def pick_pool(pools, size):
    candidates = []
    for entry in pools:
        limit = entry[2] if len(entry) > 2 else None
        if limit is None or limit >= size:
            candidates.append(entry)
    if not candidates:
        candidates = [pools[-1]]
    chosen = candidates[0]
    best = None
    for entry in candidates:
        rtt = probe_host(entry[0])
        if rtt is not None and (best is None or rtt < best):
            best = rtt
            chosen = entry
    sys.stderr.write("upload node: %s (rtt %s ms)\n" % (
        chosen[0], "?" if best is None else "%d" % (best * 1000)))
    return chosen[0], chosen[1]


class PoolBody:
    def __init__(self, path, size, ukey, chunks, progress):
        self.path = path
        self.size = size
        self.ukey = ukey
        self.chunks = chunks
        self.index = 0
        self.buf = b""
        self.macs = []
        self.progress = progress
        self.sent = 0
        self.fh = open(path, "rb")
        self.enc = Cipher(
            algorithms.AES(a32b(ukey[:4])),
            modes.CTR(a32b(ukey[4:6]) + b"\x00" * 8),
        ).encryptor()

    def read(self, amount=-1):
        if amount is None or amount < 0:
            want = self.size
        else:
            want = amount
        while len(self.buf) < want and self.index < len(self.chunks):
            _, length = self.chunks[self.index]
            data = self.fh.read(length)
            if len(data) != length:
                raise TiError("file changed while reading %s" % self.path)
            self.macs.append(mac_of_chunk(data, self.ukey))
            self.buf += self.enc.update(data)
            self.index += 1
        if amount is None or amount < 0:
            out, self.buf = self.buf, b""
        else:
            out, self.buf = self.buf[:want], self.buf[want:]
        if out:
            self.sent += len(out)
            if self.progress:
                self.progress.add(len(out))
        return out

    def close(self):
        try:
            self.fh.close()
        except OSError:
            pass


class StreamPoster:
    def __init__(self, url):
        parts = urlsplit(url)
        self.scheme = parts.scheme or "https"
        self.host = parts.hostname
        self.port = parts.port or (443 if self.scheme == "https" else 80)
        self.path = parts.path.rstrip("/")

    def post(self, body, size):
        target = self.path + "/0"
        if self.scheme == "https":
            conn = http.client.HTTPSConnection(self.host, self.port, timeout=600)
        else:
            conn = http.client.HTTPConnection(self.host, self.port, timeout=600)
        conn.blocksize = 1 << 20
        try:
            conn.request("POST", target, body=body, headers={
                "User-Agent": UA,
                "Origin": SITE,
                "Content-Length": str(size),
            })
            resp = conn.getresponse()
            data = resp.read()
            status = resp.status
        finally:
            try:
                conn.close()
            except OSError:
                pass
        if status != 200:
            if status in (400, 403, 404, 410):
                raise UploadExpired("pool upload rejected (HTTP %s)" % status)
            raise TiError("pool upload failed: HTTP %s %s" % (
                status, data[:120].decode(errors="replace")))
        stripped = data.strip()
        if stripped and stripped.lstrip(b"-").isdigit() and int(stripped) < 0:
            raise TiError("pool upload rejected: %s" % stripped.decode(errors="replace"))
        return data


def upload_file_pool(sid, root, path, entry, save):
    info = os.stat(path)
    size = info.st_size
    name = os.path.basename(path)
    ukey = entry.get("ukey") or rand_a32(6)
    entry["ukey"] = ukey
    entry["mode"] = "pool"
    save()
    host, uri = pick_pool(upload_pools(sid), size)
    chunks = chunk_sizes(size)
    progress = Progress(name, size)
    body = PoolBody(path, size, ukey, chunks, progress)
    try:
        raw = StreamPoster("https://%s/%s" % (host, uri)).post(body, size)
    finally:
        body.close()
        progress.close()
    if body.index != len(chunks) or body.sent != size:
        raise TiError("pool upload did not send the whole file")
    token = raw.strip()
    try:
        text = token.decode("ascii")
        if not text or any(not (c.isalnum() or c in "-_") for c in text):
            raise ValueError
    except (UnicodeDecodeError, ValueError):
        text = b64e(token)
    filekey = build_filekey(ukey, body.macs)
    attrs = encrypt_attr({"n": name}, filekey)
    node = {"t": 0, "h": text, "a": attrs, "k": b64e(a32b(filekey))}
    res = api_request({"a": "xp", "v": 3, "t": root, "n": [node]}, sid=sid)
    if isinstance(res, list) and res:
        res = res[0]
    if not isinstance(res, dict) or not res.get("f"):
        raise TiError("xp returned unexpected data: %r" % (res,))
    handle = res["f"][0].get("h", "")
    entry["handle"] = handle
    save()
    return handle


def state_dir():
    path = os.environ.get("TRANSFERIT_STATE_DIR") or os.path.join(
        os.path.expanduser("~"), ".cache", "transferit"
    )
    os.makedirs(path, exist_ok=True)
    return path


def upload_state_path(files):
    first = os.path.abspath(files[0])
    info = os.stat(first)
    key = hashlib.sha256(
        ("%s|%d|%d" % (first, info.st_size, info.st_mtime_ns)).encode()
    ).hexdigest()[:24]
    return os.path.join(state_dir(), "upload-%s.json" % key)


def load_json(path):
    try:
        with open(path, "r") as fh:
            return json.load(fh)
    except (OSError, ValueError):
        return None


def save_json(path, data):
    tmp = path + ".tmp"
    with open(tmp, "w") as fh:
        json.dump(data, fh)
    os.replace(tmp, path)


def file_signature(path):
    info = os.stat(path)
    return info.st_size, info.st_mtime_ns


def state_matches(state, files):
    stored = state.get("files") or {}
    if set(stored.keys()) != set(os.path.abspath(p) for p in files):
        return False
    for path in files:
        size, mtime = file_signature(path)
        entry = stored[os.path.abspath(path)]
        if entry.get("size") != size or entry.get("mtime_ns") != mtime:
            return False
    return True


def resume_session(state):
    try:
        api_request({"a": "xl"}, sid=state.get("sid"))
        return True
    except TiError:
        user = state.get("user")
        if not user:
            return False
        try:
            res = api_request({"a": "us", "user": user})
        except TiError:
            return False
        if isinstance(res, dict) and res.get("tsid"):
            state["sid"] = res["tsid"]
            return True
        return False


def upload_file(sid, root, path, workers, entry, save):
    info = os.stat(path)
    size = info.st_size
    name = os.path.basename(path)
    ukey = entry.get("ukey") or rand_a32(6)
    entry["ukey"] = ukey
    chunks = chunk_sizes(size)
    done = set(entry.get("done") or [])
    macs = [b""] * len(chunks)
    completion = entry.get("completion") or ""
    if chunks and chunks[-1][0] in done and not completion:
        done.discard(chunks[-1][0])

    upload_url = entry.get("upload_url")
    if not upload_url:
        upload_url = request_upload_url(sid, size)
        entry["upload_url"] = upload_url
        save()
    poster = ChunkPoster(upload_url)

    def discard_mac(index):
        offset, length = chunks[index]
        with open(path, "rb") as fh:
            fh.seek(offset)
            data = fh.read(length)
        if len(data) != length:
            raise TiError("file changed while reading %s" % path)
        macs[index] = mac_of_chunk(data, ukey)

    for index, (offset, _) in enumerate(chunks):
        if offset in done:
            discard_mac(index)

    skipped = sum(chunks[i][1] for i, (offset, _) in enumerate(chunks) if offset in done)
    progress = Progress(name, size)
    progress.set(skipped)

    def send(index):
        offset, length = chunks[index]
        with open(path, "rb") as fh:
            fh.seek(offset)
            data = fh.read(length)
        if len(data) != length:
            raise TiError("file changed while reading %s" % path)
        ciphertext, mac = encrypt_chunk(data, ukey, offset)
        body = poster.post(offset, ciphertext)
        stripped = body.strip()
        if stripped and stripped.lstrip(b"-").isdigit() and int(stripped) < 0:
            raise TiError("chunk %d rejected: %s" % (offset, stripped.decode(errors="replace")))
        return index, mac, stripped

    if not chunks:
        if not completion:
            completion = poster.post(0, b"").strip().decode()
            entry["completion"] = completion
            save()
        progress.close()
    else:
        last_index = len(chunks) - 1
        last_offset = chunks[last_index][0]
        if last_offset in done and not completion:
            done.discard(last_offset)
        if last_offset in done:
            discard_mac(last_index)
        pending = [i for i in range(len(chunks)) if chunks[i][0] not in done]
        try:
            if pending:
                with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
                    futures = [pool.submit(send, i) for i in pending]
                    for future in as_completed(futures):
                        index, mac, body = future.result()
                        macs[index] = mac
                        done.add(chunks[index][0])
                        if index == last_index and body:
                            completion = body.decode()
                            entry["completion"] = completion
                        entry["done"] = sorted(done)
                        save()
                        progress.add(chunks[index][1])
            if not completion:
                index, mac, body = send(last_index)
                macs[index] = mac
                if body:
                    completion = body.decode()
                    entry["completion"] = completion
                done.add(chunks[index][0])
                entry["done"] = sorted(done)
                save()
            progress.close()
        except BaseException:
            progress.close()
            raise

    if not completion:
        raise TiError("upload server returned no completion handle for %s" % name)

    filekey = build_filekey(ukey, macs)
    attrs = encrypt_attr({"n": name}, filekey)
    node = {"t": 0, "h": completion, "a": attrs, "k": b64e(a32b(filekey))}
    res = api_request({"a": "xp", "v": 3, "t": root, "n": [node]}, sid=sid)
    if isinstance(res, list) and res:
        res = res[0]
    if not isinstance(res, dict) or not res.get("f"):
        raise TiError("xp returned unexpected data: %r" % (res,))
    handle = res["f"][0].get("h", "")
    entry["handle"] = handle
    save()
    return handle


def parse_handle(value):
    text = value.strip()
    if "transfer.it/" in text:
        text = text.split("transfer.it/", 1)[1]
    text = text.split("?", 1)[0].split("#", 1)[0].rstrip("/")
    if "/" in text:
        text = text.rsplit("/", 1)[-1]
    if len(text) < 8:
        raise TiError("not a transfer.it link or handle: %s" % value)
    return text


def fetch_nodes(xh, pw):
    res = api_request({"a": "f", "c": 1, "r": 1, "xnc": 1}, xh=xh, pw=pw)
    if isinstance(res, list) and res and isinstance(res[0], dict):
        res = res[0]
    if not isinstance(res, dict) or not res.get("f"):
        raise TiError("transfer listing failed: %r" % (res,))
    nodes = []
    for item in res["f"]:
        words = b2a32(b64d(item["k"])) if item.get("k") else []
        attrs = decrypt_attr(item["a"], words) if item.get("a") and words else {}
        name = attrs.get("n") or attrs.get("name") or item.get("h", "unnamed")
        nodes.append({
            "h": item["h"],
            "p": item.get("p", ""),
            "t": item.get("t", 0),
            "s": item.get("s"),
            "k": words,
            "name": name,
        })
    return nodes


def safe_name(name):
    text = str(name).replace("\\", "/")
    parts = [p for p in text.split("/") if p not in ("", ".", "..")]
    return "_".join(parts) if parts else "download"


def folder_map(nodes):
    root = next((n["h"] for n in nodes if n["t"] == 1 and not n["p"]), None)
    paths = {}
    if root:
        paths[root] = ""
    pending = [n for n in nodes if n["t"] == 1 and n["h"] != root]
    while pending:
        progressed = False
        for node in list(pending):
            if node["p"] in paths:
                parent = paths[node["p"]]
                rel = safe_name(node["name"])
                paths[node["h"]] = (parent + "/" + rel) if parent else rel
                pending.remove(node)
                progressed = True
        if not progressed:
            break
    return paths


def fetch_info(xh):
    res = api_request({"a": "xi", "xh": xh})
    if isinstance(res, list) and res and isinstance(res[0], dict):
        res = res[0]
    if not isinstance(res, dict):
        raise TiError("xi returned unexpected data: %r" % (res,))
    title = None
    if isinstance(res.get("t"), str):
        try:
            title = b64d(res["t"]).decode()
        except UnicodeDecodeError:
            title = None
    size = res.get("size") or []
    return {
        "title": title,
        "bytes": size[0] if len(size) > 0 else None,
        "files": size[1] if len(size) > 1 else None,
        "folders": size[2] if len(size) > 2 else None,
        "password": bool(res.get("pw")),
    }


def download_url(xh, node_handle, pw):
    res = api_request({"a": "g", "n": node_handle, "g": 1, "ssl": 1}, xh=xh, pw=pw)
    if isinstance(res, list) and res:
        res = res[0]
    if not isinstance(res, dict) or not res.get("g"):
        raise TiError("g returned no download URL for %s" % node_handle)
    return res["g"], res.get("s")


def verify_file(path, filekey, size):
    ukey = [
        filekey[0] ^ filekey[4],
        filekey[1] ^ filekey[5],
        filekey[2] ^ filekey[6],
        filekey[3] ^ filekey[7],
        filekey[4],
        filekey[5],
    ]
    macs = []
    with open(path, "rb") as fh:
        for _, length in chunk_sizes(size):
            data = fh.read(length)
            if len(data) != length:
                return False
            macs.append(mac_of_chunk(data, ukey))
    mac = condense(macs, ukey) if macs else b"\x00" * 16
    m = struct.unpack(">4I", mac)
    return (m[0] ^ m[1]) == filekey[6] and (m[2] ^ m[3]) == filekey[7]


def download_file(url, target, filekey, size, verify, force):
    if os.path.exists(target) and not force:
        if not size or os.path.getsize(target) == size:
            sys.stderr.write("%s: already complete, skipped\n" % target)
            return
    os.makedirs(os.path.dirname(os.path.abspath(target)), exist_ok=True)
    partial = target + ".part"
    offset = os.path.getsize(partial) if os.path.exists(partial) else 0
    if size and offset > size:
        os.remove(partial)
        offset = 0
    if size and offset == size and offset > 0:
        if verify and not verify_file(partial, filekey, size):
            os.remove(partial)
            offset = 0
        else:
            os.replace(partial, target)
            sys.stderr.write("%s: already downloaded\n" % target)
            return

    headers = {"User-Agent": UA, "Referer": SITE + "/"}
    if offset:
        headers["Range"] = "bytes=%d-" % offset
    started = time.time()
    response = None
    try:
        response = urlopen(Request(url, headers=headers), timeout=300)
    except HTTPError as exc:
        if exc.code == 416 and size and offset == size:
            response = None
        else:
            raise TiError("download failed for %s: HTTP %s" % (target, exc.code))
    if response is not None and offset and response.status != 206:
        offset = 0
    if response is not None:
        counter = offset // 16
        iv = a32b(filekey[4:6]) + struct.pack(">Q", counter)
        dec = Cipher(algorithms.AES(attr_key(filekey)), modes.CTR(iv)).decryptor()
        skip = offset % 16
        if skip:
            dec.update(b"\x00" * skip)
        written = offset
        progress = Progress(os.path.basename(target), size or 0)
        progress.set(offset)
        try:
            with open(partial, "ab" if offset else "wb") as fh:
                while True:
                    block = response.read(MB)
                    if not block:
                        break
                    fh.write(dec.update(block))
                    written += len(block)
                    progress.add(len(block))
                fh.write(dec.finalize())
            progress.close()
        finally:
            response.close()
        if size and written != size:
            raise TiError("size mismatch for %s: got %d expected %d" % (target, written, size))
        if verify and size and not verify_file(partial, filekey, size):
            os.remove(partial)
            raise TiError("integrity check failed for %s" % target)
        os.replace(partial, target)
        if size:
            elapsed = time.time() - started
            rate = size / elapsed if elapsed > 0 else 0
            sys.stderr.write("%s: done in %.1fs (%s/s)\n" % (target, elapsed, human_size(rate)))
    else:
        if not size:
            raise TiError("cannot resume %s without a known size" % target)
        if verify and not verify_file(partial, filekey, size):
            os.remove(partial)
            raise TiError("integrity check failed for %s" % target)
        os.replace(partial, target)
        sys.stderr.write("%s: already downloaded\n" % target)


def command_upload(args):
    for path in args.files:
        if not os.path.isfile(path):
            raise TiError("not a file: %s" % path)
    state_file = upload_state_path(args.files)
    state = None if args.fresh else load_json(state_file)
    resuming = bool(state) and state_matches(state, args.files) and resume_session(state)
    if not resuming:
        if state and state.get("xh"):
            try:
                api_request({"a": "xd", "xh": state["xh"]}, sid=state.get("sid"))
            except TiError:
                pass
        user, sid = create_session()
        state = {
            "version": 1,
            "user": user,
            "sid": sid,
            "xh": None,
            "root": None,
            "title": args.title or os.path.basename(args.files[0]),
            "files": {},
        }
        for path in args.files:
            size, mtime = file_signature(path)
            state["files"][os.path.abspath(path)] = {
                "size": size,
                "mtime_ns": mtime,
                "upload_url": None,
                "ukey": None,
                "done": [],
                "completion": "",
                "handle": "",
            }
        save_json(state_file, state)
        sys.stderr.write("new upload session\n")
    else:
        sys.stderr.write("resuming upload session\n")

    sid = state["sid"]
    if not state.get("xh"):
        state["xh"], state["root"] = create_transfer(sid, state["title"])
        save_json(state_file, state)

    for path in args.files:
        entry = state["files"][os.path.abspath(path)]
        if entry.get("handle"):
            print("uploaded %s -> %s (already done)" % (os.path.basename(path), entry["handle"]))
            continue
        started = time.time()
        save_state = lambda: save_json(state_file, state)
        try:
            mode = entry.get("mode")
            resume_u = bool(entry.get("upload_url") or entry.get("done") or entry.get("completion"))
            if args.http or mode == "u" or resume_u or os.path.getsize(path) == 0:
                entry["mode"] = "u"
                handle = upload_file(sid, state["root"], path, args.workers, entry, save_state)
            else:
                try:
                    handle = upload_file_pool(sid, state["root"], path, entry, save_state)
                except Exception as exc:
                    sys.stderr.write("pool upload failed (%s); falling back to chunked upload\n" % exc)
                    entry["mode"] = "u"
                    save_state()
                    handle = upload_file(sid, state["root"], path, args.workers, entry, save_state)
        except UploadExpired:
            sys.stderr.write("upload session expired, starting over\n")
            try:
                api_request({"a": "xd", "xh": state["xh"]}, sid=sid)
            except TiError:
                pass
            os.remove(state_file)
            return command_upload(argparse.Namespace(
                files=args.files,
                title=args.title,
                password=args.password,
                expire_days=args.expire_days,
                workers=args.workers,
                http=args.http,
                fresh=True,
            ))
        sys.stderr.write("  %s in %.1fs\n" % (human_size(os.path.getsize(path)), time.time() - started))
        print("uploaded %s -> %s" % (os.path.basename(path), handle))

    meta = {"a": "xm", "xh": state["xh"]}
    if args.title:
        meta["t"] = b64e(args.title.encode())
    if args.expire_days:
        meta["e"] = int(args.expire_days * 86400)
    if args.password:
        meta["pw"] = derive_password(state["xh"], args.password)
    if len(meta) > 2:
        api_request(meta, sid=sid)
    api_request({"a": "xc", "xh": state["xh"]}, sid=sid)
    print("%s/t/%s" % (SITE, state["xh"]))
    try:
        os.remove(state_file)
    except OSError:
        pass


def command_download(args):
    xh = parse_handle(args.url)
    pw = derive_password(xh, args.password) if args.password else None
    nodes = fetch_nodes(xh, pw)
    files = [n for n in nodes if n["t"] == 0]
    if not files:
        raise TiError("transfer has no files")
    paths = folder_map(nodes)
    out = args.out
    single = len(files) == 1
    direct = bool(out) and single and not os.path.isdir(out) and not out.endswith(os.sep)
    for node in files:
        if direct:
            target = out
        else:
            base = out or "."
            parent = paths.get(node["p"], "")
            rel = (parent + "/" if parent else "") + safe_name(node["name"])
            target = os.path.join(base, rel)
        url, remote_size = download_url(xh, node["h"], pw)
        size = remote_size or node.get("s") or 0
        filekey = node["k"]
        if len(filekey) < 8:
            raise TiError("unsupported file key for %s" % node["name"])
        download_file(url, target, filekey, size, args.verify, args.force)


def command_info(args):
    xh = parse_handle(args.url)
    pw = derive_password(xh, args.password) if args.password else None
    info = fetch_info(xh)
    print("handle: %s" % xh)
    print("title: %s" % (info["title"] or "-"))
    print("size: %s bytes, %s files, %s folders" % (info["bytes"], info["files"], info["folders"]))
    print("password: %s" % ("yes" if info["password"] else "no"))
    try:
        nodes = fetch_nodes(xh, pw)
    except TiError as exc:
        print("listing: %s" % exc)
        return
    paths = folder_map(nodes)
    for node in nodes:
        if node["t"] == 0:
            parent = paths.get(node["p"], "")
            rel = (parent + "/" if parent else "") + safe_name(node["name"])
            print("FILE %12s  %s" % (node.get("s"), rel))
        else:
            print("DIR              %s" % paths.get(node["h"], node["name"]))


def main():
    parser = argparse.ArgumentParser(prog="transferit", description="transfer.it upload/download CLI (no browser)")
    sub = parser.add_subparsers(dest="command", required=True)

    up = sub.add_parser("upload", help="upload one or more files and print the share URL")
    up.add_argument("files", nargs="+")
    up.add_argument("-t", "--title")
    up.add_argument("-p", "--password")
    up.add_argument("-e", "--expire-days", type=int)
    up.add_argument("-w", "--workers", type=int, default=8)
    up.add_argument("--fresh", action="store_true", help="ignore resumable upload state")
    up.add_argument("--http", action="store_true",
                    help="use the resumable chunked path instead of the single-shot pool upload")
    up.set_defaults(func=command_upload)

    down = sub.add_parser("download", help="download all files from a transfer.it link")
    down.add_argument("url")
    down.add_argument("-o", "--out")
    down.add_argument("-p", "--password")
    down.add_argument("--verify", action="store_true")
    down.add_argument("--force", action="store_true", help="overwrite an existing complete file")
    down.set_defaults(func=command_download)

    info = sub.add_parser("info", help="show transfer metadata and file list")
    info.add_argument("url")
    info.add_argument("-p", "--password")
    info.set_defaults(func=command_info)

    args = parser.parse_args()
    try:
        args.func(args)
    except TiError as exc:
        sys.stderr.write("error: %s\n" % exc)
        return 1
    except KeyboardInterrupt:
        return 130
    return 0


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