#!/usr/bin/env python3
# WL-Pre-Push-Hook-Version: config-driven-git-flow-protection-v3
# Bump the marker VALUE on any body change: the cheap per-command gate
# (hooks_manager.hooks_need_sync) compares this value against the deployed
# copy to decide whether to re-sync (#158/B3).
"""
Pre-push hook: enforces Git Flow branch protection and push policy.

Replaces the previous hardcoded {main, develop} protection with a
config-driven approach:

  - Protected branches come from GitFlowConfig (.wl/git-flow.yaml overrides
    plus the built-in defaults where main/develop have protected: True).
  - If the config cannot be read, protection falls back to the safe defaults
    {main, develop} (protection is never opened up because the config was
    unavailable).
  - Git Flow policy is hard-enforced at push: release/hotfix branches must
    bump VERSION and CHANGELOG.md, branch naming is validated, and direct
    pushes to protected main/develop are blocked (require_pr semantics).

Backend resolution (hybrid):
  1. In-process import of wl_cli.commands.git.push_update for the stdin parse
     when available; otherwise a minimal local fallback parser (marked below),
     which still feeds branch protection and the live-head gate.
  2. In-process import of wl_cli.commands.git.* when available.
  3. Otherwise fall back to the `wl` CLI via subprocess.
  4. Otherwise fall back to default {main, develop} branch protection only
     (the git-flow policy check is skipped when wl is unavailable).

Behavior:
  - Parses stdin for git pre-push input (one line per ref being pushed) ONCE
    into PushUpdate records; every later check reads fields from them (#163).
  - Blocks pushes where the remote ref is a protected branch.
  - Blocks pushes that violate Git Flow policy.
  - Blocks --no-verify / -n flags (prevents bypass attempts).
  - All other refs (feature branches, tags, deletions) are allowed.
  - On stdin read/parse failure, prints a warning and exits 0 (fail-open).
"""

# PEP 604 unions in the annotations below (``int | None``, ``dict | str``,
# ``frozenset[str] | None``) would otherwise be evaluated at function-definition
# time and raise ``TypeError`` on Python < 3.10 -- i.e. on the system ``python3``
# (3.9) that ``#!/usr/bin/env python3`` resolves to on macOS, breaking every
# push. ``from __future__ import annotations`` makes just those *annotations*
# lazy, so the ``from __future__`` line itself is 3.7+-compatible; the hook as a
# whole still requires Python 3.9+, because it calls ``str.removeprefix()``
# below (3.9+). (#156)
from __future__ import annotations

import os
import shutil
import subprocess
import sys

# Set stdout/stderr encoding to UTF-8 for cross-platform emoji support.
# On Windows with Chinese locale (GBK), emoji characters crash print().
if sys.stdout.encoding and sys.stdout.encoding.upper() not in ("UTF-8", "UTF8"):
    _stdout = sys.stdout.buffer if hasattr(sys.stdout, "buffer") else None
    if _stdout:
        sys.stdout = type(sys.stdout)(
            _stdout,
            encoding="utf-8",
            errors="replace",
        )
    _stderr = sys.stderr.buffer if hasattr(sys.stderr, "buffer") else None
    if _stderr:
        sys.stderr = type(sys.stderr)(
            _stderr,
            encoding="utf-8",
            errors="replace",
        )


def check_no_verify(argv: list[str]) -> int | None:
    """Return exit code 1 if --no-verify or -n is present, None otherwise."""
    if "--no-verify" in argv or "-n" in argv:
        print(
            "错误: 不允许使用 --no-verify 或 -n 选项绕过 pre-push 检查。",
            file=sys.stderr,
        )
        print(
            "请使用 feature 分支推送代码，而不是绕过分支保护。",
            file=sys.stderr,
        )
        print(file=sys.stderr)
        print(
            "ERROR: --no-verify/-n is not allowed for protected branches.",
            file=sys.stderr,
        )
        print(
            "Use a feature branch instead of bypassing branch protection.",
            file=sys.stderr,
        )
        return 1
    return None


def read_stdin() -> str:
    """Read all stdin input, handling bytes decoding."""
    try:
        raw = sys.stdin.buffer.read()
        return raw.decode("utf-8", errors="replace")
    except AttributeError:
        # Fallback when sys.stdin.buffer is unavailable
        return sys.stdin.read()


