"""Двусторонняя синхронизация локальной папки и GitHub-репозитория.

Конвенция: имя репозитория = имя папки проекта (basename).
Репозиторий создаётся локально через gh CLI; Viktor только пушит в него.

Поток на каждую задачу:
  ensure_repo     -> локальный git + GitHub repo + remote viktor
  push_local      -> закоммитить/запушить текущее состояние (чтобы Viktor его видел)
  session_branch  -> создать ветку viktor/{id} и переключиться на неё
  ... Viktor работает и пушит ...
  switch_back     -> вернуться на main
  pull            -> подтянуть изменения Viktor'а обратно в папку
"""
from __future__ import annotations

import asyncio
import os
import re
from dataclasses import dataclass


@dataclass
class RepoInfo:
    name: str
    owner: str
    url: str
    branch: str


class GitError(RuntimeError):
    pass


_REMOTE = "viktor"  # имя git remote для репо Viktor (не origin — чтобы не перетирать существующий)


def sanitize_repo_name(raw: str) -> str:
    """Имя GitHub-репозитория допускает только [A-Za-z0-9._-].

    Папки вроде «litcoiin_ready1 - Copy» (пробелы, спецсимволы) превращаем в
    валидное имя, иначе URL ломается («Malformed input to a URL function»).
    """
    name = re.sub(r"[^A-Za-z0-9._-]+", "-", raw)  # рун недопустимых -> один дефис
    name = re.sub(r"-{2,}", "-", name).strip("-.")
    return name or "repo"


async def _run(args: list[str], cwd: str | None = None, timeout: int = 120) -> tuple[int, str]:
    try:
        proc = await asyncio.create_subprocess_exec(
            *args, cwd=cwd,
            stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
        )
    except FileNotFoundError as e:
        # WinError 2 / ENOENT — самого исполняемого файла нет в PATH (git/gh не
        # установлены или не в PATH). Понятное сообщение вместо «[WinError 2]».
        raise GitError(
            f"не найден '{args[0]}' — установите Git и GitHub CLI (gh), добавьте их "
            f"в PATH и перезапустите прокси (проверка: `git --version`, `gh --version`)"
        ) from e
    out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
    return proc.returncode or 0, out.decode(errors="replace").strip()


async def detect_owner(override: str = "") -> str:
    if override:
        return override
    code, out = await _run(["gh", "api", "user", "-q", ".login"])
    if code != 0 or not out:
        raise GitError(f"не удалось определить владельца GitHub: {out}")
    return out.splitlines()[0].strip()


async def current_branch(path: str) -> str:
    code, out = await _run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"])
    if code == 0 and out and out != "HEAD":
        return out
    return "main"


async def session_branch(path: str, session_id: str) -> str:
    """Создать ветку viktor/{session_id}, переключиться и запушть на remote.

    Возвращает имя ветки. Идемпотентно.
    """
    branch = f"viktor/{session_id}"
    code, out = await _run(["git", "-C", path, "rev-parse", "--verify", branch])
    if code != 0:
        await _run(["git", "-C", path, "checkout", "-b", branch])
        print(f"[git] создана ветка {branch}", flush=True)
    else:
        await _run(["git", "-C", path, "checkout", branch])
        print(f"[git] переключён на {branch}", flush=True)
    # Пушим ветку на remote (если её там ещё нет), чтобы Viktor её увидел
    code, _ = await _run(["git", "-C", path, "ls-remote", "--heads", _REMOTE, branch], timeout=30)
    if code != 0 or not _.strip():
        await _run(["git", "-C", path, "push", "-u", _REMOTE, branch])
        print(f"[git] ветка {branch} запушена на remote", flush=True)
    return branch


async def switch_back(path: str, target: str = "main") -> None:
    """Вернуться на указанную ветку (по умолчанию main)."""
    code, _ = await _run(["git", "-C", path, "rev-parse", "--verify", target])
    if code != 0:
        target = "main"
    await _run(["git", "-C", path, "checkout", target])
    print(f"[git] возврат на {target}", flush=True)


