#!/usr/bin/env python3
"""ooc-repo - Out of Context repositories from your own computer.

The server never sees a zip file. Everything risky happens HERE, on your machine:
  * a zip is unpacked by a hardened extractor (zip-bomb / path-traversal / symlink / name checks, hard size limits),
  * the result is compared with the repository (added / changed / removed / renamed files),
  * changes made by other people in the meantime are merged here (3-way text merge),
  * and only the finished changeset is sent to the server over HTTPS (individual files, never an archive).

Commands:
  ooc-repo login                       save your user name + repository password (https://repo.out-of-context.blog/tokens)
  ooc-repo whoami                      who am I, and what are my limits
  ooc-repo create NAME [-d TEXT]       create a repository
  ooc-repo push-zip FILE.zip [REPO]    unpack, diff, merge and commit a zip   (REPO = owner/name or name)
  ooc-repo git-submit [--repo-dir D]   send local Git commits (made on a clone of the mirror) to Subversion
  ooc-repo watch DIR                   drop FILE.zip into DIR and it is processed automatically
  ooc-repo verify-zip FILE.zip         only check a zip with the safety rules
  ooc-repo install-integration         right-click "send to OOC" in the file manager + optional watch service
Needs only Python 3.8+ (no Subversion client). `git-submit` additionally needs `git`.
"""
import argparse
import getpass
import hashlib
import json
import os
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import time
import unicodedata
import urllib.error
import urllib.request
import zipfile
from base64 import b64encode

for _stream in (sys.stdout, sys.stderr):
    try:
        _stream.reconfigure(errors="replace")      # never crash on characters the Windows console code page lacks
    except (AttributeError, ValueError):
        pass

