# oceansguard/cli.py
from __future__ import annotations

import argparse
import fnmatch
import hashlib
import json
import os
import re
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from .frontend_policy import run_frontend_strict_policy


# ============================================================
# UX / logging
# ============================================================

class UX:
    def __init__(self, verbose: bool = False) -> None:
        self.verbose = verbose

    def info(self, msg: str) -> None:
        print(f"[OceansGuard] {msg}")

    def warn(self, msg: str) -> None:
        print(f"[OceansGuard][WARN] {msg}")

    def debug(self, msg: str) -> None:
        if self.verbose:
            print(f"[OceansGuard][DEBUG] {msg}")


def now_iso() -> str:
    return datetime.now().isoformat(timespec="seconds")


def die(msg: str, code: int = 1) -> None:
    # NOTE: exit code を確実に反映し、メッセージは stderr に出す
    print(f"[OceansGuard][ERROR] {msg}", file=sys.stderr)
    raise SystemExit(code)


def guard_root() -> Path:
    # oceansguard/cli.py -> oceansguard/
    return Path(__file__).resolve().parent


# ============================================================
# subprocess utils
# ============================================================

def _decode_bytes(b: bytes) -> str:
    if not b:
        return ""
    try:
        return b.decode("utf-8")
    except UnicodeDecodeError:
        return b.decode("utf-8", errors="replace")


def run_argv(argv: List[str], cwd: Path, check: bool = False, timeout: int | None = None) -> subprocess.CompletedProcess:
    cp = subprocess.run(
        argv,
        cwd=str(cwd),
        capture_output=True,
        check=check,
        timeout=timeout,
        env=os.environ.copy(),
    )
    cp.stdout = _decode_bytes(cp.stdout)  # type: ignore[attr-defined]
    cp.stderr = _decode_bytes(cp.stderr)  # type: ignore[attr-defined]
    return cp


def run_shell(cmd: str, cwd: Path, check: bool = False, timeout: int | None = None) -> subprocess.CompletedProcess:
    """
    capture_output 前提（ログ回収用）。
    ※リアルタイム出力が必要な場合は run_shell_stream() を使う。
    """
    cp = subprocess.run(
        cmd,
        cwd=str(cwd),
        shell=True,
        capture_output=True,
        check=check,
        timeout=timeout,
        env=os.environ.copy(),
    )
    cp.stdout = _decode_bytes(cp.stdout)  # type: ignore[attr-defined]
    cp.stderr = _decode_bytes(cp.stderr)  # type: ignore[attr-defined]
    return cp


def run_shell_stream(
    cmd: str,
    cwd: Path,
    timeout: int | None = None,
    *,
    print_prefix: str = "",
    env: Optional[Dict[str, str]] = None,
) -> subprocess.CompletedProcess:
    """
    リアルタイムで stdout/stderr をストリーミングしつつ、最終的に stdout/stderr を文字列として回収する。

    - npm ci / npm install / npm run build など「長くて止まって見える」系に使う
    - mac / linux で安定（shell=True 前提）
    """
    started = time.time()

    proc = subprocess.Popen(
        cmd,
        cwd=str(cwd),
        shell=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        bufsize=1,  # line-buffered
        universal_newlines=True,
        env=env or os.environ.copy(),
    )

    out_lines: List[str] = []
    err_lines: List[str] = []

    def _drain(pipe, collector: List[str], is_err: bool) -> None:
        # pipe は text=True なので str が来る
        assert pipe is not None
        for line in pipe:
            collector.append(line)
            # 画面にも出す（prefix を付与）
            if print_prefix:
                if is_err:
                    # stderr は [stderr] を付けて識別しやすく
                    sys.stderr.write(f"{print_prefix}[stderr] {line}")
                else:
                    sys.stdout.write(f"{print_prefix}{line}")
            else:
                # prefix なし
                if is_err:
                    sys.stderr.write(line)
                else:
                    sys.stdout.write(line)

    # stdout/stderr を同時に読む（片方で詰まらないように）
    import threading

    t_out = threading.Thread(target=_drain, args=(proc.stdout, out_lines, False), daemon=True)
    t_err = threading.Thread(target=_drain, args=(proc.stderr, err_lines, True), daemon=True)
    t_out.start()
    t_err.start()

    try:
        rc = proc.wait(timeout=timeout)
    except subprocess.TimeoutExpired:
        # timeout → kill
        proc.kill()
        err_lines.append(f"\n[TIMEOUT] exceeded {timeout}s\n")
        rc = 124
    finally:
        # join（短時間だけ待つ）
        t_out.join(timeout=1.0)
        t_err.join(timeout=1.0)

    cp = subprocess.CompletedProcess(
        args=cmd,
        returncode=rc,
        stdout="".join(out_lines),
        stderr="".join(err_lines),
    )
    # “経過時間” を呼び出し側がログできるように（属性追加ではなく、呼び出し側で計測推奨）
    _ = started
    return cp