class _FallbackPushUpdate:
    """Minimal PushUpdate stand-in for the degraded (no wl_cli) path.

    Mirrors ``wl_cli.commands.git.push_update.PushUpdate``'s four fields and
    branch predicates, so the checks below read one shape whether or not the
    package imported.
    """

    __slots__ = ("local_ref", "local_sha", "remote_ref", "remote_sha")

    def __init__(
        self,
        local_ref: str,
        local_sha: str,
        remote_ref: str,
        remote_sha: str,
    ) -> None:
        self.local_ref = local_ref
        self.local_sha = local_sha
        self.remote_ref = remote_ref
        self.remote_sha = remote_sha

    @property
    def is_branch(self) -> bool:
        return self.remote_ref.startswith("refs/heads/")

    @property
    def is_delete(self) -> bool:
        return self.local_sha == "(delete)" or (
            self.local_sha != "" and self.local_sha.strip("0") == ""
        )

    @property
    def branch_name(self) -> str | None:
        if not self.is_branch:
            return None
        return self.remote_ref.removeprefix("refs/heads/")


def _parse_push_updates_fallback(data: str) -> list[_FallbackPushUpdate]:
    """Degraded path: parse stdin here only because wl_cli is not importable.

    This is the *only* place besides ``wl_cli.commands.git.push_update`` that
    splits pre-push stdin; when the package imports, it is never called.
    """
    updates: list[_FallbackPushUpdate] = []
    for line in data.splitlines():
        line = line.strip()
        if not line:
            continue
        parts = line.split()
        # Format: local-ref local-sha remote-ref remote-sha
        if len(parts) < 4:
            continue
        updates.append(
            _FallbackPushUpdate(parts[0], parts[1], parts[2], parts[3])
        )
    return updates


def _load_push_update_parser():
    """Resolve the single stdin parser for this run (hybrid backend).

    Prefers the in-process ``wl_cli.commands.git.push_update`` module so the
    hook and the Python helpers share one definition of a push. When wl_cli
    cannot be imported -- e.g. the hook runs under a bare system ``python3`` --
    this degrades to :func:`_parse_push_updates_fallback`; the Git Flow policy
    already has its own degraded paths (``wl`` subprocess / defaults).
    """
    try:
        from wl_cli.commands.git.push_update import parse_push_updates

        return parse_push_updates
    except Exception:  # noqa: BLE001 - the hook must stay runnable either way
        return _parse_push_updates_fallback


def parse_push_updates(data: str):
    """Parse pre-push stdin into ``PushUpdate`` records, exactly once per run."""
    return _load_push_update_parser()(data)


def parse_remote_refs(data: str) -> list[str]:
    """
    Parse git pre-push stdin and extract remote branch ref names.

    Thin wrapper over :func:`parse_push_updates` kept for compatibility: the
    hook itself filters ``update.remote_ref`` from its single parse instead of
    calling this again (#163).

    Git pre-push format (one line per ref being pushed):
        <local-ref> <local-sha1> <remote-ref> <remote-sha1>

    Returns list of remote refs like 'refs/heads/main'.
    """
    return [update.remote_ref for update in parse_push_updates(data) if update.is_branch]


def _has_live_head_update(updates) -> bool:
    """Whether the parsed updates carry a real (non-deletion) refs/heads update.

    Deletion lines use ``(delete)`` / an all-zero local sha and only remove a
    branch, so they carry no content for git-flow evidence to judge.
    """
    for update in updates:
        if update.is_branch and not update.is_delete:
            return True
    return False


def _resolve_protected_branches() -> frozenset[str]:
    """Resolve the protected branch ref set from GitFlowConfig.

    Protected branches come from the configured main/develop branch names.
    Falls back to the safe default {main, develop} whenever the config cannot
    be read — protection is never opened up because the config was missing,
    malformed, or wl_cli could not be imported.
    """
    try:
        from wl_cli.commands.git.config import GitFlowConfig

        config = GitFlowConfig()
        return frozenset(
            {
                f"refs/heads/{config.main_branch}",
                f"refs/heads/{config.develop_branch}",
            }
        )
    except Exception:
        return frozenset({"refs/heads/main", "refs/heads/develop"})


def check_branch_protection(
    remote_refs: list[str],
    protected_branches: frozenset[str] | None = None,
) -> int:
    """Check if any remote ref is a protected branch. Returns 1 if blocked."""
    if protected_branches is None:
        protected_branches = _resolve_protected_branches()

    for ref in remote_refs:
        if ref in protected_branches:
            branch = ref.removeprefix("refs/heads/")
            print(f'🚫 PUSH BLOCKED: Direct push to "{branch}" branch is not allowed.')
            print()
            print("📋 Git Flow Policy:")
            print("   - Never push directly to main or develop")
            print("   - Create a feature/fix branch for your changes")
            print("   - Submit changes via Pull Request only")
            print()
            print("🤖 For AI Agents:")
            print("   - Use `git checkout -b feature/<description>` to create a branch")
            print(
                "   - Push to your feature branch instead:"
                " `git push origin feature/<description>`"
            )
            print("   - Then create a Pull Request for review")
            return 1

    return 0


