"""Repo operations for workers: clone, push, local workspace materialization."""
from __future__ import annotations
import asyncio
import os
import shutil
from typing import Optional
from uuid import UUID

import structlog

from src.domain.projects.paths import get_project_meta_path, get_project_repo_path

logger = structlog.get_logger()


def _ignore_boilerplate_artifacts(dirpath: str, names: list[str]) -> set[str]:
    """Skip VCS and heavy/ephemeral dirs when copying boilerplate onto the server."""
    skip_names = {
        ".git",
        "__pycache__",
        ".venv",
        "venv",
        "node_modules",
        "dist",
        "build",
        ".turbo",
        ".next",
        ".nuxt",
    }
    ignored: set[str] = set()
    for n in names:
        if n in skip_names or n.endswith(".pyc"):
            ignored.add(n)
    return ignored


async def run_git(repo_path: str, *args: str) -> tuple[int, str, str]:
    """Public wrapper around the internal git runner. Returns (returncode, stdout, stderr)."""
    return await _git(repo_path, *args)


_GIT_TIMEOUT = 120  # seconds — applies to fetch, checkout, push, etc.
_GIT_CLONE_TIMEOUT = 180  # seconds — clone may transfer more data


async def _git(repo_path: str, *args: str, timeout: int = _GIT_TIMEOUT) -> tuple[int, str, str]:
    proc = await asyncio.create_subprocess_exec(
        "git",
        *args,
        cwd=repo_path,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    try:
        stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
    except asyncio.TimeoutError:
        proc.kill()
        await proc.wait()
        raise RuntimeError(f"git {' '.join(args)} timed out after {timeout}s")
    out = (stdout or b"").decode(errors="replace")
    err = (stderr or b"").decode(errors="replace")
    return proc.returncode, out, err


async def git_rev_parse_head(repo_path: str) -> Optional[str]:
    # Subprocess requires ``cwd`` to exist; first-time setup has no folder until materialize runs.
    if not os.path.isdir(repo_path):
        return None
    rc, out, _ = await _git(repo_path, "rev-parse", "HEAD")
    if rc != 0:
        return None
    return out.strip() or None


async def git_branch_move_to(repo_path: str, new_name: str) -> None:
    """Rename the current branch to ``new_name`` (align local with GitHub default branch)."""
    rc, _, err = await _git(repo_path, "branch", "-M", new_name)
    if rc != 0:
        raise RuntimeError(f"git branch -M failed: {err}")


async def materialize_local_project_workspace(
    repo_path: str,
    boilerplate_src: Optional[str],
    *,
    fallback_readme_title: str,
    git_name: str,
    git_email: str,
    initial_branch: str = "main",
) -> str:
    """Copy boilerplate to ``repo_path``, ``git init``, initial commit. Idempotent if valid repo exists.

    This runs **before** GitHub repo creation so Cursor and later pushes use the checkout directory:
    ``<repo_workspace>/<workspace_id>/<project_id>/`` (P2HS meta lives alongside under ``project_meta/``).
    """
    parent = os.path.dirname(repo_path)
    if parent:
        os.makedirs(parent, exist_ok=True)

    git_dir = os.path.join(repo_path, ".git")
    if os.path.isdir(git_dir):
        sha = await git_rev_parse_head(repo_path)
        if sha:
            logger.info("local_workspace_reuse", path=repo_path, sha=sha[:8])
            return sha
        logger.warning("local_workspace_repair", path=repo_path, reason="invalid git state, recreating tree")
        shutil.rmtree(repo_path, ignore_errors=True)

    if os.path.isdir(repo_path):
        shutil.rmtree(repo_path, ignore_errors=True)

    if boilerplate_src and os.path.isdir(boilerplate_src):
        shutil.copytree(boilerplate_src, repo_path, ignore=_ignore_boilerplate_artifacts)
    else:
        os.makedirs(repo_path, exist_ok=True)
        readme = os.path.join(repo_path, "README.md")
        with open(readme, "w", encoding="utf-8") as f:
            f.write(f"# {fallback_readme_title}\n\nGenerated by P2HS.\n")

    rc, _, err = await _git(repo_path, "init")
    if rc != 0:
        raise RuntimeError(f"git init failed: {err}")

    await _git(repo_path, "config", "user.email", git_email)
    await _git(repo_path, "config", "user.name", git_name)

    await stage_publishable_and_assert(repo_path)

    rc, _, err = await _git(repo_path, "commit", "-m", "Initial scaffold from P2HS")
    if rc != 0:
        if "nothing to commit" in err.lower() or "nothing added to commit" in err.lower():
            raise RuntimeError("git commit found nothing to commit after materializing workspace")
        raise RuntimeError(f"git commit failed: {err}")

    rc, _, err = await _git(repo_path, "branch", "-M", initial_branch)
    if rc != 0:
        raise RuntimeError(f"git branch rename failed: {err}")

    sha = await git_rev_parse_head(repo_path)
    if not sha:
        raise RuntimeError("git rev-parse HEAD failed after initial commit")
    logger.info("local_workspace_materialized", path=repo_path, sha=sha[:8], branch=initial_branch)
    return sha


def _remote_is_ahead(push_output: str) -> bool:
    text = push_output.lower()
    return "fetch first" in text or "non-fast-forward" in text


async def ensure_git_remote_push(
    repo_path: str,
    origin_url_with_credentials: str,
    branch: str,
) -> None:
    """Point ``origin`` at the authenticated URL and push ``branch`` (idempotent if already up to date)."""
    rc, _, _ = await _git(repo_path, "remote", "get-url", "origin")
    if rc == 0:
        rc, _, err = await _git(repo_path, "remote", "set-url", "origin", origin_url_with_credentials)
        if rc != 0:
            raise RuntimeError(f"git remote set-url failed: {err}")
    else:
        rc, _, err = await _git(repo_path, "remote", "add", "origin", origin_url_with_credentials)
        if rc != 0:
            raise RuntimeError(f"git remote add failed: {err}")

    # GitHub can take a few seconds to fully provision a freshly-created repo.
    # Retry on "Repository not found" with exponential backoff before giving up.
    _PUSH_RETRY_DELAYS = [2, 5, 10]
    rebased = False
    for attempt, delay in enumerate([0] + _PUSH_RETRY_DELAYS, start=1):
        if delay:
            logger.info("git_push_retry", path=repo_path, branch=branch, attempt=attempt, delay=delay)
            await asyncio.sleep(delay)
        rc, out, err = await _git(repo_path, "push", "-u", "origin", branch)
        combined = (out or "") + (err or "")
        if rc == 0:
            logger.info("local_workspace_pushed", path=repo_path, branch=branch)
            return
        if "Everything up-to-date" in combined or "already up to date" in combined.lower():
            logger.info("local_workspace_push_noop", path=repo_path, branch=branch)
            return
        if "repository not found" in combined.lower() and attempt <= len(_PUSH_RETRY_DELAYS):
            logger.warning("git_push_repo_not_found_retrying", path=repo_path, branch=branch, attempt=attempt)
            continue
        if _remote_is_ahead(combined) and not rebased:
            # Something else pushed to this branch after our first commit (for example a codegen run that
            # commits the workflow to the default branch). Replay our commits on top of it and push again.
            # A conflict aborts and fails the push; nothing on the remote is overwritten.
            logger.warning("git_push_remote_ahead_rebasing", path=repo_path, branch=branch)
            from src.config import get_settings

            s = get_settings()
            rc, out, err = await _git(
                repo_path,
                "-c", f"user.name={s.git_committer_name}",
                "-c", f"user.email={s.git_committer_email}",
                "pull", "--rebase", "origin", branch,
            )
            if rc != 0:
                await _git(repo_path, "rebase", "--abort")
                raise RuntimeError(f"git rebase onto remote failed: {err or out}")
            rebased = True
            continue
        raise RuntimeError(f"git push failed: {err or out}")
    raise RuntimeError(f"git push failed after retries: {err or out}")


async def _porcelain_paths(repo_path: str) -> list[str]:
    _, out, _ = await _git(repo_path, "status", "--porcelain", "-uall")
    paths: list[str] = []
    for line in (out or "").splitlines():
        if len(line) < 4:
            continue
        rest = line[3:]
        if " -> " in rest:
            rest = rest.split(" -> ", 1)[1]
        paths.append(rest.strip().strip('"'))
    return paths


async def stage_publishable_and_assert(
    repo_path: str, paths: list[str] | None = None
) -> list[str]:
    """Stage allowlisted application files only, then scan. Never ``git add -A``."""
    import os

    from src.domain.codegen.repo_export import (
        assert_export_clean,
        is_forbidden_path,
        normalize_repo_relative_path,
        select_publishable_paths,
    )

    candidates = paths if paths is not None else await _porcelain_paths(repo_path)
    normalized = [normalize_repo_relative_path(item) for item in candidates if item]
    allowed = select_publishable_paths(normalized)
    # After ``git rm -r``, porcelain still lists deleted allowlisted paths (e.g. app.vue).
    # ``git add -- app.vue`` then fails with pathspec did not match when the new tree
    # does not restore that file. Deletions already staged by ``git rm`` stay staged;
    # only add paths that exist in the working tree.
    to_add = [
        path for path in allowed if os.path.lexists(os.path.join(repo_path, path))
    ]
    for index in range(0, len(to_add), 32):
        chunk = to_add[index : index + 32]
        rc, _, err = await _git(repo_path, "add", "--", *chunk)
        if rc != 0:
            raise RuntimeError(f"git add failed: {err}")
    _, staged_deleted_out, _ = await _git(
        repo_path, "diff", "--cached", "--name-only", "--diff-filter=D", "--"
    )
    staged_deleted = {
        normalize_repo_relative_path(line)
        for line in staged_deleted_out.splitlines()
        if line.strip()
    }
    # A path can disappear from the index during a replacement (for example
    # when the incoming starter never had the old file). Only ask Git to stage
    # deletions that are still present as unstaged tracked deletions; otherwise
    # ``git add -u -- path`` raises a misleading pathspec error.
    _, unstaged_deleted_out, _ = await _git(
        repo_path, "diff", "--name-only", "--diff-filter=D", "--"
    )
    unstaged_deleted = {
        normalize_repo_relative_path(line)
        for line in unstaged_deleted_out.splitlines()
        if line.strip()
    }
    deleted = [
        path
        for path in allowed
        if not os.path.lexists(os.path.join(repo_path, path))
        and path not in staged_deleted
        and path in unstaged_deleted
    ]
    for index in range(0, len(deleted), 32):
        chunk = deleted[index : index + 32]
        rc, _, err = await _git(repo_path, "add", "-u", "--", *chunk)
        if rc != 0:
            raise RuntimeError(f"git add deleted paths failed: {err}")
    _, out, _ = await _git(repo_path, "diff", "--cached", "--name-only")
    staged = [
        normalize_repo_relative_path(line)
        for line in (out or "").splitlines()
        if line.strip()
    ]
    allowed_set = set(allowed)
    drop = [
        path
        for path in staged
        if is_forbidden_path(path) or path not in allowed_set
    ]
    if drop:
        rc, _, err = await _git(repo_path, "rm", "-f", "--cached", "--", *drop)
        if rc != 0:
            await _git(repo_path, "reset", "--", *drop)
        staged = [path for path in staged if path not in set(drop)]
    assert_export_clean(repo_path, staged)
    return staged


async def _unstage_control_plane_and_assert(repo_path: str) -> None:
    """Compatibility wrapper. Prefer ``stage_publishable_and_assert``."""
    await stage_publishable_and_assert(repo_path)


async def git_init_and_commit(
    repo_path: str,
    commit_message: str,
    git_name: str,
    git_email: str,
    initial_branch: str = "main",
) -> str:
    """git init, stage allowlisted files, and commit at repo_path.

    Idempotent: returns the existing HEAD sha without touching anything if the
    repo already has at least one commit.  All files must already be in place
    before calling this function.
    """
    existing = await git_rev_parse_head(repo_path)
    if existing:
        logger.info("git_init_and_commit_noop", path=repo_path, sha=existing[:8])
        return existing

    os.makedirs(repo_path, exist_ok=True)
    env = {
        **os.environ,
        "GIT_AUTHOR_NAME": git_name, "GIT_AUTHOR_EMAIL": git_email,
        "GIT_COMMITTER_NAME": git_name, "GIT_COMMITTER_EMAIL": git_email,
    }

    # --initial-branch requires git ≥ 2.28; fall back gracefully for older installs.
    rc, _, _ = await _git(repo_path, "init", f"--initial-branch={initial_branch}")
    if rc != 0:
        rc, _, err = await _git(repo_path, "init")
        if rc != 0:
            raise RuntimeError(f"git init failed: {err}")
        await _git(repo_path, "checkout", "-b", initial_branch)

    await stage_publishable_and_assert(repo_path)

    proc = await asyncio.create_subprocess_exec(
        "git", "commit", "-m", commit_message,
        cwd=repo_path, env=env,
        stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
    )
    stdout, stderr = await proc.communicate()
    if proc.returncode != 0:
        combined = (stdout or b"").decode() + (stderr or b"").decode()
        raise RuntimeError(f"git commit failed: {combined}")

    sha = await git_rev_parse_head(repo_path)
    if not sha:
        raise RuntimeError("git rev-parse HEAD returned nothing after initial commit")
    logger.info("git_init_and_commit_ok", path=repo_path, sha=sha[:8], branch=initial_branch)
    return sha


def _is_missing_remote_branch_error(message: str) -> bool:
    """True when ``git fetch`` / ``git clone -b`` failed because the ref is absent on origin."""
    m = (message or "").lower()
    return (
        "couldn't find remote ref" in m
        or "could not find remote ref" in m
        or "invalid refspec" in m
        or ("remote branch" in m and "not found" in m)
    )


async def prepare_working_tree(
    repo_path: str,
    clone_url: str,
    branch: str = "main",
    *,
    heal_missing_remote_from: Optional[str] = None,
) -> None:
    """Sync ``repo_path`` to ``origin/{branch}`` without deleting an existing clone (preserves Cursor workspace).

    If ``repo_path`` is missing or not a git repo, performs a fresh ``git clone``.

    ``heal_missing_remote_from``: When set (typ. default branch name) and ``branch`` is missing on
    the remote, create ``branch`` from ``origin/{heal_missing_remote_from}`` locally and push it so
    P2HS-managed preview branches self-repair after partial setup or legacy repos.
    """
    parent = os.path.dirname(repo_path)
    if parent:
        os.makedirs(parent, exist_ok=True)

    if os.path.isdir(os.path.join(repo_path, ".git")):
        rc, _, _ = await _git(repo_path, "remote", "get-url", "origin")
        if rc == 0:
            rc, _, err = await _git(repo_path, "remote", "set-url", "origin", clone_url)
            if rc != 0:
                raise RuntimeError(f"git remote set-url failed: {err}")
        else:
            rc, _, err = await _git(repo_path, "remote", "add", "origin", clone_url)
            if rc != 0:
                raise RuntimeError(f"git remote add failed: {err}")

        rc, _, err = await _git(repo_path, "fetch", "origin", branch)
        base = (heal_missing_remote_from or "").strip()
        # Some Git versions return success for a refspec that resolves to no
        # remote-tracking ref.  Check the ref explicitly before checkout so a
        # missing preview branch follows the same healing path as a failed fetch.
        ref_rc, _, ref_err = await _git(
            repo_path, "rev-parse", "--verify", f"origin/{branch}"
        )
        missing_remote_branch = rc != 0 or ref_rc != 0
        if missing_remote_branch:
            fetch_error = err or ref_err
            if (
                base
                and base != branch
                and (_is_missing_remote_branch_error(fetch_error) or ref_rc != 0)
            ):
                logger.warning(
                    "prepare_working_tree_missing_remote_branch",
                    path=repo_path,
                    branch=branch,
                    heal_from=base,
                    fetch_error=fetch_error.strip()[:500],
                )
                rc_b, _, err_b = await _git(repo_path, "fetch", "origin", base)
                if rc_b != 0:
                    raise RuntimeError(f"git fetch failed (heal base {base}): {err_b}")

                await _git(repo_path, "reset", "--hard", "HEAD")
                await _git(repo_path, "clean", "-fd")

                rc_c, _, err_c = await _git(
                    repo_path, "checkout", "-f", "-B", branch, f"origin/{base}"
                )
                if rc_c != 0:
                    raise RuntimeError(f"git checkout (heal {branch} from origin/{base}) failed: {err_c}")

                await push_branch(repo_path, branch, force=True)
                logger.info(
                    "working_tree_synced_healed_branch",
                    path=repo_path,
                    branch=branch,
                    from_remote=f"origin/{base}",
                )
                return
            raise RuntimeError(f"git fetch failed: {fetch_error}")

        # Discard any uncommitted changes left by a previous failed run so that
        # git checkout cannot refuse due to a dirty working tree.
        await _git(repo_path, "reset", "--hard", "HEAD")
        await _git(repo_path, "clean", "-fd")

        # -f forces the checkout even if files would be overwritten.
        rc, _, err = await _git(repo_path, "checkout", "-f", "-B", branch, f"origin/{branch}")
        if rc != 0:
            raise RuntimeError(f"git checkout failed: {err}")

        logger.info("working_tree_synced", path=repo_path, branch=branch)
        return

    try:
        await clone_repo(clone_url, repo_path, branch=branch)
    except RuntimeError as exc:
        msg = str(exc)
        base = (heal_missing_remote_from or "").strip()
        if base and base != branch and _is_missing_remote_branch_error(msg):
            logger.warning(
                "prepare_working_tree_clone_heal",
                path=repo_path,
                branch=branch,
                heal_from=base,
                error=msg.strip()[:500],
            )
            await clone_repo(clone_url, repo_path, branch=base)
            await _git(repo_path, "reset", "--hard", "HEAD")
            await _git(repo_path, "clean", "-fd")
            rc_h, _, err_h = await _git(
                repo_path, "checkout", "-f", "-B", branch, f"origin/{base}"
            )
            if rc_h != 0:
                raise RuntimeError(
                    f"git checkout (heal clone {branch} from origin/{base}) failed: {err_h}"
                )
            await push_branch(repo_path, branch, force=True)
            logger.info(
                "working_tree_cloned_healed_branch",
                path=repo_path,
                branch=branch,
                from_remote=f"origin/{base}",
            )
            return
        raise


async def clone_repo(clone_url: str, dest_path: str, branch: str = "main") -> str:
    """Clone repo to dest_path. Returns dest_path on success."""
    os.makedirs(os.path.dirname(dest_path) or ".", exist_ok=True)
    if os.path.isdir(dest_path):
        shutil.rmtree(dest_path)
    proc = await asyncio.create_subprocess_exec(
        "git", "clone", "--depth", "50", "-b", branch, clone_url, dest_path,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    try:
        stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_GIT_CLONE_TIMEOUT)
    except asyncio.TimeoutError:
        proc.kill()
        await proc.wait()
        raise RuntimeError(f"git clone timed out after {_GIT_CLONE_TIMEOUT}s")
    if proc.returncode != 0:
        raise RuntimeError(f"git clone failed: {stderr.decode()}")
    logger.info("repo_cloned", dest=dest_path, branch=branch)
    return dest_path


async def push_repo(repo_path: str, branch: str = "main", *, force_with_lease: bool = False) -> None:
    """Push local commits to origin.

    Use ``force_with_lease=True`` for P2HS-managed single-writer branches (e.g. preview)
    so that a previous failed push or a manual commit on the remote cannot permanently
    block future codegen runs.  ``--force-with-lease`` is safe here: prepare_working_tree
    always fetches and resets to origin/{branch} immediately before the AI runs, so the
    lease ref is the state we just fetched from — nobody should be pushing to this branch
    concurrently.
    """
    push_args = ["push"]
    if force_with_lease:
        push_args.append("--force-with-lease")
    push_args += ["origin", branch]

    proc = await asyncio.create_subprocess_exec(
        "git", *push_args,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        cwd=repo_path,
    )
    stdout, stderr = await proc.communicate()
    if proc.returncode != 0:
        raise RuntimeError(f"git push failed: {stderr.decode()}")
    logger.info("repo_pushed", path=repo_path, branch=branch, force_with_lease=force_with_lease)


def backup_push_args(backup_ref: str) -> tuple[str, ...]:
    """Push args for a rollback backup ref.

    Forced on purpose: a retried promotion reuses its deploy id and records the production SHA
    it is about to replace, which can differ from an earlier attempt's backup. A non-forced
    push then fails every retry (non-fast-forward), and the deploy never promotes. The backup
    namespace is used only for rollback, so the newest pre-promotion SHA is the right value.
    """
    return ("push", "--force", "origin", f"{backup_ref}:{backup_ref}")


async def promote_preview_to_production(
    repo_path: str,
    clone_url: str,
    preview_branch: str,
    default_branch: str,
    deploy_id: str,
    git_name: str,
    git_email: str,
    source_ref: str | None = None,
) -> tuple[str, str]:
    """Replace production with preview while preserving a rollback point.

    The preview branch is the application-code source of truth. Before
    replacing ``main``, the previous production SHA is saved under a unique
    remote backup ref so rollback does not depend on a local reflog. The
    returned tuple is ``(new_production_sha, previous_production_sha)``.

    Credentials, database state, and Cloud configuration are intentionally not
    part of this Git promotion; they remain externally managed.
    """
    # Sync local repo to the latest state of both branches
    await prepare_working_tree(repo_path, clone_url, branch=default_branch)

    previous_sha = await git_rev_parse_head(repo_path)
    if not previous_sha:
        raise RuntimeError(f"Cannot determine current {default_branch} SHA")

    promotion_source_ref = source_ref or preview_branch
    rc, _, err = await _git(repo_path, "fetch", "origin", promotion_source_ref)
    if rc != 0:
        raise RuntimeError(f"git fetch failed for {promotion_source_ref}: {err}")

    rc, promotion_sha, err = await _git(repo_path, "rev-parse", "FETCH_HEAD")
    if rc != 0 or not promotion_sha.strip():
        raise RuntimeError(f"Cannot determine promotion source SHA: {err}")

    backup_ref = f"refs/p2hs/backups/{deploy_id}"
    rc, _, err = await _git(repo_path, "update-ref", backup_ref, previous_sha)
    if rc != 0:
        raise RuntimeError(f"git backup ref creation failed: {err}")

    rc, _, err = await _git(repo_path, *backup_push_args(backup_ref))
    if rc != 0:
        raise RuntimeError(f"git rollback backup push failed: {err}")

    rc, _, err = await _git(repo_path, "reset", "--hard", "FETCH_HEAD")
    if rc != 0:
        raise RuntimeError(f"git reset {default_branch} to promotion source failed: {err}")

    await push_repo(repo_path, branch=default_branch, force_with_lease=True)

    promoted_sha = promotion_sha.strip()
    logger.info(
        "preview_promoted_to_production",
        repo_path=repo_path,
        default_branch=default_branch,
        preview_branch=promotion_source_ref,
        sha=promoted_sha[:12] if promoted_sha else "unknown",
        previous_sha=previous_sha[:12],
        backup_ref=backup_ref,
    )
    return promoted_sha, previous_sha


async def create_or_reset_preview_branch(
    repo_path: str,
    preview_branch: str,
    from_branch: str = "main",
) -> None:
    """Create ``preview_branch`` from ``from_branch``, or reset it if it already exists.

    Called once during project setup so the preview branch always starts at the
    same commit as ``main`` / the production branch.
    """
    rc, _, _ = await _git(repo_path, "rev-parse", "--verify", preview_branch)
    if rc == 0:
        # Branch already exists locally — reset it to from_branch HEAD
        rc, _, err = await _git(repo_path, "branch", "-f", preview_branch, from_branch)
        if rc != 0:
            raise RuntimeError(f"git branch -f {preview_branch} {from_branch} failed: {err}")
    else:
        rc, _, err = await _git(repo_path, "checkout", "-b", preview_branch)
        if rc != 0:
            raise RuntimeError(f"git checkout -b {preview_branch} failed: {err}")
        rc, _, err = await _git(repo_path, "checkout", from_branch)
        if rc != 0:
            raise RuntimeError(f"git checkout {from_branch} failed: {err}")
    logger.info("preview_branch_ready", path=repo_path, branch=preview_branch, from_branch=from_branch)


async def push_branch(
    repo_path: str,
    branch: str,
    *,
    force: bool = False,
) -> None:
    """Push ``branch`` to origin. Use ``force=True`` only for the initial preview branch reset."""
    args = ["push", "origin", branch]
    if force:
        args.insert(1, "--force-with-lease")
    proc = await asyncio.create_subprocess_exec(
        "git", *args,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        cwd=repo_path,
    )
    stdout, stderr = await proc.communicate()
    combined = (stdout or b"").decode() + (stderr or b"").decode()
    if proc.returncode != 0 and "Everything up-to-date" not in combined:
        raise RuntimeError(f"git push origin {branch} failed: {stderr.decode()}")
    logger.info("branch_pushed", path=repo_path, branch=branch)


async def inject_deploy_workflow(
    repo_path: str,
    project_type: str,
    project_ref: str,
    git_name: str = "P2HS",
    git_email: str = "noreply@p2hs.app",
    project_created_at=None,
    settings=None,
    primary_hosting_provider=None,
    assume_unassigned_w4p: bool = False,
    has_successful_pages_deployment: bool | None = None,
) -> bool:
    """Write .github/workflows/deploy.yml and commit it if it changed.

    Returns True if a new commit was made, False if already up-to-date.
    Called on the current branch (usually ``main``) before the initial push to GitHub.
    """
    from src.domain.hosting.deployment_targets import deploy_workflow_for_project

    workflow_dir = os.path.join(repo_path, ".github", "workflows")
    workflow_path = os.path.join(workflow_dir, "deploy.yml")
    content = deploy_workflow_for_project(
        project_type, project_ref, project_created_at, settings,
        primary_hosting_provider=primary_hosting_provider,
        assume_unassigned_w4p=assume_unassigned_w4p,
        has_successful_pages_deployment=has_successful_pages_deployment,
    )

    os.makedirs(workflow_dir, exist_ok=True)

    env = {**os.environ, "GIT_AUTHOR_NAME": git_name, "GIT_AUTHOR_EMAIL": git_email,
           "GIT_COMMITTER_NAME": git_name, "GIT_COMMITTER_EMAIL": git_email}

    # Ensure we're on main (not detached HEAD) before committing
    rc_branch, current_branch, _ = await _git(repo_path, "symbolic-ref", "--short", "HEAD")
    if rc_branch != 0 or (current_branch or "").strip() != "main":
        rc_co, _, err_co = await _git(repo_path, "checkout", "main")
        if rc_co != 0:
            raise RuntimeError(f"git checkout main failed: {err_co}")

    if os.path.isfile(workflow_path):
        with open(workflow_path, "r", encoding="utf-8") as f:
            existing = f.read()
        if existing == content:
            logger.info("deploy_workflow_unchanged", path=repo_path)
            return False

    with open(workflow_path, "w", encoding="utf-8") as f:
        f.write(content)

    proc = await asyncio.create_subprocess_exec(
        "git", "add", ".github/workflows/deploy.yml",
        cwd=repo_path, env=env,
        stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
    )
    await proc.communicate()

    proc = await asyncio.create_subprocess_exec(
        "git", "commit", "-m", "chore: add CF Pages deploy workflow",
        cwd=repo_path, env=env,
        stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
    )
    stdout, stderr = await proc.communicate()
    if proc.returncode != 0:
        out = (stdout or b"").decode()
        err = (stderr or b"").decode()
        if "nothing to commit" in out + err:
            return False
        raise RuntimeError(f"git commit (deploy workflow) failed: {err}")

    logger.info("deploy_workflow_committed", path=repo_path, project_ref=project_ref, project_type=project_type)
    return True


async def ensure_deploy_workflow(
    repo_path: str,
    project_type: str,
    project_ref: str,
    git_name: str = "P2HS",
    git_email: str = "noreply@p2hs.app",
    project_created_at=None,
    settings=None,
    primary_hosting_provider=None,
    assume_unassigned_w4p: bool = False,
    has_successful_pages_deployment: bool | None = None,
) -> bool:
    """Ensure .github/workflows/deploy.yml is present and up-to-date on the current branch.

    Unlike inject_deploy_workflow (which forces a checkout to main), this writes directly
    on whichever branch is currently checked out. Used during codegen to guarantee the
    deploy workflow is on the preview branch before pushing. Returns True if a commit was made.
    """
    from src.domain.hosting.deployment_targets import deploy_workflow_for_project

    if not project_ref:
        return False

    workflow_dir = os.path.join(repo_path, ".github", "workflows")
    workflow_path = os.path.join(workflow_dir, "deploy.yml")
    content = deploy_workflow_for_project(
        project_type, project_ref, project_created_at, settings,
        primary_hosting_provider=primary_hosting_provider,
        assume_unassigned_w4p=assume_unassigned_w4p,
        has_successful_pages_deployment=has_successful_pages_deployment,
    )

    from src.domain.codegen.port_adapter import materialize_checkout_platform_lock

    staged = materialize_checkout_platform_lock(repo_path, project_type)
    if os.path.isfile(workflow_path):
        with open(workflow_path, "r", encoding="utf-8") as f:
            existing = f.read()
        workflow_changed = existing != content
    else:
        workflow_changed = True
    if workflow_changed:
        os.makedirs(workflow_dir, exist_ok=True)
        with open(workflow_path, "w", encoding="utf-8") as f:
            f.write(content)
        staged.append(".github/workflows/deploy.yml")
    if not staged:
        return False

    env = {
        **os.environ,
        "GIT_AUTHOR_NAME": git_name, "GIT_AUTHOR_EMAIL": git_email,
        "GIT_COMMITTER_NAME": git_name, "GIT_COMMITTER_EMAIL": git_email,
    }

    proc = await asyncio.create_subprocess_exec(
        "git", "add", "-f", "--", *staged,
        cwd=repo_path, env=env,
        stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
    )
    await proc.communicate()
    if proc.returncode != 0:
        raise RuntimeError("git add deploy workflow and platform lock failed")

    message = (
        "[p2hs] Add/update GitHub Actions deploy workflow"
        if workflow_changed
        else "[p2hs] Track the W4P platform ownership lock"
    )
    proc = await asyncio.create_subprocess_exec(
        "git", "commit", "-m", message,
        cwd=repo_path, env=env,
        stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
    )
    stdout, stderr = await proc.communicate()
    if proc.returncode != 0:
        out = (stdout or b"").decode()
        err = (stderr or b"").decode()
        if "nothing to commit" in (out + err).lower():
            return False
        raise RuntimeError(f"git commit deploy.yml failed: {err}")

    logger.info("deploy_workflow_ensured", path=repo_path, project_type=project_type, project_ref=project_ref)
    return True


async def ensure_generated_runtime_adapters(
    repo_path: str,
    project_type: str,
    *,
    git_name: str = "P2HS",
    git_email: str = "noreply@p2hs.app",
) -> bool:
    """Refresh only the platform-owned records/files BFF adapter files.

    This is deliberately separate from ``ensure_deploy_workflow`` so a workflow
    migration cannot overwrite application routes implicitly. The caller opts in
    for a known project whose generated bundle needs a compatibility repair.
    """
    from src.domain.codegen.file_adapter import file_adapter_files
    from src.domain.codegen.record_adapter import record_adapter_files

    files = {**record_adapter_files(project_type), **file_adapter_files(project_type)}
    if not files:
        return False
    for relative, content in files.items():
        full_path = os.path.join(repo_path, relative)
        os.makedirs(os.path.dirname(full_path), exist_ok=True)
        with open(full_path, "w", encoding="utf-8", newline="\n") as fh:
            fh.write(content)

    env = {
        **os.environ,
        "GIT_AUTHOR_NAME": git_name,
        "GIT_AUTHOR_EMAIL": git_email,
        "GIT_COMMITTER_NAME": git_name,
        "GIT_COMMITTER_EMAIL": git_email,
    }
    proc = await asyncio.create_subprocess_exec(
        "git", "add", "--", *files.keys(),
        cwd=repo_path,
        env=env,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    await proc.communicate()
    if proc.returncode != 0:
        raise RuntimeError("git add generated runtime adapters failed")

    diff = await asyncio.create_subprocess_exec(
        "git", "diff", "--cached", "--quiet", "--", *files.keys(),
        cwd=repo_path,
        env=env,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    await diff.communicate()
    if diff.returncode == 0:
        return False
    if diff.returncode != 1:
        raise RuntimeError("git diff generated runtime adapters failed")

    proc = await asyncio.create_subprocess_exec(
        "git", "commit", "-m", "[p2hs] Refresh generated runtime BFF adapters",
        cwd=repo_path,
        env=env,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    stdout, stderr = await proc.communicate()
    if proc.returncode != 0:
        raise RuntimeError(f"git commit generated runtime adapters failed: {(stderr or stdout).decode()}")
    logger.info(
        "generated_runtime_adapters_refreshed",
        path=repo_path,
        project_type=project_type,
        files=sorted(files),
    )
    return True


async def write_and_commit_file(
    repo_path: str,
    rel_path: str,
    content: str,
    commit_message: str,
    git_name: str = "P2HS",
    git_email: str = "noreply@p2hs.app",
) -> bool:
    """Write a file at rel_path (relative to repo root) and commit it.

    Returns True if a commit was created, False if there was nothing to commit.
    """
    full_path = os.path.join(repo_path, rel_path)
    os.makedirs(os.path.dirname(full_path), exist_ok=True)
    with open(full_path, "w", encoding="utf-8") as f:
        f.write(content)

    env = {
        **os.environ,
        "GIT_AUTHOR_NAME": git_name, "GIT_AUTHOR_EMAIL": git_email,
        "GIT_COMMITTER_NAME": git_name, "GIT_COMMITTER_EMAIL": git_email,
    }

    proc = await asyncio.create_subprocess_exec(
        "git", "add", rel_path,
        cwd=repo_path, env=env,
        stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
    )
    await proc.communicate()

    proc = await asyncio.create_subprocess_exec(
        "git", "commit", "-m", commit_message,
        cwd=repo_path, env=env,
        stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
    )
    stdout, stderr = await proc.communicate()
    if proc.returncode != 0:
        out = (stdout or b"").decode()
        err = (stderr or b"").decode()
        if "nothing to commit" in (out + err).lower():
            return False
        raise RuntimeError(f"git commit failed: {err}")
    return True


async def merge_branch_into(
    repo_path: str,
    source_branch: str,
    target_branch: str,
    *,
    commit_message: str = "chore: merge preview into main for deployment",
) -> str:
    """Merge ``source_branch`` into ``target_branch`` (fast-forward when possible).

    Returns the resulting HEAD SHA on ``target_branch``.
    Leaves the repo checked out on ``target_branch``.
    """
    rc, _, err = await _git(repo_path, "checkout", target_branch)
    if rc != 0:
        raise RuntimeError(f"git checkout {target_branch} failed: {err}")

    # Try fast-forward first
    rc, _, err = await _git(repo_path, "merge", "--ff-only", source_branch)
    if rc != 0:
        # If FF not possible, do a merge commit
        rc, _, err = await _git(repo_path, "merge", source_branch, "--no-edit", "-m", commit_message)
        if rc != 0:
            raise RuntimeError(f"git merge {source_branch} into {target_branch} failed: {err}")

    sha = await git_rev_parse_head(repo_path)
    if not sha:
        raise RuntimeError("git rev-parse HEAD returned nothing after merge")
    logger.info("branch_merged", path=repo_path, source=source_branch, target=target_branch, sha=sha[:8])
    return sha


async def checkout_sha(repo_path: str, sha: str, branch: str = "main") -> None:
    """Checkout a specific commit SHA. Fetches from origin if SHA not in local history."""
    proc = await asyncio.create_subprocess_exec(
        "git", "checkout", sha,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        cwd=repo_path,
    )
    stdout, stderr = await proc.communicate()
    if proc.returncode != 0:
        proc = await asyncio.create_subprocess_exec(
            "git", "fetch", "origin", branch,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            cwd=repo_path,
        )
        await proc.communicate()
        proc = await asyncio.create_subprocess_exec(
            "git", "checkout", sha,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            cwd=repo_path,
        )
        stdout, stderr = await proc.communicate()
        if proc.returncode != 0:
            raise RuntimeError(f"git checkout {sha} failed: {stderr.decode()}")
    logger.info("repo_checked_out", path=repo_path, sha=sha[:8])