# ============================================================
# file utils
# ============================================================

def sha256_text(s: str) -> str:
    return hashlib.sha256(s.encode("utf-8", errors="ignore")).hexdigest()


def safe_read_text(p: Path, max_kb: int) -> str:
    try:
        b = p.read_bytes()
    except Exception as e:
        return f"(failed to read: {e})\n"

    if len(b) > max_kb * 1024:
        return f"(skipped: too large {len(b)} bytes > {max_kb}KB)\n"

    try:
        return b.decode("utf-8", errors="replace")
    except Exception as e:
        return f"(failed to decode: {e})\n"


# ============================================================
# Config (YAML)
# ============================================================

def ensure_pyyaml(strict: bool) -> bool:
    try:
        import yaml  # noqa: F401
        return True
    except Exception:
        if strict:
            die("PyYAML is required in --strict mode. Install: pip install pyyaml")
        return False


@dataclass
class ContextSmall:
    include: List[str]


@dataclass
class ContextLarge:
    roots: List[str]
    exclude_dirs: List[str]
    exclude_globs: List[str]
    max_files: int
    max_kb_each: int


@dataclass
class Evidence:
    commands: List[str]


@dataclass
class Dlp:
    enable: bool
    block_on_detect: bool
    mask: bool
    allowlist_files: List[str]


@dataclass
class Guard:
    max_files: int
    max_lines: int
    forbid_full_rewrite: bool
    allow_full_rewrite_globs: List[str]


@dataclass
class Checks:
    commands: List[str]
    strict_commands: List[str]


@dataclass
class Output:
    pack: str
    audit: str
    testlog: str
    report_json: str


@dataclass
class Config:
    version: int
    context_small: ContextSmall
    context_large: ContextLarge
    evidence: Evidence
    dlp: Dlp
    guard: Guard
    checks: Checks
    output: Output


def _dict_get(d: Dict[str, Any], key: str, default: Any) -> Any:
    v = d.get(key, default)
    return default if v is None else v


def load_config(ux: UX, repo: Path, strict: bool) -> Config:
    has_yaml = ensure_pyyaml(strict=strict)
    cfg_path = repo / ".aiguard.yml"
    if not cfg_path.exists():
        die(".aiguard.yml not found. Run `aiguard init --repo <repo>` first.")

    raw: Dict[str, Any] = {}
    if has_yaml:
        try:
            import yaml  # type: ignore
            raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
        except Exception as e:
            if strict:
                die(f"Failed to parse .aiguard.yml: {e}")
            ux.warn(f"Failed to parse .aiguard.yml; using minimal defaults. ({e})")
            raw = {}
    else:
        if strict:
            die("Cannot read .aiguard.yml without PyYAML in strict mode.")
        ux.warn("PyYAML not found. Config-driven features may be skipped.")

    version = int(_dict_get(raw, "version", 1))

    ctx = _dict_get(raw, "context", {})
    small = _dict_get(ctx, "small", {})
    large = _dict_get(ctx, "large", {})

    context_small = ContextSmall(include=[str(x) for x in _dict_get(small, "include", [])])

    context_large = ContextLarge(
        roots=[str(x) for x in _dict_get(large, "roots", ["backend", "app", "src", "frontend"])],
        exclude_dirs=[str(x) for x in _dict_get(
            large,
            "exclude_dirs",
            [
                ".git", ".venv", "venv",
                "node_modules", "dist", "build", ".next", ".nuxt", ".vite", ".turbo", "coverage",
                "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache",
            ],
        )],
        exclude_globs=[str(x) for x in _dict_get(
            large,
            "exclude_globs",
            ["**/*.min.js", "**/*.map", "**/*.lock", "**/*.log"],
        )],
        max_files=int(_dict_get(large, "max_files", 300)),
        max_kb_each=int(_dict_get(large, "max_kb_each", 64)),
    )

    ev = _dict_get(raw, "evidence", {})
    evidence = Evidence(commands=[str(x) for x in _dict_get(ev, "commands", [])])

    dlp_raw = _dict_get(raw, "dlp", {})
    dlp = Dlp(
        enable=bool(_dict_get(dlp_raw, "enable", True)),
        block_on_detect=bool(_dict_get(dlp_raw, "block_on_detect", True)),
        mask=bool(_dict_get(dlp_raw, "mask", True)),
        allowlist_files=[str(x) for x in _dict_get(dlp_raw, "allowlist_files", [])],
    )

    guard_raw = _dict_get(raw, "guard", {})
    guard = Guard(
        max_files=int(_dict_get(guard_raw, "max_files", 10)),
        max_lines=int(_dict_get(guard_raw, "max_lines", 400)),
        forbid_full_rewrite=bool(_dict_get(guard_raw, "forbid_full_rewrite", True)),
        allow_full_rewrite_globs=[str(x) for x in _dict_get(guard_raw, "allow_full_rewrite_globs", [])],
    )

    checks_raw = _dict_get(raw, "checks", {})
    checks = Checks(
        commands=[str(x) for x in _dict_get(checks_raw, "commands", [])],
        strict_commands=[str(x) for x in _dict_get(checks_raw, "strict_commands", [])],
    )

    out_raw = _dict_get(raw, "output", {})
    output = Output(
        pack=str(_dict_get(out_raw, "pack", "ai_context_pack.md")),
        audit=str(_dict_get(out_raw, "audit", "CHANGELOG_AI.md")),
        testlog=str(_dict_get(out_raw, "testlog", "ai_test_last.log")),
        report_json=str(_dict_get(out_raw, "report_json", "ai_check_report.json")),
    )

    return Config(
        version=version,
        context_small=context_small,
        context_large=context_large,
        evidence=evidence,
        dlp=dlp,
        guard=guard,
        checks=checks,
        output=output,
    )