IS_WINDOWS = os.name == "nt"
VERSION = "1.1"
DEFAULT_SERVER = "https://repo.out-of-context.blog"
MiB = 1024 * 1024
CONFIG_DIR = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "ooc-repo")
DATA_DIR = os.path.join(os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")), "ooc-repo")
SVN_CONFIG = os.path.join(DATA_DIR, "svnconfig")
BAD_PARTS = {".git", ".svn", ".hg", ".bzr", "cvs", ".gitmodules"}
JUNK = {".ds_store", "thumbs.db", "desktop.ini"}


class Fail(Exception):
    pass


def say(*a):
    print(*a, flush=True)


def warn(*a):
    print("  ⚠", *a, file=sys.stderr, flush=True)


# --------------------------------------------------------------------------------------------- config / http
def load_config():
    cfg = {}
    try:
        with open(os.path.join(CONFIG_DIR, "config.json")) as f:
            cfg = json.load(f)
    except (OSError, ValueError):
        pass
    cfg["server"] = os.environ.get("OOC_REPO_SERVER", cfg.get("server", DEFAULT_SERVER)).rstrip("/")
    cfg["user"] = os.environ.get("OOC_REPO_USER", cfg.get("user", ""))
    cfg["token"] = os.environ.get("OOC_REPO_TOKEN", cfg.get("token", ""))
    if not cfg["server"].startswith("https://") and os.environ.get("OOC_REPO_ALLOW_HTTP") != "1":
        raise Fail("サーバー URL は https:// で始まる必要があります（テスト用に OOC_REPO_ALLOW_HTTP=1 で例外）")
    return cfg


def save_config(cfg):
    os.makedirs(CONFIG_DIR, mode=0o700, exist_ok=True)
    path = os.path.join(CONFIG_DIR, "config.json")
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as f:
        json.dump({k: cfg[k] for k in ("server", "user", "token")}, f)


def need_login(cfg):
    if not cfg["user"] or not cfg["token"]:
        raise Fail("先に `ooc-repo login` を実行してください（リポジトリ用パスワードは %s/tokens で発行）" % cfg["server"])


def request_raw(cfg, method, path, data=None, headers=None, auth=True, timeout=120):
    req = urllib.request.Request(cfg["server"] + path, method=method, data=data)
    req.add_header("User-Agent", "Mozilla/5.0 (compatible; ooc-repo/%s)" % VERSION)
    for k, v in (headers or {}).items():
        req.add_header(k, v)
    if auth:
        req.add_header("Authorization", "Basic " + b64encode(("%s:%s" % (cfg["user"], cfg["token"])).encode()).decode())
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return r.status, r.read()
    except urllib.error.HTTPError as e:
        return e.code, e.read()
    except urllib.error.URLError as e:
        raise Fail("サーバーに接続できません: %s" % e.reason)


def api(cfg, method, path, body=None, auth=True, timeout=120):
    st, raw = request_raw(cfg, method, path, json.dumps(body).encode() if body is not None else None,
                          {"Content-Type": "application/json"} if body is not None else None, auth, timeout)
    try:
        return st, json.loads(raw.decode() or "{}")
    except ValueError:
        return st, {}


def have(cmd):
    return shutil.which(cmd) is not None


# --------------------------------------------------------------------------------------------- safe names / zip extraction
class Limits:
    def __init__(self, max_files=20000, max_total=500 * MiB, max_file=50 * MiB, ratio=200, total_ratio=300):
        self.max_files, self.max_total, self.max_file, self.ratio, self.total_ratio = max_files, max_total, max_file, ratio, total_ratio


def clean_name(raw, what="zip"):
    """Normalise one entry name. Returns None for junk that is silently dropped; raises Fail for hostile names."""
    name = unicodedata.normalize("NFC", raw.replace("\\", "/"))
    if "\x00" in name:
        raise Fail("%s に NUL 文字を含む名前があります（拒否）" % what)
    if name.startswith("/") or re.match(r"^[A-Za-z]:", name):
        raise Fail("%s に絶対パスの名前があります: %r（拒否）" % (what, name[:60]))
    parts = [p for p in name.split("/") if p not in ("", ".")]
    if not parts:
        return None
    for p in parts:
        if p == "..":
            raise Fail("%s にディレクトリ脱出(..)を含む名前があります: %r（拒否）" % (what, name[:60]))
        if any(ord(c) < 32 or ord(c) == 127 for c in p):
            raise Fail("%s に制御文字を含む名前があります: %r（拒否）" % (what, name[:60]))
        if len(p.encode("utf-8")) > 200:
            raise Fail("%s に長すぎる名前があります: %r（拒否）" % (what, p[:40]))
    if IS_WINDOWS:
        for p in parts:
            if re.search(r'[<>:"|?*]', p) or p.rstrip(" .") != p or p.split(".")[0].upper() in (
                    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "LPT1", "LPT2", "LPT3"):
                raise Fail("%s に Windows では使えない名前があります: %r" % (what, name[:60]))
    lowered = [p.lower() for p in parts]
    if lowered[0] == "__macosx" or lowered[-1] in JUNK:
        return None
    for p in lowered:
        if p in BAD_PARTS:
            warn("無視しました（使えない名前）: %s" % "/".join(parts)[:80])
            return None
    if len("/".join(parts)) > 240 or len(parts) > 30:
        raise Fail("%s にパスが長すぎる/深すぎる名前があります: %r（拒否）" % (what, name[:60]))
    return "/".join(parts)


def decode_zip_name(info):
    """Zip names without the UTF-8 flag are CP437 to Python; Japanese Windows tools write CP932 - recover it."""
    if info.flag_bits & 0x800:
        return info.filename
    raw = info.filename.encode("cp437", "replace")
    for enc in ("utf-8", "cp932"):
        try:
            s = raw.decode(enc)
            if enc == "utf-8" or any(ord(c) > 127 for c in s):
                return s
        except UnicodeDecodeError:
            continue
    return info.filename


def safe_extract(zip_path, dest, lim, strip="auto"):
    """Unpack `zip_path` into the empty directory `dest`. Never trusts sizes in the zip header: bytes are counted while
    streaming. Returns {relpath: executable_bool}. Raises Fail on anything hostile or too big."""
    try:
        zf = zipfile.ZipFile(zip_path)
    except zipfile.BadZipFile:
        raise Fail("zip ファイルとして読めません（壊れているか zip ではありません）")
    with zf:
        infos = zf.infolist()
        if len(infos) > lim.max_files:
            raise Fail("zip 内のファイル数が多すぎます（%d > %d）" % (len(infos), lim.max_files))
        entries = []
        declared = 0
        seen_fold = {}
        for info in infos:
            if info.is_dir():
                continue
            if info.flag_bits & 0x1:
                raise Fail("パスワード付き zip は扱えません")
            mode = (info.external_attr >> 16) & 0xFFFF if info.create_system == 3 else 0
            if mode and stat.S_IFMT(mode) not in (0, stat.S_IFREG):
                warn("無視しました（シンボリックリンク/特殊ファイル）: %s" % info.filename[:80])
                continue
            name = clean_name(decode_zip_name(info))
            if name is None:
                continue
            fold = name.lower()
            if fold in seen_fold and seen_fold[fold] != name:
                raise Fail("大文字小文字だけが違う同名ファイルがあります: %s / %s" % (seen_fold[fold], name))
            seen_fold[fold] = name
            if info.file_size > lim.max_file:
                raise Fail("ファイルが大きすぎます（%d MiB > %d MiB）: %s" % (info.file_size // MiB, lim.max_file // MiB, name))
            if info.file_size > MiB and info.compress_size and info.file_size / info.compress_size > lim.ratio:
                raise Fail("圧縮率が異常です（zip 爆弾の疑い）: %s" % name)
            declared += info.file_size
            entries.append((info, name, bool(mode & 0o111)))
        if declared > lim.max_total:
            raise Fail("展開後の合計サイズが大きすぎます（%d MiB > %d MiB）" % (declared // MiB, lim.max_total // MiB))
        csize = sum(i.compress_size for i, _n, _x in entries) or 1
        if declared > 50 * MiB and declared / csize > lim.total_ratio:
            raise Fail("全体の圧縮率が異常です（zip 爆弾の疑い）")
        if not entries:
            raise Fail("zip の中に取り込めるファイルがありません")
        prefix = ""
        if strip != "none":
            firsts = {n.split("/")[0] for _i, n, _x in entries if "/" in n}
            if len(firsts) == 1 and all("/" in n for _i, n, _x in entries):
                prefix = firsts.pop() + "/"
        files, total = {}, 0
        for info, name, ex in entries:
            rel = name[len(prefix):] if prefix and name.startswith(prefix) else name
            target = os.path.join(dest, *rel.split("/"))
            os.makedirs(os.path.dirname(target), exist_ok=True)
            written = 0
            with zf.open(info) as src, open(target, "wb") as out:
                while True:
                    chunk = src.read(1024 * 1024)
                    if not chunk:
                        break
                    written += len(chunk)
                    total += len(chunk)
                    if written > lim.max_file or total > lim.max_total or written > info.file_size + 1024:
                        raise Fail("展開中にサイズ超過を検出しました（zip 爆弾の疑い）: %s" % name)
                    out.write(chunk)
            os.chmod(target, 0o755 if ex else 0o644)
            files[rel] = ex
    return files


# --------------------------------------------------------------------------------------------- tree comparison
def scan(root):
    out = {}
    for base, dirs, names in os.walk(root):
        for n in names:
            p = os.path.join(base, n)
            if os.path.islink(p):
                continue
            rel = os.path.relpath(p, root).replace(os.sep, "/")
            h = hashlib.sha256()
            with open(p, "rb") as f:
                for chunk in iter(lambda: f.read(1024 * 1024), b""):
                    h.update(chunk)
            out[rel] = (h.hexdigest(), os.path.getsize(p))
    return out


def plan_changes(old, new):
    added = sorted(set(new) - set(old))
    removed = sorted(set(old) - set(new))
    modified = sorted(p for p in set(old) & set(new) if old[p][0] != new[p][0])
    # exact-content renames: one removed path <-> one added path
    by_hash_removed, by_hash_added = {}, {}
    for p in removed:
        by_hash_removed.setdefault(old[p][0], []).append(p)
    for p in added:
        by_hash_added.setdefault(new[p][0], []).append(p)
    renames = []
    for h, rem in by_hash_removed.items():
        add = by_hash_added.get(h, [])
        if old[rem[0]][1] == 0:
            continue
        if len(rem) == 1 and len(add) == 1:
            renames.append((rem[0], add[0]))
    rn_old = {a for a, _b in renames}
    rn_new = {b for _a, b in renames}
    return {"added": [p for p in added if p not in rn_new], "removed": [p for p in removed if p not in rn_old],
            "modified": modified, "renames": renames}


def show_plan(plan, new, limit=40):
    def block(title, items, fmt):
        if not items:
            return
        say("  %s (%d)" % (title, len(items)))
        for it in items[:limit]:
            say("    " + fmt(it))
        if len(items) > limit:
            say("    … 他 %d 件" % (len(items) - limit))
    block("＋ 追加", plan["added"], lambda p: "+ %s  (%d KB)" % (p, new[p][1] // 1024))
    block("～ 変更", plan["modified"], lambda p: "~ %s" % p)
    block("－ 削除", plan["removed"], lambda p: "- %s" % p)
    block("→ 名前変更/移動", plan["renames"], lambda r: "%s  →  %s" % r)


# --------------------------------------------------------------------------------------------- 3-way text merge
def merge3(base, mine, theirs):
    """Line based diff3. Returns (merged_lines, had_conflict). Each side's changes are located against `base`; changes in
    different places are combined, identical changes collapse, overlapping different changes are a conflict."""
    import difflib

    def hunks(a, b, side):
        sm = difflib.SequenceMatcher(None, a, b, autojunk=False)
        return [(i1, i2, b[j1:j2], side) for tag, i1, i2, j1, j2 in sm.get_opcodes() if tag != "equal"]
    allh = sorted(hunks(base, mine, "m") + hunks(base, theirs, "t"), key=lambda h: (h[0], h[1]))
    out, pos, i, conflict = [], 0, 0, False
    while i < len(allh):
        start, end = allh[i][0], allh[i][1]
        cluster = [allh[i]]
        i += 1
        while i < len(allh) and allh[i][0] <= end:
            end = max(end, allh[i][1])
            cluster.append(allh[i])
            i += 1
        out += base[pos:start]

        def apply(side):
            hs = [h for h in cluster if h[3] == side]
            if not hs:
                return None
            res, p = [], start
            for h in hs:
                res += base[p:h[0]] + h[2]
                p = h[1]
            return res + base[p:end]
        rm, rt = apply("m"), apply("t")
        if rm is None:
            out += rt
        elif rt is None or rm == rt:
            out += rm
        else:
            conflict = True
            out += ["<<<<<<< zip\n"] + rm + ["=======\n"] + rt + [">>>>>>> server\n"]
        pos = end
    return out + base[pos:], conflict


# --------------------------------------------------------------------------------------------- the import engine
def parse_repo(cfg, spec):
    spec = (spec or "").strip().strip("/")
    if not spec:
        raise Fail("リポジトリ名を指定してください（例: owner/name、または自分の name）")
    if "/" in spec or "." in spec:
        owner, name = re.split(r"[/.]", spec, 1)
    else:
        owner, name = cfg["user"], spec
    owner, name = owner.lower(), name.lower()
    if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", owner) or not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", name):
        raise Fail("リポジトリ名が正しくありません: %s" % spec)
    return "%s.%s" % (owner, name)


def state_path(rid):
    return os.path.join(DATA_DIR, "state", rid + ".json")


def load_state(rid):
    try:
        with open(state_path(rid)) as f:
            return json.load(f)
    except (OSError, ValueError):
        return {}


def save_state(rid, st):
    os.makedirs(os.path.dirname(state_path(rid)), mode=0o700, exist_ok=True)
    with open(state_path(rid), "w") as f:
        json.dump(st, f)


def repo_exists(cfg, rid):
    st, _ = api(cfg, "GET", "/api/repos/" + rid, auth=False)
    return st == 200


def fetch_manifest(cfg, rid, rev):
    st, body = api(cfg, "GET", "/api/repos/%s/manifest?rev=%s" % (rid, rev), auth=False, timeout=900)
    if st == 429:
        time.sleep(3)
        st, body = api(cfg, "GET", "/api/repos/%s/manifest?rev=%s" % (rid, rev), auth=False, timeout=900)
    if st != 200:
        raise Fail("リポジトリの内容を取得できません (%s) %s" % (st, body.get("error", "")))
    return body


def fetch_file(cfg, rid, rev, path):
    from urllib.parse import quote
    st, raw = request_raw(cfg, "GET", "/r/%s/raw/trunk/%s?rev=%d&download=1" % (rid, quote(path), rev), auth=False)
    if st != 200:
        raise Fail("ファイルを取得できません: %s (%s)" % (path, st))
    return raw


def import_tree(cfg, rid, tree_dir, execflags, message, base, args):
    """Core: compare the tree with the repository, merge with what others changed since `base`, upload only the needed
    files and commit them atomically. Returns the new revision, or None when there was nothing to do."""
    for _attempt in range(3):
        res = _import_once(cfg, rid, tree_dir, execflags, message, base, args)
        if res != "retry":
            return res
        say("  他の人が同時に変更しました。最新の状態で取り込み直します…")
        base = None
    raise Fail("他の人の変更が続いたため取り込めませんでした。少し待ってからもう一度実行してください。")


def _import_once(cfg, rid, tree_dir, execflags, message, base, args):
    st, info = api(cfg, "GET", "/api/repos/" + rid, auth=False)
    if st != 200:
        raise Fail("リポジトリ情報を取得できません (%s)" % st)
    head = info["rev"]
    base = base or load_state(rid).get("base") or head
    base = min(int(base), head)
    head_m = fetch_manifest(cfg, rid, head)
    if base == head:
        base_m = head_m
    else:
        try:
            base_m = fetch_manifest(cfg, rid, base)
        except Fail:
            warn("基準リビジョン r%d を取得できなかったため、最新(r%d)を基準にします" % (base, head))
            base, base_m = head, head_m
    hf, bf = head_m["files"], base_m["files"]
    old = {p: (v[0], v[1]) for p, v in bf.items()}
    new = scan(tree_dir)
    plan = plan_changes(old, new)
    if args.partial:
        plan["removed"] = []
    if not sum(len(v) for v in plan.values()):
        say("  変更はありません（リポジトリの内容と同じです）。")
        save_state(rid, {"base": head})
        return None
    say("  比較の基準: r%d / 最新: r%d%s" % (base, head, "" if base == head else "  （他の人の変更とマージします）"))
    show_plan(plan, new)
    gone = len(plan["removed"])
    if gone >= 3 and gone > len(old) // 2 and not args.allow_mass_delete:
        raise Fail("リポジトリの %d 個中 %d 個のファイルが消える内容です。zip が一部だけの場合は --partial、本当に消すなら --allow-mass-delete を付けてください。" % (len(old), gone))
    if args.dry_run:
        say("  (--dry-run のため、ここで終了します。何も送っていません)")
        return None
    if not args.yes:
        if input("  この内容でコミットしますか？ [y/N] ").strip().lower() not in ("y", "yes", "はい"):
            raise Fail("中止しました")

    ops, uploads, conflicts, merged = [], {}, [], []
    scratch = tempfile.mkdtemp(prefix="ooc-merge-")
    try:
        def local(p):
            return os.path.join(tree_dir, *p.split("/"))

        def put(p, path=None, sha=None):
            sha = sha or new[p][0]
            uploads[sha] = path or local(p)
            want = bool(execflags.get(p))
            head_exec = bool(hf[p][2]) if p in hf else False
            ops.append({"op": "put", "path": p, "sha": sha, "exec": True if (want and not head_exec) else (False if (head_exec and not want) else None)})

        added, modified, removed = list(plan["added"]), list(plan["modified"]), list(plan["removed"])
        for a, b in plan["renames"]:
            ha = hf.get(a)
            if ha and ha[0] == bf[a][0] and b not in hf:
                ops.append({"op": "mv", "src": a, "dst": b})
            else:
                removed.append(a)
                added.append(b)
        decided = []           # (kind, path) needing a policy decision
        for p in added:
            h = hf.get(p)
            if h is None:
                put(p)
            elif h[0] != new[p][0]:
                decided.append(("add/add", p))
        for p in modified:
            h = hf.get(p)
            if h is None:
                decided.append(("modify/delete", p))
            elif h[0] == bf[p][0]:
                put(p)
            elif h[0] != new[p][0]:
                try:
                    b_txt = fetch_file(cfg, rid, base, p)
                    t_txt = fetch_file(cfg, rid, head, p)
                    m_txt = open(local(p), "rb").read()
                    if any(b"\x00" in x[:8192] for x in (b_txt, t_txt, m_txt)):
                        raise ValueError("binary")
                    lines, bad = merge3(b_txt.decode("utf-8").splitlines(True), m_txt.decode("utf-8").splitlines(True),
                                        t_txt.decode("utf-8").splitlines(True))
                except (ValueError, UnicodeDecodeError):
                    decided.append(("edit/edit", p))
                    continue
                if bad:
                    decided.append(("edit/edit", p))
                else:
                    data = "".join(lines).encode("utf-8")
                    sha = hashlib.sha256(data).hexdigest()
                    mp = os.path.join(scratch, sha)
                    with open(mp, "wb") as f:
                        f.write(data)
                    put(p, mp, sha)
                    merged.append(p)
        for p in removed:
            h = hf.get(p)
            if h is None:
                continue
            if h[0] == bf.get(p, [None])[0]:
                ops.append({"op": "rm", "path": p})
            else:
                decided.append(("delete/modify", p))
        if decided:
            pol = args.on_conflict
            names = ["%s  [%s]" % (p, k) for k, p in decided]
            if pol == "abort":
                raise Fail("他の人の変更と衝突しました（%d 件）:\n    %s\n  何も送っていません。--on-conflict theirs（サーバー側を優先）または mine（zip 側を優先）で自動選択できます。"
                           % (len(decided), "\n    ".join(names[:20])))
            for kind, p in decided:
                if pol == "mine":
                    ops.append({"op": "rm", "path": p}) if kind == "delete/modify" else put(p)
            warn("衝突は %s を優先して解決しました: %s" % ("サーバー側" if pol == "theirs" else "zip 側", ", ".join(p for _k, p in decided[:5])))
        if merged:
            say("  自動マージしたファイル (%d): %s" % (len(merged), ", ".join(merged[:8])))
        if not ops:
            say("  マージの結果、リポジトリに加える変更は残りませんでした。")
            save_state(rid, {"base": head})
            return None
        # upload the file contents the server does not have yet, then commit everything atomically
        st, chk = api(cfg, "POST", "/api/repos/%s/blobs/check" % rid, {"shas": sorted(uploads)})
        if st != 200:
            raise Fail(chk.get("error", "アップロードの準備に失敗しました (%s)" % st))
        missing = chk.get("missing", [])
        from concurrent.futures import ThreadPoolExecutor

        def send(sha):
            with open(uploads[sha], "rb") as f:
                data = f.read()
            for attempt in range(4):
                st, raw = request_raw(cfg, "PUT", "/api/repos/%s/blobs/%s" % (rid, sha), data,
                                      {"Content-Type": "application/octet-stream"}, timeout=600)
                if st in (200, 201):
                    return None
                if st == 429 and attempt < 3:
                    time.sleep(2 * (attempt + 1))
                    continue
                try:
                    msg = json.loads(raw.decode()).get("error", "")
                except ValueError:
                    msg = ""
                return "アップロードに失敗しました: %s (%s) %s" % (os.path.basename(uploads[sha]), st, msg)
        done = 0
        with ThreadPoolExecutor(6) as ex:
            for err in ex.map(send, missing):
                done += 1
                if err:
                    raise Fail(err)
                if len(missing) > 50 and done % 200 == 0:
                    say("    … %d / %d ファイル送信" % (done, len(missing)))
        st, res = api(cfg, "POST", "/api/repos/%s/commit" % rid,
                      {"base": head, "message": (message or "Import").strip(), "ops": ops}, timeout=900)
        if st == 409:
            return "retry"
        if st != 201:
            raise Fail("サーバーに拒否されました: %s" % res.get("error", st))
        newrev = res["rev"]
        save_state(rid, {"base": newrev})
        say("  ✔ r%d としてコミットしました（%d 件の変更）→ %s/r/%s/rev/%d" % (newrev, len(ops), cfg["server"], rid, newrev))
        return newrev
    finally:
        shutil.rmtree(scratch, ignore_errors=True)


# --------------------------------------------------------------------------------------------- commands
def cmd_login(args):
    cfg = load_config()
    if args.server:
        cfg["server"] = args.server.rstrip("/")
    cfg["user"] = (args.user or input("ユーザー名（Wiki のユーザー名）: ")).strip().lower()
    cfg["token"] = args.token or getpass.getpass("リポジトリ用パスワード（%s/tokens で発行）: " % cfg["server"]).strip()
    st, body = api(cfg, "GET", "/api/whoami")
    if st != 200:
        raise Fail("ログインできません: %s" % body.get("error", st))
    save_config(cfg)
    say("ログインしました: %s%s  設定: %s" % (body["user"], "（管理者）" if body.get("admin") else "", CONFIG_DIR))


def cmd_whoami(args):
    cfg = load_config()
    need_login(cfg)
    st, b = api(cfg, "GET", "/api/whoami")
    if st != 200:
        raise Fail("認証に失敗しました: %s" % b.get("error", st))
    q, lim = b["quota"], b["limits"]
    say("ユーザー: %s%s" % (b["user"], "（管理者）" if b["admin"] else ""))
    say("リポジトリ: %d / %d 個" % (q["repos_used"], q["repos_max"]))
    say("上限: 1リポジトリ %d MiB ・ 1ファイル %d MiB ・ 1コミット %d MiB ・ 1コミット %d ファイル" %
        (q["repo_bytes"] // MiB, lim["max_file_bytes"] // MiB, lim["max_commit_bytes"] // MiB, lim["max_files_per_commit"]))


def cmd_create(args):
    cfg = load_config()
    need_login(cfg)
    st, b = api(cfg, "POST", "/api/repos", {"name": args.name, "description": args.description or ""})
    if st != 201:
        raise Fail(b.get("error", "作成に失敗しました (%s)" % st))
    say("作成しました: %s\n  SVN: %s\n  Git(読み取り専用): %s\n  画面: %s/r/%s/" % (b["id"], b["svn"], b["git"], cfg["server"], b["id"]))


def limits_from_server(cfg):
    st, b = api(cfg, "GET", "/api/whoami")
    lim = Limits()
    if st == 200:
        lim.max_file = min(lim.max_file, b["limits"]["max_file_bytes"])
        lim.max_files = max(lim.max_files, b["limits"]["max_files_per_commit"])
    return lim


def zip_label(path):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()[:12]


def cmd_verify(args):
    lim = Limits()
    with tempfile.TemporaryDirectory(prefix="ooc-verify-") as tmp:
        files = safe_extract(args.zip, tmp, lim, args.strip)
    say("OK: %d 個のファイル。安全上の問題は見つかりませんでした。" % len(files))


def cmd_push_zip(args):
    cfg = load_config()
    need_login(cfg)
    rid = parse_repo(cfg, args.repo or os.path.splitext(os.path.basename(args.zip))[0].split("__")[0])
    if not os.path.isfile(args.zip):
        raise Fail("ファイルが見つかりません: %s" % args.zip)
    say("▶ %s  ←  %s" % (rid.replace(".", "/", 1), os.path.basename(args.zip)))
    if not repo_exists(cfg, rid):
        owner, name = rid.split(".", 1)
        if owner != cfg["user"]:
            raise Fail("リポジトリ %s は存在しません" % rid)
        if not (args.create or args.yes or input("  リポジトリ %s はまだありません。作りますか？ [y/N] " % name).strip().lower() in ("y", "yes")):
            raise Fail("中止しました")
        st, b = api(cfg, "POST", "/api/repos", {"name": name, "description": args.description or ""})
        if st != 201:
            raise Fail(b.get("error", "作成に失敗しました"))
        say("  リポジトリを作成しました")
    lim = limits_from_server(cfg)
    with tempfile.TemporaryDirectory(prefix="ooc-zip-") as tmp:
        tree = os.path.join(tmp, "tree")
        os.makedirs(tree)
        say("  zip を安全に展開しています…")
        flags = safe_extract(args.zip, tree, lim, args.strip)
        say("  %d 個のファイルを確認しました。" % len(flags))
        msg = args.message or "Import %s (sha256 %s)" % (os.path.basename(args.zip), zip_label(args.zip))
        import_tree(cfg, rid, tree, flags, msg, args.base, args)


def read_git_tree(repo_dir, commit, lim):
    """Read a commit's tree straight from Git objects (no checkout, no tar): regular files only."""
    def g(*a, inp=None):
        r = subprocess.run(["git", "-C", repo_dir] + list(a), input=inp, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        if r.returncode:
            raise Fail("git %s に失敗: %s" % (a[0], r.stderr.decode("utf-8", "replace")[:200]))
        return r.stdout
    ls = g("ls-tree", "-r", "-z", "--full-tree", commit).split(b"\x00")
    out, flags, total = {}, {}, 0
    if len(ls) > lim.max_files:
        raise Fail("ファイル数が多すぎます")
    for ent in ls:
        if not ent:
            continue
        meta, _tab, rawpath = ent.partition(b"\t")
        mode, typ, sha = meta.decode().split()
        if typ != "blob" or mode not in ("100644", "100755"):
            warn("無視しました（シンボリックリンク/サブモジュール）: %s" % rawpath.decode("utf-8", "replace")[:80])
            continue
        name = clean_name(rawpath.decode("utf-8", "replace"), "git")
        if name is None:
            continue
        size = int(g("cat-file", "-s", sha).strip())
        if size > lim.max_file:
            raise Fail("ファイルが大きすぎます（%d MiB）: %s" % (size // MiB, name))
        total += size
        if total > lim.max_total:
            raise Fail("合計サイズが大きすぎます")
        out[name] = g("cat-file", "blob", sha)
        flags[name] = mode == "100755"
    return out, flags


def cmd_git_submit(args):
    cfg = load_config()
    need_login(cfg)
    if not have("git"):
        raise Fail("git が見つかりません")
    d = os.path.abspath(args.repo_dir)

    def g(*a, check=True):
        r = subprocess.run(["git", "-C", d] + list(a), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        if check and r.returncode:
            raise Fail("git %s: %s" % (a[0], r.stderr.decode("utf-8", "replace").strip()[:200]))
        return r.stdout.decode("utf-8", "replace")
    url = g("remote", "get-url", "origin").strip()
    m = re.match(r"^%s/git/([a-z0-9_-]+\.[a-z0-9_-]+)\.git$" % re.escape(cfg["server"]), url)
    if not m:
        raise Fail("この Git リポジトリの origin は %s/git/<owner>.<name>.git ではありません" % cfg["server"])
    rid = m.group(1)
    g("fetch", "-q", "origin", check=False)
    upstream = "origin/main"
    commits = [c for c in g("rev-list", "--reverse", "--first-parent", "%s..HEAD" % upstream).split() if c]
    if not commits:
        raise Fail("送る新しいコミットがありません（origin/main より先に進んでいません）")
    base_commit = g("merge-base", upstream, "HEAD").strip()
    body = g("log", "-1", "--format=%B", base_commit)
    mm = re.search(r"git-svn-id: .*@(\d+) ", body)
    base = int(mm.group(1)) if mm else None
    if len(commits) > 50 and not args.squash:
        raise Fail("コミットが %d 個あります。--squash で1つにまとめて送れます。" % len(commits))
    say("▶ %s  ←  Git コミット %d 個%s" % (rid.replace(".", "/", 1), len(commits), "（まとめて1つに）" if args.squash else ""))
    lim = limits_from_server(cfg)
    todo = [commits[-1]] if args.squash else commits
    last = None
    for c in todo:
        subj = g("log", "-1", "--format=%s", c).strip()
        full = g("log", "-1", "--format=%B", c).strip()
        author = g("log", "-1", "--format=%an <%ae>", c).strip()
        if args.squash:
            full = "\n".join(g("log", "--reverse", "--format=* %s", "%s..%s" % (upstream, c)).strip().splitlines()[:30])
            subj = "Squashed %d Git commits" % len(commits)
            full = subj + "\n\n" + full
        msg = "%s\n\n(Git commit %s by %s)" % (full, c[:10], author)
        say("\n  ◆ %s  [%s]" % (subj[:70], c[:8]))
        blobs, flags = read_git_tree(d, c, lim)
        with tempfile.TemporaryDirectory(prefix="ooc-git-") as tmp:
            tree = os.path.join(tmp, "tree")
            for rel, data in blobs.items():
                p = os.path.join(tree, *rel.split("/"))
                os.makedirs(os.path.dirname(p), exist_ok=True)
                with open(p, "wb") as f:
                    f.write(data)
            rev = import_tree(cfg, rid, tree, flags, msg, base if last is None else None, args)
        if rev:
            last = rev
        elif args.dry_run:
            break
    if last and not args.no_rebase:
        say("\nGit ミラーが追いつくのを待っています（最大 90 秒）…")
        for _ in range(30):
            time.sleep(3)
            g("fetch", "-q", "origin", check=False)
            if re.search(r"git-svn-id: .*@%d " % last, g("log", "-1", "--format=%B", upstream)) or \
               re.search(r"git-svn-id: .*@%d " % last, g("log", "--format=%B", "-5", upstream)):
                r = subprocess.run(["git", "-C", d, "rebase", upstream], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
                say("  " + ("ローカルを最新のミラーに合わせました（git rebase origin/main）。" if r.returncode == 0
                            else "自動 rebase で衝突しました。`git rebase --abort` のうえ `git pull --rebase` を手動で。"))
                break
        else:
            say("  ミラーの更新はまだです。後で `git pull --rebase` してください。")


def process_zip_file(cfg, path, args):
    name = os.path.splitext(os.path.basename(path))[0]
    repo, _sep, label = name.partition("__")
    ns = argparse.Namespace(zip=path, repo=repo, message=("Import %s" % label if label else None), base=None, strip="auto",
                            yes=True, dry_run=False, partial=False, allow_mass_delete=False, on_conflict="abort",
                            create=True, description="")
    cmd_push_zip(ns)


def cmd_watch(args):
    cfg = load_config()
    need_login(cfg)
    d = os.path.abspath(os.path.expanduser(args.dir))
    for sub in ("", "done", "failed"):
        os.makedirs(os.path.join(d, sub), exist_ok=True)
    say("監視中: %s  （FILE.zip または NAME__ラベル.zip を入れると自動処理。Ctrl+C で終了）" % d)
    sizes = {}
    while True:
        for fn in sorted(os.listdir(d)):
            p = os.path.join(d, fn)
            if not (fn.lower().endswith(".zip") and os.path.isfile(p)):
                continue
            st = os.stat(p)
            key = (st.st_size, int(st.st_mtime))
            if sizes.get(p) != key or time.time() - st.st_mtime < 3:
                sizes[p] = key
                continue
            sizes.pop(p, None)
            stamp = time.strftime("%Y%m%d-%H%M%S")
            log = os.path.join(d, "failed", "%s-%s.log" % (stamp, fn))
            try:
                process_zip_file(cfg, p, args)
                shutil.move(p, os.path.join(d, "done", "%s-%s" % (stamp, fn)))
            except Fail as e:
                say("  ✘ %s: %s" % (fn, e))
                with open(log, "w") as f:
                    f.write(str(e) + "\n")
                shutil.move(p, os.path.join(d, "failed", "%s-%s" % (stamp, fn)))
            except Exception as e:  # keep watching
                say("  ✘ %s: 予期しないエラー: %s" % (fn, e))
                shutil.move(p, os.path.join(d, "failed", "%s-%s" % (stamp, fn)))
        time.sleep(3)


def cmd_install_integration(args):
    me = os.path.abspath(sys.argv[0])
    if IS_WINDOWS:
        send_to = os.path.join(os.environ.get("APPDATA", ""), "Microsoft", "Windows", "SendTo")
        if not os.path.isdir(send_to):
            raise Fail("SendTo フォルダが見つかりません")
        launcher = ["py", "-3", me] if me.lower().endswith(".py") else [me]
        cmdline = " ".join('"%s"' % a for a in launcher)
        path = os.path.join(send_to, "OOC リポジトリに送る.cmd")
        with open(path, "w", encoding="utf-8", newline="\r\n") as f:
            f.write("""@echo off
chcp 65001 >nul
set "Z=%%~1"
echo zip: %%Z%%
%(c)s push-zip "%%Z%%" --dry-run
echo.
choice /m "この内容でコミットしますか"
if errorlevel 2 exit /b 0
%(c)s push-zip "%%Z%%" --yes --create
pause
""" % {"c": cmdline})
        say("zip を右クリック →「送る」→「OOC リポジトリに送る」を追加しました（zip のファイル名がリポジトリ名になります。例: myproject.zip → myproject）。")
        if args.watch:
            say("Windows の自動監視は、`ooc-repo watch フォルダ` を開いたままにするか、タスクスケジューラで常駐させてください。")
        return
    scripts = os.path.expanduser("~/.local/share/nautilus/scripts")
    os.makedirs(scripts, exist_ok=True)
    sp = os.path.join(scripts, "OOC リポジトリに送る")
    with open(sp, "w") as f:
        f.write("""#!/bin/bash
# Right-click a .zip -> Scripts -> "OOC リポジトリに送る"
for z in "$@"; do
  name=$(basename "$z" .zip | cut -d_ -f1)
  repo=$(zenity --entry --title="OOC リポジトリ" --text="送り先（owner/name または name）:" --entry-text="$name") || exit 0
  out=$(%(me)s push-zip "$z" "$repo" --dry-run 2>&1)
  zenity --text-info --title="差分の確認" --width=720 --height=480 --ok-label="コミットする" --cancel-label="やめる" <<<"$out" || exit 0
  out=$(%(me)s push-zip "$z" "$repo" --yes --create 2>&1)
  zenity --text-info --title="結果" --width=720 --height=360 <<<"$out"
done
""" % {"me": me})
    os.chmod(sp, 0o755)
    say("ファイルマネージャー(Nautilus)の右クリック → スクリプト → 「OOC リポジトリに送る」を追加しました。")
    if args.watch:
        d = os.path.abspath(os.path.expanduser(args.watch))
        os.makedirs(d, exist_ok=True)
        ud = os.path.expanduser("~/.config/systemd/user")
        os.makedirs(ud, exist_ok=True)
        with open(os.path.join(ud, "ooc-repo-watch.service"), "w") as f:
            f.write("[Unit]\nDescription=ooc-repo drop folder\n\n[Service]\nExecStart=%s watch %s\nRestart=always\n\n[Install]\nWantedBy=default.target\n" % (me, d))
        say("systemd ユーザーサービスを作りました。有効化: systemctl --user enable --now ooc-repo-watch")
        say("ドロップフォルダ: %s" % d)


def main(argv=None):
    p = argparse.ArgumentParser(prog="ooc-repo", description=__doc__.split("\n\n")[0], formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("--version", action="version", version="ooc-repo " + VERSION)
    sub = p.add_subparsers(dest="cmd", required=True)
    a = sub.add_parser("login"); a.add_argument("--server"); a.add_argument("--user"); a.add_argument("--token"); a.set_defaults(fn=cmd_login)
    sub.add_parser("whoami").set_defaults(fn=cmd_whoami)
    a = sub.add_parser("create"); a.add_argument("name"); a.add_argument("-d", "--description"); a.set_defaults(fn=cmd_create)
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--yes", "-y", action="store_true", help="確認せずコミット")
    common.add_argument("--dry-run", "-n", action="store_true", help="差分を表示するだけ")
    common.add_argument("--partial", action="store_true", help="zip は一部のファイルだけ（削除はしない）")
    common.add_argument("--allow-mass-delete", action="store_true")
    common.add_argument("--on-conflict", choices=["abort", "theirs", "mine"], default="abort")
    common.add_argument("--base", type=int, help="この zip の元になったリビジョン（未指定なら前回の続き／最新）")
    a = sub.add_parser("push-zip", parents=[common]); a.add_argument("zip"); a.add_argument("repo", nargs="?")
    a.add_argument("-m", "--message"); a.add_argument("--strip", choices=["auto", "none"], default="auto")
    a.add_argument("--create", action="store_true", help="なければリポジトリを作る"); a.add_argument("-d", "--description"); a.set_defaults(fn=cmd_push_zip)
    a = sub.add_parser("git-submit", parents=[common]); a.add_argument("--repo-dir", default="."); a.add_argument("--squash", action="store_true")
    a.add_argument("--no-rebase", action="store_true"); a.set_defaults(fn=cmd_git_submit)
    a = sub.add_parser("watch"); a.add_argument("dir"); a.set_defaults(fn=cmd_watch)
    a = sub.add_parser("verify-zip"); a.add_argument("zip"); a.add_argument("--strip", choices=["auto", "none"], default="auto"); a.set_defaults(fn=cmd_verify)
    a = sub.add_parser("install-integration"); a.add_argument("--watch", help="ドロップフォルダを監視するサービスも作る"); a.set_defaults(fn=cmd_install_integration)
    args = p.parse_args(argv)
    try:
        args.fn(args)
    except Fail as e:
        print("エラー: %s" % e, file=sys.stderr)
        return 2
    except KeyboardInterrupt:
        return 130
    return 0


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