def _load_git_flow_backend() -> dict | str:
    """Load the in-process Git Flow backend.

    Returns:
        dict: backend with config/validator/hook/git_facts when available.
        "subprocess": wl_cli is not importable; use the wl CLI.
        "defaults": the config could not be read; skip policy validation.
    """
    try:
        from wl_cli.commands.git import git_facts
        from wl_cli.commands.git import hook as git_hook
        from wl_cli.commands.git.config import GitFlowConfig
        from wl_cli.commands.git.policy import GitFlowPolicyValidator

        config = GitFlowConfig()
        return {
            "config": config,
            "validator": GitFlowPolicyValidator(config),
            "hook": git_hook,
            "git_facts": git_facts,
        }
    except ImportError:
        return "subprocess"
    except Exception:
        return "defaults"


def _run_wl_subprocess_check() -> int:
    """Validate the push via the `wl` CLI subprocess.

    Returns 1 when the CLI blocks the push, 0 when allowed or wl is missing.
    """
    wl_exe = shutil.which("wl")
    if not wl_exe:
        return 0
    try:
        result = subprocess.run(
            [wl_exe, "git-flow", "check"],
            capture_output=True,
            text=True,
            check=False,
        )
        if result.stdout:
            print(result.stdout.strip())
        if result.stderr:
            print(result.stderr.strip(), file=sys.stderr)
        return 1 if result.returncode != 0 else 0
    except (OSError, subprocess.SubprocessError):
        return 0


def _run_git_flow_policy(updates) -> int:
    """Enforce Git Flow policy for the push. Returns 1 if blocked, else 0."""
    backend = _load_git_flow_backend()
    if backend == "defaults":
        # Policy skipped; branch protection already ran with default {main, develop}.
        return 0
    if backend == "subprocess":
        return _run_wl_subprocess_check()

    config = backend["config"]
    validator = backend["validator"]
    git_hook = backend["hook"]
    git_facts = backend["git_facts"]

    local_branch = git_facts.get_current_branch()
    local_type = validator.classify_branch(local_branch)
    source = (
        config.source_branch(local_type)
        if local_type in {"feature", "release", "hotfix"}
        else None
    )
    changed_files = (
        git_facts.get_changed_files(source, file_filters=["VERSION", "CHANGELOG.md"])
        if source
        else frozenset()
    )
    context = git_hook.build_push_context(
        local_branch=local_branch,
        updates=tuple(updates),
        changed_files=changed_files,
    )
    result = validator.validate_push(context)
    if not result.ok:
        print(git_hook.format_hook_failure(result))
        return 1
    return 0


def main() -> int:
    """Main entry point for the pre-push hook."""

    # Step 1: Block --no-verify bypass attempts
    result = check_no_verify(sys.argv)
    if result is not None:
        return result

    # Step 2: Read stdin (fail-open on read errors)
    try:
        data = read_stdin()
    except Exception as exc:
        print(
            f"Warning: Could not read pre-push input: {exc}",
            file=sys.stderr,
        )
        print(
            "Warning: Branch protection check skipped.",
            file=sys.stderr,
        )
        return 0  # fail-open

    # Step 3: Parse stdin ONCE into PushUpdate records (fail-open on errors).
    # Branch protection, the live-head gate and the policy context all read
    # these records; nothing below splits stdin a second time (#163).
    try:
        updates = tuple(parse_push_updates(data))
    except Exception as exc:
        print(
            f"Warning: Could not parse pre-push input: {exc}",
            file=sys.stderr,
        )
        print(
            "Warning: Branch protection check skipped.",
            file=sys.stderr,
        )
        return 0  # fail-open

    # Step 4: Branch protection (config-driven with safe default fallback)
    protected = _resolve_protected_branches()
    remote_refs = [update.remote_ref for update in updates if update.is_branch]
    if check_branch_protection(remote_refs, protected) != 0:
        return 1

    # Step 4.5: Pushes with no live head update -- only tags, or branch
    # deletions (e.g. `git push origin v1.2.3` / `--tags`, or
    # `git push origin --delete release/1.0`) -- carry no content for the
    # policy's evidence check to judge, so the local-branch fallback and
    # release/hotfix evidence rules must not block them (#43 / review D2).
    # Branch protection above still blocks deletions/updates of main/develop.
    if not _has_live_head_update(updates):
        return 0

    # Step 5: Git Flow policy validation (hybrid backend)
    return _run_git_flow_policy(updates)


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