# ============================================================
# init (optional templates)
# ============================================================

def copy_if_missing(ux: UX, src: Path, dst: Path, *, force: bool = False) -> None:
    if dst.exists() and not force:
        ux.info(f"skip exists: {dst}")
        return
    dst.parent.mkdir(parents=True, exist_ok=True)
    dst.write_bytes(src.read_bytes())
    ux.info(f"{'overwrite' if dst.exists() and force else 'create'}: {dst}")



def write_if_missing(ux: UX, dst: Path, content: str, *, force: bool = False) -> None:
    if dst.exists() and not force:
        ux.info(f"skip exists: {dst}")
        return
    dst.parent.mkdir(parents=True, exist_ok=True)
    dst.write_text(content, encoding="utf-8")
    ux.info(f"{'overwrite' if dst.exists() and force else 'create'}: {dst}")



def cmd_init(ux: UX, repo: Path, *, force: bool = False) -> None:
    tpl = guard_root() / "templates" / ".aiguard.yml"
    if not tpl.exists():
        die("templates/.aiguard.yml not found in oceansguard package")

    copy_if_missing(ux, tpl, repo / ".aiguard.yml", force=force)

    write_if_missing(
        ux,
        repo / "ARCHITECTURE.md",
        "# ARCHITECTURE\n\n- レイヤ構成\n- 依存方向\n- 外部I/O（API/DB）\n\n"
        f"_generated by OceansGuard init @ {now_iso()}_\n",
        force=False,  # これは上書きしない方が安全
    )
    write_if_missing(
        ux,
        repo / "SNAPSHOT.md",
        "# SNAPSHOT\n\n- 現在の仕様\n- 既知の制約\n- 触ってはいけない領域\n\n"
        f"_generated by OceansGuard init @ {now_iso()}_\n",
        force=False,
    )

    contracts = repo / "contracts"
    contracts.mkdir(parents=True, exist_ok=True)
    write_if_missing(
        ux,
        contracts / "README.md",
        "# Contracts\n\nこのディレクトリには、守るべき契約（スキーマ/仕様）を置きます。\n\n"
        "- OpenAPI: contracts/openapi.json（または openapi.yaml）\n"
        "- DB schema snapshot\n"
        "- DTO/型\n",
        force=False,
    )

    if not (contracts / "openapi.json").exists():
        (contracts / "openapi.json").write_text("", encoding="utf-8")
        ux.info("create: contracts/openapi.json (empty)")

    ux.info("init completed")



# ============================================================
# pack
# ============================================================

def glob_files(root: Path, pattern: str) -> List[Path]:
    return [p for p in root.glob(pattern) if p.is_file()]


def should_exclude(path: Path, repo: Path, exclude_dirs: List[str], exclude_globs: List[str]) -> bool:
    rel = path.relative_to(repo).as_posix()

    parts = set(Path(rel).parts)
    if any(d in parts for d in exclude_dirs):
        return True

    for g in exclude_globs:
        if fnmatch.fnmatch(rel, g):
            return True

    return False


