"""Исполнение команд и запись доказательств.

Единственный способ получить доказательство — реально прогнать команду через этот
модуль. Модель не может «отметить, что тесты прошли»: отметку ставит код возврата.
"""

from __future__ import annotations

import hashlib
import re
import shlex
import subprocess
import time
from datetime import datetime
from pathlib import Path

from .store import STATE_DIR, Run, StateError, git, git_sha, harness, now


def worktree_fingerprint(root: Path) -> str | None:
    """Отпечаток рабочей копии, а не только коммита.

    Незакоммиченная правка между доказательством и закрытием таска иначе проходит
    незамеченной: HEAD тот же, код другой. Учитывает как отслеживаемые, так и
    неотслеживаемые файлы рабочей копии (кроме каталога состояния).
    """
    status = git(root, "status", "--porcelain=v1", "-uall")
    if not status and not git_sha(root):
        return None
    # Служебные файлы прогона исключаются: они меняются от каждой команды по
    # замыслу. Без этого после первого же коммита каталога прогона закрыть
    # задачу становилось невозможно — отпечаток расходился всегда.
    lines: list[str] = []
    for ln in status.splitlines():
        if not ln.strip() or f"{STATE_DIR}/" in ln:
            continue
        rel = ln[3:].strip()
        if " -> " in rel:
            rel = rel.split(" -> ", 1)[1].strip()
        p = root / rel
        if p.is_file():
            lines.append(f"{ln}:{_digest(p)}")
        else:
            lines.append(ln)

    return hashlib.sha256("\n".join(sorted(lines)).encode("utf-8")).hexdigest()[:16]

REUSE_MAX_AGE = 24 * 3600
_SKIP_DIRS = {".git", STATE_DIR, "__pycache__", ".venv", "node_modules"}


def _zone_paths(zone: str) -> list[str]:
    """Зона — один путь или несколько через запятую."""
    return [p.strip().strip("/") for p in (zone or "").split(",") if p.strip().strip("/")]


def _digest(path: Path) -> str:
    try:
        return hashlib.sha256(path.read_bytes()).hexdigest()
    except OSError:
        return "missing"


def _zone_files(root: Path, zone: str) -> list[Path]:
    files: list[Path] = []
    for rel in _zone_paths(zone):
        p = root / rel
        if p.is_dir():
            for f in sorted(p.rglob("*")):
                try:
                    parts = f.relative_to(root).parts
                except ValueError:
                    continue
                if f.is_file() and not any(part in _SKIP_DIRS for part in parts):
                    files.append(f)
        else:
            files.append(p)  # файла может не быть — отсутствие тоже часть отпечатка
    return files


def _command_files(root: Path, cmd: str) -> list[Path]:
    """Файлы, которые команда запускает.

    Без них `pytest tests/a.py` и `pytest tests/b.py` при одной зоне неотличимы:
    правка проверяемого файла обязана ломать переиспользование, даже если сам
    файл лежит вне зоны задачи. Каталоги разворачиваются во все вложенные файлы.
    """
    try:
        tokens = shlex.split(cmd)
    except ValueError:
        tokens = cmd.split()
    files: list[Path] = []
    for tok in tokens:
        tok = tok.split("::", 1)[0]
        if not tok or tok.startswith("-"):
            continue
        p = root / tok
        if p.is_file():
            files.append(p)
        elif p.is_dir():
            for f in sorted(p.rglob("*")):
                try:
                    parts = f.relative_to(root).parts
                except ValueError:
                    continue
                if f.is_file() and not any(part in _SKIP_DIRS for part in parts):
                    files.append(f)
    return files


def reuse_fingerprint(run: Run, task: str | None, cmd: str, kind: str = "check") -> str | None:
    """Отпечаток входа прогона: файлы зоны + запускаемый файл + сама строка команды.

    None означает «переиспользование запрещено». Запретов четыре, и три из них
    видны прямо здесь: приёмка, прогон без таска и таск без зоны. Пустая зона —
    это не «весь репозиторий»: чем такая задача проверяется, отпечатком не
    описать, поэтому у неё переиспользования нет никогда.
    """
    if kind == "accept" or not task:
        return None
    t = run.task(task)
    if t is None or not (t.get("zone") or "").strip():
        return None
    root = run.root
    seen: dict[str, str] = {}
    for f in _zone_files(root, t["zone"]) + _command_files(root, cmd):
        try:
            key = str(f.relative_to(root))
        except ValueError:
            key = str(f)
        seen.setdefault(key, _digest(f))
    h = hashlib.sha256()
    h.update(cmd.encode("utf-8"))
    for key in sorted(seen):
        h.update(f"\0{key}\0{seen[key]}".encode("utf-8"))
    return h.hexdigest()[:16]


def _age_seconds(ts: str | None) -> float | None:
    if not ts:
        return None
    try:
        return (datetime.now().astimezone() - datetime.fromisoformat(ts)).total_seconds()
    except ValueError:
        return None