async def ensure_repo(path: str, owner: str) -> RepoInfo:
    """Гарантировать локальный git, ПРИВАТНЫЙ GitHub-репо и remote viktor.

    Идемпотентно. Репозиторий ВСЕГДА приватный: новый создаётся с --private,
    а уже существующий публичный переводится в приватный.
    """
    raw = os.path.basename(os.path.normpath(path))
    name = sanitize_repo_name(raw)
    if not name:
        raise GitError(f"не удалось извлечь имя репозитория из пути: {path}")
    if name != raw:
        print(f"[repo] имя папки '{raw}' -> репозиторий '{name}'", flush=True)
    url = f"https://github.com/{owner}/{name}.git"

    # 1) локальный git init
    if not os.path.isdir(os.path.join(path, ".git")):
        await _run(["git", "-C", path, "init", "-b", "main"])

    # 2) GitHub-репозиторий — всегда приватный
    code, vis = await _run(
        ["gh", "repo", "view", f"{owner}/{name}", "--json", "visibility", "-q", ".visibility"]
    )
    if code != 0:
        code, out = await _run(["gh", "repo", "create", f"{owner}/{name}", "--private"])
        if code != 0 and "already exists" not in out.lower():
            raise GitError(f"gh repo create провалился: {out}")
    elif vis.strip().upper() != "PRIVATE":
        # существующий публичный репозиторий -> делаем приватным
        await _run([
            "gh", "repo", "edit", f"{owner}/{name}",
            "--visibility", "private", "--accept-visibility-change-consequences",
        ])

    # 3) remote viktor
    code, out = await _run(["git", "-C", path, "remote", "get-url", _REMOTE])
    if code != 0:
        await _run(["git", "-C", path, "remote", "add", _REMOTE, url])
    elif out.strip() != url:
        await _run(["git", "-C", path, "remote", "set-url", _REMOTE, url])

    branch = await current_branch(path)
    return RepoInfo(name=name, owner=owner, url=url, branch=branch)


def ensure_local_exclude(path: str, entry: str) -> None:
    """Добавить entry в .git/info/exclude (локальный gitignore, НЕ пушится).

    Так папка со скачанным медиа (напр. viktor_media/) остаётся только на ПК и не
    попадает в коммиты/пуши — пользователь не хочет таскать большие видео в репо.
    """
    excl = os.path.join(path, ".git", "info", "exclude")
    if not os.path.isdir(os.path.dirname(excl)):
        return
    content = ""
    if os.path.exists(excl):
        with open(excl, encoding="utf-8", errors="replace") as f:
            content = f.read()
    if entry in content.split():
        return
    with open(excl, "a", encoding="utf-8") as f:
        if content and not content.endswith("\n"):
            f.write("\n")
        f.write(entry + "\n")


async def _merge_remote(path: str, branch: str, prefer: str = "ours") -> tuple[int, str]:
    """Интегрировать viktor/branch в локальную ветку: ff-only → merge → allow-unrelated.

    Нужно и перед push (чтобы не получить non-fast-forward), и для самого pull.
    `prefer` — чьей стороне отдать конфликтующие куски: 'ours' (локальное, для push)
    или 'theirs' (версия Viktor, для pull). При неразрешимом конфликте делаем
    merge --abort, чтобы НИКОГДА не оставлять рабочую копию в сломанном состоянии.
    Возвращает (code, out) последней попытки. Если ветки на remote нет — (0, '').
    """
    code, out = await _run(["git", "-C", path, "pull", "--ff-only", _REMOTE, branch])
    if code == 0:
        return code, out
    low = out.lower()
    if "couldn't find remote ref" in low or "no such ref" in low:
        return 0, out  # ветки на remote ещё нет — интегрировать нечего
    idn = ["-c", "user.email=valency-viktor@local", "-c", "user.name=viktor-valency"]
    xopt = ["-X", prefer]  # на конфликтах предпочитаем нужную сторону, не падаем
    merge = ["git", "-C", path, *idn, "pull", "--no-rebase", "--no-edit", *xopt, _REMOTE, branch]
    code2, out2 = await _run(merge)
    # Несвязанные истории (локальный init поверх уже жившего репо) — сливаем явно.
    if code2 != 0 and "unrelated histories" in out2.lower():
        code2, out2 = await _run(merge[:-2] + ["--allow-unrelated-histories", _REMOTE, branch])
    if code2 != 0:  # конфликт не разрулился (-X не помог) — откатываем, не ломаем папку
        await _run(["git", "-C", path, "merge", "--abort"])
    return code2, out2