def collect_small(repo: Path, cfg: Config) -> List[Path]:
    out: List[Path] = []
    for pat in cfg.context_small.include:
        out.extend(glob_files(repo, pat))

    seen = set()
    uniq: List[Path] = []
    for p in sorted(out, key=lambda x: x.relative_to(repo).as_posix()):
        rel = p.relative_to(repo).as_posix()
        if rel in seen:
            continue
        seen.add(rel)
        uniq.append(p)
    return uniq


def collect_changed_files(repo: Path) -> List[Path]:
    cp = run_argv(["git", "diff", "--name-only"], cwd=repo)
    files: List[Path] = []
    for ln in (cp.stdout or "").splitlines():  # type: ignore[union-attr]
        ln = ln.strip()
        if not ln:
            continue
        p = repo / ln
        if p.exists() and p.is_file():
            files.append(p)
    return files


def collect_large(ux: UX, repo: Path, cfg: Config) -> List[Path]:
    """
    os.walk-based collector.
    - Avoids pathlib.rglob overhead.
    - Prevents descending into excluded dirs BEFORE entering them.
    - Shows progress during collection.
    """
    files: List[Path] = []
    roots = cfg.context_large.roots[:] if cfg.context_large.roots else ["."]
    max_files = int(cfg.context_large.max_files)

    scanned = 0
    last_log = time.time()

    for r in roots:
        base = (repo / r).resolve()
        if not base.exists():
            ux.debug(f"pack: skip missing root {r}")
            continue

        for dirpath, dirnames, filenames in os.walk(base, followlinks=False):
            dirnames[:] = [d for d in dirnames if d not in cfg.context_large.exclude_dirs]

            for fn in filenames:
                p = Path(dirpath) / fn
                if not p.is_file():
                    continue
                if should_exclude(p, repo, cfg.context_large.exclude_dirs, cfg.context_large.exclude_globs):
                    continue

                files.append(p)
                scanned += 1

                now = time.time()
                if now - last_log >= 1.0:
                    ux.info(f"pack: collect files ... scanned={scanned}, kept={len(files)}")
                    last_log = now

                if len(files) >= max_files:
                    return sorted(files, key=lambda x: x.relative_to(repo).as_posix())

    return sorted(files, key=lambda x: x.relative_to(repo).as_posix())


def pack_git_section(repo: Path) -> str:
    buf: List[str] = []
    buf.append("## Git\n")

    st = run_argv(["git", "status", "--porcelain=v1"], cwd=repo)
    buf.append("\n### git status --porcelain=v1\n```text\n")
    buf.append(st.stdout or "")  # type: ignore[arg-type]
    buf.append("\n```\n")

    df = run_argv(["git", "diff"], cwd=repo)
    diff_text = df.stdout or ""  # type: ignore[assignment]
    buf.append("\n### git diff\n```diff\n")
    if len(diff_text) > 200_000:
        buf.append(diff_text[:200_000])
        buf.append("\n... (truncated)\n")
    else:
        buf.append(diff_text)
    buf.append("\n```\n")

    return "".join(buf)


def evidence_section(repo: Path, cfg: Config) -> str:
    buf: List[str] = []
    if not cfg.evidence.commands:
        return ""
    buf.append("## Evidence\n")
    for cmd in cfg.evidence.commands:
        buf.append(f"\n### $ {cmd}\n")
        cp = run_shell(cmd, cwd=repo)
        buf.append("```text\n")
        buf.append(cp.stdout or "")  # type: ignore[arg-type]
        if cp.stderr:
            buf.append("\n[stderr]\n")
            buf.append(cp.stderr)
        buf.append("\n```\n")
    return "".join(buf)


def pack_files(ux: UX, repo: Path, files: List[Path], *, max_kb_each: int, title: str) -> str:
    buf: List[str] = []
    total = len(files)

    buf.append(f"## {title}\n")
    buf.append("\n### File list\n```text\n")
    for p in files:
        buf.append(p.relative_to(repo).as_posix() + "\n")
    buf.append("```\n\n")

    for i, p in enumerate(files, start=1):
        if i == 1 or i % 10 == 0 or i == total:
            ux.info(f"pack: [{i}/{total}] {p.relative_to(repo).as_posix()}")

        rel = p.relative_to(repo).as_posix()
        buf.append(f"### {rel}\n")
        buf.append("```text\n")
        buf.append(safe_read_text(p, max_kb=max_kb_each))
        buf.append("\n```\n\n")

    return "".join(buf)