def staged_files_outside_zone(root: Path, zone: str) -> list[str]:
    """Файлы в staged-индексе git вне разрешённой зоны задачи."""
    from .gates import _zone_paths, _zones_overlap

    head = git_sha(root)
    if head:
        out = git(root, "diff-index", "--cached", "--name-only", "HEAD")
    else:
        out = git(root, "diff", "--cached", "--name-only")
    staged = [line.strip() for line in (out or "").splitlines() if line.strip()]
    if not staged:
        return []
    allowed_paths = _zone_paths(zone)
    if not allowed_paths:
        return []
    alien = []
    for f in staged:
        norm = f.strip().strip("/")
        if norm.startswith(f"{STATE_DIR}/") or norm == STATE_DIR:
            continue
        if not any(_zones_overlap(norm, z) for z in allowed_paths):
            alien.append(f)
    return alien


def find_reusable(run: Run, fingerprint: str | None) -> dict | None:
    """Прошлый зелёный прогон с тем же отпечатком, который ещё можно засчитать."""
    if not fingerprint:
        return None
    head = git_sha(run.root)
    current_tree = worktree_fingerprint(run.root)
    for row in reversed(run.evidence()):
        if row.get("reused") or row.get("exit") != 0 or row.get("kind") == "accept":
            continue
        if row.get("fingerprint") != fingerprint:
            continue
        if row.get("git_sha") != head:  # другой коммит — другой код
            continue
        if row.get("tree") is not None and row.get("tree") != current_tree:
            continue
        age = _age_seconds(row.get("ts"))
        if age is None or age > REUSE_MAX_AGE:
            continue
        return row
    return None


TAIL_LINES = 40
TAIL_CHARS = 4000


def _capture_via_pty(cmd: str, cwd: Path, timeout: int) -> tuple[int, str]:
    """Прогон под псевдотерминалом.

    Многие инструменты печатают итог только в терминал: `pytest -q`, отданный в
    канал, не пишет строку «337 passed» вообще. Код возврата от этого не страдает,
    но журнал копит записи, по которым человек потом не прочитает результат, —
    а читаемость доказательства и есть смысл журнала.
    """
    import errno
    import os
    import pty
    import select

    master, slave = pty.openpty()
    try:
        proc = subprocess.Popen(
            cmd, shell=True, cwd=cwd, stdin=subprocess.DEVNULL,
            stdout=slave, stderr=slave, close_fds=True,
        )
    except OSError:
        os.close(master)
        os.close(slave)
        raise
    os.close(slave)

    chunks: list[bytes] = []
    deadline = time.monotonic() + timeout
    try:
        while True:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                proc.kill()
                chunks.append(b"\n[command timed out]")
                break
            ready, _, _ = select.select([master], [], [], min(remaining, 1.0))
            if ready:
                try:
                    data = os.read(master, 65536)
                except OSError as exc:
                    if exc.errno == errno.EIO:  # обычный конец на macOS
                        break
                    raise
                if not data:
                    break
                chunks.append(data)
            elif proc.poll() is not None:
                break
    finally:
        os.close(master)
    exit_code = proc.wait()
    text = b"".join(chunks).decode("utf-8", errors="replace").replace("\r\n", "\n")
    return exit_code, text


ANSI = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07")


def _tail(text: str) -> str:
    # Под псевдотерминалом инструменты раскрашивают вывод. В журнале коды только
    # мешают: его читают глазами и грепают, а не показывают в терминале.
    text = ANSI.sub("", text)
    lines = text.splitlines()[-TAIL_LINES:]
    out = "\n".join(lines)
    return out[-TAIL_CHARS:] if len(out) > TAIL_CHARS else out