async def push_local(path: str, branch: str) -> str:
    """Закоммитить ЛОКАЛЬНЫЕ изменения и запушить — ТОЛЬКО если они есть.

    Если пользователь ничего локально не менял, ничего не делаем и возвращаем ""
    (мост не пушит проект на каждое сообщение). Пустую папку без коммитов
    инициализируем пустым коммитом, чтобы ветка появилась и Viktor мог пушить.
    """
    cm = ["-c", "user.email=valency-viktor@local", "-c", "user.name=viktor-valency"]
    await _run(["git", "-C", path, "add", "-A"])
    has_head = (await _run(["git", "-C", path, "rev-parse", "--verify", "HEAD"]))[0] == 0
    has_staged = (await _run(["git", "-C", path, "diff", "--cached", "--quiet"]))[0] != 0

    report = []
    if has_staged:
        await _run(["git", "-C", path, *cm, "commit", "-m", "sync: локальные изменения перед задачей"])
        report.append("закоммичены локальные изменения")
    elif not has_head:
        await _run(["git", "-C", path, *cm, "commit", "--allow-empty", "-m", "init"])
        report.append("инициализирован пустой репозиторий")
    # После этого блока коммит ГАРАНТИРОВАННО есть (закоммитили staged / пустой init /
    # head уже был). has_head проверялся ДО коммита — обновляем, иначе для свежего
    # git init + пустого remote ahead посчитается 0 и первый push не уйдёт (репо
    # останется пустым, Viktor не увидит файлов).
    has_head = True

    # Есть ли что пушить? Сверяемся с РЕАЛЬНЫМ состоянием remote через ls-remote
    # (не с локальным кэш-рефом viktor/branch — он может быть устаревшим, напр. у
    # папки, скопированной из другого репо: viktor remote указывал на старый репозиторий).
    code, out = await _run(["git", "-C", path, "ls-remote", "--heads", _REMOTE, branch], timeout=60)
    remote_sha = out.split()[0] if (code == 0 and out.strip()) else ""
    if not remote_sha:
        ahead = 1 if has_head else 0  # ветки на remote нет (пустой репо) -> пушим
    else:
        code, out = await _run(["git", "-C", path, "rev-list", "--count", f"{remote_sha}..HEAD"])
        try:
            ahead = int(out.strip() or "0") if code == 0 else 1  # remote_sha не у нас -> пушим
        except ValueError:
            ahead = 1

    if ahead == 0:
        return ""  # нечего пушить — на GitHub уже актуальное состояние

    # Интегрируем remote (ff/merge/allow-unrelated — иначе push отклонят как
    # non-fast-forward и локальные файлы пользователя не дойдут до Viktor), потом пушим.
    await _merge_remote(path, branch)
    code, out = await _run(["git", "-C", path, "push", "-u", _REMOTE, branch])
    report.append("локальное состояние запушено" if code == 0 else f"push предупреждение: {out}")
    return "; ".join(report)


_EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"  # git пустое дерево


async def remote_head(path: str, branch: str) -> str:
    """SHA вершины ветки на GitHub (якорь «до задачи Viktor»). '' если ветки нет."""
    code, out = await _run(["git", "-C", path, "ls-remote", "--heads", _REMOTE, branch], timeout=60)
    return out.split()[0] if (code == 0 and out.strip()) else ""


async def changed_files(path: str, branch: str, base_sha: str) -> list[str]:
    """Изменённые Viktor'ом файлы: diff base_sha..viktor/branch (name-status).

    base_sha '' (новый/пустой репо) -> diff от пустого дерева (всё = добавлено).
    Возвращает строки git diff --name-status, напр. 'M\\tsrc/x.rs'.
    """
    await _run(["git", "-C", path, "fetch", _REMOTE, branch], timeout=60)
    code, ref = await _run(["git", "-C", path, "rev-parse", "--verify", f"{_REMOTE}/{branch}"])
    if code != 0:
        return []
    base = base_sha or _EMPTY_TREE
    code, out = await _run(["git", "-C", path, "diff", "--name-status", f"{base}..{_REMOTE}/{branch}"])
    if code != 0:
        # base неизвестен локально -> сравним с пустым деревом
        code, out = await _run(["git", "-C", path, "diff", "--name-status", f"{_EMPTY_TREE}..{_REMOTE}/{branch}"])
    return [ln for ln in out.splitlines() if ln.strip()]


async def has_incoming(path: str, branch: str) -> bool:
    """Есть ли на viktor/branch коммиты, которых нет локально (т.е. что тянуть).

    Делает fetch и сравнивает HEAD с viktor/branch. Нет ветки/ошибка → False.
    """
    await _run(["git", "-C", path, "fetch", _REMOTE, branch], timeout=60)
    code, out = await _run(["git", "-C", path, "rev-list", "--count", f"HEAD..{_REMOTE}/{branch}"])
    try:
        return code == 0 and int(out.strip()) > 0
    except (ValueError, AttributeError):
        return False


async def pull(path: str, branch: str) -> str:
    """Подтянуть изменения из viktor/branch обратно в папку."""
    code, out = await _merge_remote(path, branch, prefer="theirs")
    status = "ok" if code == 0 else f"конфликт (код {code})"
    return f"[git pull {status}]\n{out}"