def cmd_pack(ux: UX, repo: Path, strict: bool) -> None:
    t0 = time.time()
    ux.info("pack ...")

    cfg = load_config(ux, repo, strict=strict)
    out_path = repo / cfg.output.pack

    ux.info("pack: collect files ...")
    t1 = time.time()
    try:
        changed = [
            p for p in collect_changed_files(repo)
            if not should_exclude(p, repo, cfg.context_large.exclude_dirs, cfg.context_large.exclude_globs)
        ]
        small_files = collect_small(repo, cfg)
        large_files = collect_large(ux, repo, cfg)
        ux.info(f"pack: collect files done ({time.time() - t1:.2f}s)")
    except Exception:
        ux.warn(f"pack: collect files failed ({time.time() - t1:.2f}s)")
        raise

    ux.info("pack: render markdown ...")
    t2 = time.time()

    buf: List[str] = []
    buf.append("# OceansGuard Context Pack\n\n")
    buf.append(f"- generated_at: {now_iso()}\n")
    buf.append(f"- repo: {repo}\n")
    buf.append(f"- config_version: {cfg.version}\n")
    buf.append(f"- mode: {'strict' if strict else 'normal'}\n\n")

    buf.append(pack_git_section(repo))
    buf.append(evidence_section(repo, cfg))

    if changed:
        buf.append(pack_files(ux, repo, changed, max_kb_each=cfg.context_large.max_kb_each, title="Changed files (diff-first)"))
    if small_files:
        buf.append(pack_files(ux, repo, small_files, max_kb_each=cfg.context_large.max_kb_each, title="Context (small)"))
    if large_files:
        buf.append(pack_files(ux, repo, large_files, max_kb_each=cfg.context_large.max_kb_each, title="Context (large, capped)"))

    out_path.write_text("".join(buf), encoding="utf-8")

    ux.info(f"pack: render markdown done ({time.time() - t2:.2f}s)")
    ux.info(f"pack written: {out_path}")
    ux.info(f"pack done ({time.time() - t0:.2f}s)")


# ============================================================
# Guard (full rewrite)
# ============================================================

def detect_full_rewrite(repo: Path, forbid: bool, allow_globs: List[str]) -> Optional[str]:
    if not forbid:
        return None

    cp = run_argv(["git", "diff", "--numstat"], cwd=repo)
    suspicious: List[str] = []

    def _allowed(path_str: str) -> bool:
        s = path_str.replace("\\", "/")
        for g in allow_globs or []:
            if fnmatch.fnmatch(s, g):
                return True
        return False

    for ln in (cp.stdout or "").splitlines():  # type: ignore[union-attr]
        parts = ln.split("\t")
        if len(parts) != 3:
            continue
        add_s, del_s, file_s = parts
        if add_s == "-" or del_s == "-":
            continue
        if _allowed(file_s):
            continue

        try:
            add_n = int(add_s)
            del_n = int(del_s)
        except ValueError:
            continue

        if (add_n + del_n) >= 800 and add_n >= 300 and del_n >= 300:
            suspicious.append(f"{file_s} (add={add_n}, del={del_n})")

    if suspicious:
        return "Possible full rewrite detected:\n" + "\n".join(f"- {s}" for s in suspicious)
    return None


# ============================================================
# DLP
# ============================================================