def run_command(
    run: Run,
    cmd: str,
    task: str | None = None,
    kind: str = "check",
    model: str | None = None,
    cwd: Path | None = None,
    timeout: int = 1800,
    reuse: bool = True,
    extra: dict | None = None,
) -> dict:
    """Прогоняет команду, пишет строку в журнал, возвращает её.

    `extra` дописывается в строку журнала перед записью: отрыв (R07) помечает
    исход своей меткой, а собирать строку доказательства вторым кодом — верный
    способ развести два разных доказательства.
    """
    if not cmd or not cmd.strip():
        raise StateError(
            "пустая команда не может быть доказательством. Оболочка возвращает на неё "
            "ноль, и таск закрылся бы, ничего не проверив — назовите настоящую команду."
        )
    if run.task(task) is None and task is not None:
        raise StateError(
            f"нет таска {task}: доказательство привязать не к чему. Заведите таск "
            f"(`chekan task add {task} …`) или уберите --task."
        )
    workdir = cwd or run.root
    fingerprint = reuse_fingerprint(run, task, cmd, kind) if reuse else None
    prior = find_reusable(run, fingerprint)
    if prior is not None:
        age = int(_age_seconds(prior.get("ts")) or 0)
        row = dict(prior)
        row.update({
            "ts": now(),
            "task": task,
            "kind": kind,
            "cmd": cmd,
            "duration_ms": 0,
            "agent": harness(),
            "reused": True,
            "reused_from": prior.get("ts"),
            "reused_age_s": age,
            "fingerprint": fingerprint,
            # git_sha и tree — от прогона, который реально был: строка журнала не
            # должна выдавать себя за проверку сегодняшнего дерева.
            "git_sha": prior.get("git_sha"),
            "tree": prior.get("tree"),
        })
        row.update(extra or {})
        run.append_evidence(row)
        return row

    started = time.monotonic()
    try:
        exit_code, out = _capture_via_pty(cmd, workdir, timeout)
        err = ""
    except Exception:
        # Псевдотерминал недоступен — обычные каналы. Итог может быть беднее,
        # но код возврата остаётся авторитетным.
        try:
            proc = subprocess.run(
                cmd, shell=True, cwd=workdir, capture_output=True, text=True, timeout=timeout,
            )
            exit_code = proc.returncode
            out, err = proc.stdout or "", proc.stderr or ""
        except subprocess.TimeoutExpired:
            exit_code = 124
            out, err = "", f"команда не уложилась в {timeout} с и была снята"
        except OSError as exc:
            exit_code = 127
            out, err = "", f"не удалось запустить: {exc}"

    row = {
        "ts": now(),
        "task": task,
        "kind": kind,
        "cmd": cmd,
        "exit": exit_code,
        "duration_ms": int((time.monotonic() - started) * 1000),
        "cwd": str(workdir),
        "git_sha": git_sha(run.root),
        "tree": worktree_fingerprint(run.root),
        "agent": harness(),
        "fingerprint": fingerprint,
        "reused": False,
        "model": model or (run.task(task) or {}).get("model") if task else model,
        # Потоки раздельно: под псевдотерминалом они и так слиты (err пуст), а на
        # запасном пути это не даёт многословному stderr вытеснить итог из stdout.
        "out_tail": _tail(out),
        "err_tail": _tail(err),
    }
    # Фиксация базы 1С в журнале доказательств, если есть конфигурация chekan-1c.json
    onec_cfg = run.root / "chekan-1c.json"
    if onec_cfg.is_file():
        try:
            from .onec_lock import load_onec_config
            cfg_data = load_onec_config(run.root)
            conn_val = cfg_data.get("ibconnection") or cfg_data.get("db_path") or cfg_data.get("path")
            if conn_val:
                row["ibconnection"] = str(conn_val)
        except Exception:
            pass

    row.update(extra or {})
    run.append_evidence(row)
    return row


def close_task(run: Run, tid: str) -> tuple[bool, list[str]]:
    """Закрывает таск. Отказывает, если за ним нет зелёного доказательства."""
    task = run.task(tid)
    if task is None:
        return False, [f"нет таска {tid}"]

    green = run.green_evidence(tid)
    if not green:
        attempts = run.evidence(tid)
        if attempts:
            last = attempts[-1]
            return False, [
                f"таск {tid} нельзя закрыть: последний прогон `{last['cmd']}` "
                f"вернул код {last['exit']}",
                "Почините и прогоните заново через `chekan run`.",
            ]
        return False, [
            f"таск {tid} нельзя закрыть: доказательств нет вообще.",
            f"Прогоните проверку: chekan run {shlex.quote(task.get('verify') or '<команда проверки>')} --task {tid}",
        ]

    head = git_sha(run.root)
    if head and green.get("git_sha") and head != green["git_sha"]:
        return False, [
            f"таск {tid}: доказательство снято на коммите {green['git_sha'][:8]}, "
            f"а сейчас HEAD {head[:8]}. Код изменился после проверки — прогоните заново.",
        ]
    alien = staged_files_outside_zone(run.root, task.get("zone", ""))
    if alien:
        files_list = ", ".join(alien[:5]) + (f" (и ещё {len(alien) - 5})" if len(alien) > 5 else "")
        restore_cmd = f"git restore --staged {' '.join(shlex.quote(f) for f in alien)}"
        return False, [
            f"таск {tid}: общий индекс git содержит чужие файлы вне зоны задачи ({files_list}).",
            f"Очистите чужие файлы из индекса: {restore_cmd}",
        ]

    # Сравнения коммитов мало: обычный прогон коммитит в конце, и правка между
    # доказательством и закрытием остаётся незакоммиченной — а значит невидимой.
    if green.get("tree") is not None and worktree_fingerprint(run.root) != green["tree"]:
        return False, [
            f"таск {tid}: рабочая копия изменилась после доказательства "
            "(правки не закоммичены). Прогоните проверку заново — иначе закрываете "
            "таск по прогону другого кода.",
        ]

    run.set_task_status(tid, "done")
    st = run.state
    task = next(t for t in st["tasks"] if t["id"] == tid)
    task["commit"] = head
    task["commit_subject"] = git(run.root, "log", "-1", "--format=%s") or None
    task["closed_at"] = now()
    for rid in task.get("requirements", []):
        req = next((r for r in st["requirements"] if r["id"] == rid), None)
        if req and all(
            t["status"] == "done"
            for t in st["tasks"]
            if rid in t.get("requirements", [])
        ):
            req["status"] = "done"
    run.save(st)
    return True, [f"таск {tid} закрыт, доказательство: `{green['cmd']}` → 0"]