SECRET_PATTERNS: List[Tuple[str, re.Pattern[str]]] = [
    ("PRIVATE_KEY", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")),
    ("AWS_ACCESS_KEY", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
    ("GITHUB_TOKEN", re.compile(r"\bghp_[A-Za-z0-9]{36}\b")),
    ("SLACK_TOKEN", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")),
    ("JWT_LIKE", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")),
    ("PASSWORD_ASSIGN", re.compile(r"(?i)\b(password|passwd|pwd|secret|api[_-]?key|token)\b\s*[:=]\s*['\"][^'\"]{6,}['\"]")),
]


def is_allowlisted(rel_posix: str, allowlist: List[str]) -> bool:
    for pat in allowlist:
        if fnmatch.fnmatch(rel_posix, pat):
            return True
    return False


def mask_hit(text: str) -> str:
    return f"<masked:{sha256_text(text)[:12]}>"


def scan_dlp(repo: Path, enable: bool, allowlist: List[str], mask: bool) -> List[str]:
    if not enable:
        return []

    max_bytes = 256 * 1024
    hits: List[str] = []

    cp = run_argv(["git", "ls-files"], cwd=repo)
    for rel in (cp.stdout or "").splitlines():  # type: ignore[union-attr]
        rel = rel.strip()
        if not rel:
            continue
        if is_allowlisted(rel, allowlist):
            continue

        p = repo / rel
        if not p.exists() or not p.is_file():
            continue

        try:
            b = p.read_bytes()
        except Exception:
            continue
        if len(b) > max_bytes:
            continue

        s = b.decode("utf-8", errors="ignore")
        for name, pat in SECRET_PATTERNS:
            m = pat.search(s)
            if not m:
                continue
            sample = m.group(0)
            if mask:
                sample = mask_hit(sample)
            hits.append(f"{name}: {rel}: {sample}")

    return hits

# ============================================================
# Frontend (Node/TS) policy
# ============================================================

def _read_json(path: Path) -> Dict[str, Any]:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return {}

def _has_frontend(repo: Path) -> bool:
    return (repo / "frontend" / "package.json").exists()

def _frontend_scripts(repo: Path) -> Dict[str, str]:
    pj = repo / "frontend" / "package.json"
    data = _read_json(pj) if pj.exists() else {}
    scripts = data.get("scripts", {}) if isinstance(data.get("scripts", {}), dict) else {}
    # str 以外が混ざっても落ちないように
    return {k: str(v) for k, v in scripts.items()}

def _node_version(repo: Path) -> Optional[str]:
    try:
        cp = run_shell("node -v", cwd=repo)
        v = (cp.stdout or "").strip()
        return v if v else None
    except Exception:
        return None

def _parse_semver(v: str) -> Tuple[int, int, int]:
    # "v22.11.0" / "22.11.0" を想定
    s = v.strip()
    if s.startswith("v"):
        s = s[1:]
    parts = s.split(".")
    try:
        major = int(parts[0]) if len(parts) > 0 else 0
        minor = int(parts[1]) if len(parts) > 1 else 0
        patch = int(parts[2]) if len(parts) > 2 else 0
        return major, minor, patch
    except Exception:
        return (0, 0, 0)

def _node_satisfies_for_vite(v: str) -> bool:
    # Vite の一般的要件（例：20.19+ or 22.12+）に合わせる
    major, minor, patch = _parse_semver(v)
    if major == 20:
        return (minor > 19) or (minor == 19 and patch >= 0)
    if major == 22:
        return (minor > 12) or (minor == 12 and patch >= 0)
    return major > 22  # 将来版は許容

def _npm_run(repo: Path, script: str, *, strict: bool, ux: UX) -> int:
    d = repo / "frontend"
    if not d.exists():
        return 0
    cmd = f"npm run {script} --silent"
    ux.info(f"frontend: {cmd}")
    cp = run_shell_stream(cmd, cwd=d, timeout=_timeout_for_check_command(cmd), print_prefix="[OceansGuard][frontend] ")
    if cp.returncode != 0:
        # 失敗を “解釈” してメッセージを強化
        stderr = cp.stderr or ""
        if "TS5072" in stderr:
            ux.warn("frontend: TypeScript config/options mismatch (TS5072). Check tsconfig references/build settings.")
        if "Vite requires Node.js version" in stderr:
            ux.warn("frontend: Node version does not satisfy Vite requirements.")
    return cp.returncode

def _frontend_strict_checks(repo: Path, ux: UX) -> List[Tuple[str, int]]:
    """
    strict 時に Guard が自動的に追加する「frontend 検問」。
    戻り値: [(label, exit_code), ...]
    """
    failed: List[Tuple[str, int]] = []

    if not _has_frontend(repo):
        return failed

    # 1) node version check
    v = _node_version(repo)
    if not v:
        failed.append(("node -v (node not found)", 1))
        return failed

    if not _node_satisfies_for_vite(v):
        # strict は fail（normal は warn にしたいなら cmd_check 側で分岐）
        failed.append((f"node version not supported for vite: {v}", 1))
        return failed

    # 2) install deps (ci preferred if lock exists)
    fe = repo / "frontend"
    has_lock = (fe / "package-lock.json").exists()
    cmd = "npm ci --silent" if has_lock else "npm install --silent"
    ux.info(f"frontend: {cmd}")
    cp_i = run_shell_stream(cmd, cwd=fe, timeout=_timeout_for_check_command(cmd), print_prefix="[OceansGuard][frontend] ")
    if cp_i.returncode != 0:
        failed.append((cmd, cp_i.returncode))
        return failed

    # 3) run scripts (if exist)
    scripts = _frontend_scripts(repo)
    for s in ("lint", "typecheck", "build"):
        if s in scripts:
            rc = _npm_run(repo, s, strict=True, ux=ux)
            if rc != 0:
                failed.append((f"npm run {s}", rc))

    return failed

# ============================================================
# OpenAPI contract
# ============================================================

def openapi_contract_check(repo: Path, strict: bool) -> Optional[str]:
    p = repo / "contracts" / "openapi.json"
    if not p.exists():
        if strict:
            return "contracts/openapi.json is required in strict mode (can be empty only in normal mode)"
        return None

    txt = p.read_text(encoding="utf-8", errors="ignore").strip()
    if not txt:
        if strict:
            return "contracts/openapi.json is empty in strict mode"
        return None

    try:
        obj = json.loads(txt)
    except Exception as e:
        return f"contracts/openapi.json is not valid JSON: {e}"

    if not isinstance(obj, dict):
        return "contracts/openapi.json must be a JSON object"
    if "openapi" not in obj and "swagger" not in obj:
        return "contracts/openapi.json missing 'openapi' (or 'swagger') field"
    return None


# ============================================================
# check
# ============================================================

def _timeout_for_check_command(cmd: str) -> int:
    """
    コマンド別のタイムアウト（秒）。
    - npm install/ci は長め（ネットワーク次第）
    - npm build も長め
    """
    c = (cmd or "").strip()

    if re.search(r"\bnpm\s+ci\b", c):
        return 900
    if re.search(r"\bnpm\s+install\b", c):
        return 900
    if re.search(r"\bnpm\s+run\s+build\b", c):
        return 900

    if "compileall" in c:
        return 120
    if re.search(r"\bruff\b", c):
        return 180
    if re.search(r"\bmypy\b", c):
        return 300
    if re.search(r"\bpytest\b", c):
        return 600

    return 300


def _should_stream_check_command(cmd: str) -> bool:
    """
    「止まって見える」系はストリーミング実行にする。
    """
    c = (cmd or "").strip()
    if re.search(r"\bnpm\s+ci\b", c):
        return True
    if re.search(r"\bnpm\s+install\b", c):
        return True
    if re.search(r"\bnpm\s+run\s+build\b", c):
        return True
    return False


def cmd_check(ux: UX, repo: Path, strict: bool) -> None:
    t0 = time.time()
    ux.info("check ...")

    cfg = load_config(ux, repo, strict=strict)

    report: Dict[str, Any] = {
        "tool": "OceansGuard",
        "generated_at": now_iso(),
        "repo": str(repo),
        "mode": "strict" if strict else "normal",
        "checks": [],
        "dlp_hits": [],
        "guard": {"full_rewrite": None},
        "openapi_contract": None,
        "status": "pass",
    }

    # 1) full rewrite guard
    fr = detect_full_rewrite(repo, forbid=cfg.guard.forbid_full_rewrite, allow_globs=cfg.guard.allow_full_rewrite_globs)
    if fr:
        report["guard"]["full_rewrite"] = fr
        report["status"] = "fail"
        (repo / cfg.output.report_json).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
        die(fr)

    # 2) DLP
    dlp_hits = scan_dlp(repo, enable=cfg.dlp.enable, allowlist=cfg.dlp.allowlist_files, mask=cfg.dlp.mask)
    report["dlp_hits"] = dlp_hits
    if dlp_hits and cfg.dlp.block_on_detect:
        report["status"] = "fail"
        (repo / cfg.output.report_json).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
        die("DLP detected potential secrets:\n" + "\n".join(f"- {h}" for h in dlp_hits))

    # 3) OpenAPI contract
    oc = openapi_contract_check(repo, strict=strict)
    report["openapi_contract"] = oc
    if oc:
        report["status"] = "fail"
        (repo / cfg.output.report_json).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
        die(oc)

    # 4) checks.commands (+ strict_commands in strict mode)
    base_cmds = [c.strip() for c in cfg.checks.commands if (c or "").strip()]
    strict_cmds = [c.strip() for c in cfg.checks.strict_commands if (c or "").strip()] if strict else []

    if strict and not (base_cmds or strict_cmds):
        report["status"] = "fail"
        (repo / cfg.output.report_json).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
        die("checks.commands / checks.strict_commands are empty in strict mode")

    # ---- strict frontend policy (Guard-managed) ----
    if strict:
        fe = run_frontend_strict_policy(repo, ux)
        for w in fe.warnings:
            ux.warn(w)

        if fe.failed:
            for label, rc in fe.failed:
                report["checks"].append(
                    {
                        "index": len(report["checks"]) + 1,
                        "command": f"[frontend-policy] {label}",
                        "exit_code": rc,
                        "timeout": False,
                        "timeout_sec": None,
                        "stream": True,
                        "elapsed_sec": None,
                    }
                )
            report["status"] = "fail"
            (repo / cfg.output.report_json).write_text(
                json.dumps(report, ensure_ascii=False, indent=2),
                encoding="utf-8",
            )
            summary = "\n".join([f"- ({rc}) {label}" for label, rc in fe.failed])
            die("check failed (frontend strict policy):\n" + summary)



    all_cmds = base_cmds + strict_cmds
    total = len(all_cmds)

    log_path = repo / cfg.output.testlog
    logs: List[str] = []
    logs.append(f"[OceansGuard] check started @ {now_iso()}\n")
    logs.append(f"[mode] {'strict' if strict else 'normal'}\n")

    failed: List[Tuple[str, int]] = []

    for i, cmd in enumerate(all_cmds, start=1):
        ux.info(f"check[{i}/{total}] {cmd}")
        timeout_sec = _timeout_for_check_command(cmd)
        stream = _should_stream_check_command(cmd)

        logs.append(f"\n=== check[{i}] {cmd} ===\n")
        logs.append(f"[timeout] {timeout_sec}s\n")
        logs.append(f"[stream] {stream}\n")

        t_cmd = time.time()
        try:
            if stream:
                # リアルタイム出力しつつ回収（prefix付）
                prefix = f"[OceansGuard][check {i}/{total}] "
                cp = run_shell_stream(cmd, cwd=repo, timeout=timeout_sec, print_prefix=prefix)
            else:
                cp = run_shell(cmd, cwd=repo, timeout=timeout_sec)

            logs.append(cp.stdout or "")
            if cp.stderr:
                logs.append("\n[stderr]\n")
                logs.append(cp.stderr)
            logs.append(f"\n[exit_code] {cp.returncode}\n")
            logs.append(f"[elapsed] {time.time() - t_cmd:.2f}s\n")

            report["checks"].append({
                "index": i,
                "command": cmd,
                "exit_code": cp.returncode,
                "timeout": (cp.returncode == 124),
                "timeout_sec": timeout_sec,
                "stream": stream,
                "elapsed_sec": round(time.time() - t_cmd, 2),
            })

            if cp.returncode != 0:
                failed.append((cmd, cp.returncode))

        except subprocess.TimeoutExpired:
            # run_shell() 側の TimeoutExpired（stream 側は 124 に寄せる設計）
            logs.append("\n[TIMEOUT]\n")
            logs.append("[exit_code] 124\n")
            logs.append(f"[elapsed] {time.time() - t_cmd:.2f}s\n")
            report["checks"].append({
                "index": i,
                "command": cmd,
                "exit_code": 124,
                "timeout": True,
                "timeout_sec": timeout_sec,
                "stream": stream,
                "elapsed_sec": round(time.time() - t_cmd, 2),
            })
            failed.append((cmd, 124))
            ux.warn(f"check[{i}/{total}] timeout after {timeout_sec}s")

    log_path.write_text("".join(logs), encoding="utf-8")
    ux.info(f"testlog written: {log_path}")

    if failed:
        report["status"] = "fail"
        (repo / cfg.output.report_json).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
        summary = "\n".join([f"- ({rc}) {cmd}" for cmd, rc in failed])
        die("check failed:\n" + summary)

    (repo / cfg.output.report_json).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
    ux.info(f"report written: {repo / cfg.output.report_json}")
    ux.info(f"check passed ({time.time() - t0:.2f}s)")


# ============================================================
# run
# ============================================================

def cmd_run(ux: UX, repo: Path, task: str, strict: bool) -> None:
    if task.strip():
        ux.info(f"run task: {task}")
    else:
        ux.warn("run: --task is empty (still runs pack/check)")
    cmd_pack(ux, repo, strict=strict)
    cmd_check(ux, repo, strict=strict)


# ============================================================
# CLI
# ============================================================

def main() -> None:
    ap = argparse.ArgumentParser(prog="aiguard")
    ap.add_argument("command", choices=["init", "pack", "check", "run"])
    ap.add_argument("--repo", default=".")
    ap.add_argument("--task", default="")
    ap.add_argument("--strict", action="store_true", help="Do not allow skipping; fail if prerequisites missing.")
    ap.add_argument("--verbose", action="store_true", help="Verbose logs (debug).")
    ap.add_argument("--force", action="store_true", help="Overwrite files on init (e.g., .aiguard.yml).")

    args = ap.parse_args()

    ux = UX(verbose=bool(args.verbose))
    repo = Path(args.repo).resolve()

    if args.command == "init":
        cmd_init(ux, repo, force=bool(args.force))
        return
    if args.command == "pack":
        cmd_pack(ux, repo, strict=args.strict)
        return
    if args.command == "check":
        cmd_check(ux, repo, strict=args.strict)
        return
    if args.command == "run":
        cmd_run(ux, repo, task=args.task, strict=args.strict)
        return

    die("unknown command")


if __name__ == "__main__":
    main()
