"""Self-modification loop — branch → edit → test → commit/revert.

Timmy's self-coding capability, restored after deletion in
Operation Darling Purge (commit 584eeb679e88).

## Cycle
1. **Branch** — create ``self-modify/<slug>`` from ``main``
2. **Edit**   — apply the proposed change (patch string or callable)
3. **Test**   — run ``pytest tests/ -x -q``; never commit on failure
4. **Commit** — stage and commit on green; revert branch on red
5. **PR**     — open a Gitea pull request (requires no direct push to main)

## Guards
- Never push directly to ``main`` or ``master``
- All changes land via PR (enforced by ``_guard_branch``)
- Test gate is mandatory; ``skip_tests=True`` is for unit-test use only
- Commits only happen when ``pytest tests/ -x -q`` exits 0

## Usage::

    from self_coding.self_modify.loop import SelfModifyLoop

    loop = SelfModifyLoop()
    result = await loop.run(
        slug="add-hello-tool",
        description="Add hello() convenience tool",
        edit_fn=my_edit_function,  # callable(repo_root: str) -> None
    )
    if result.success:
        print(f"PR: {result.pr_url}")
    else:
        print(f"Failed: {result.error}")
"""

from __future__ import annotations

import logging
import subprocess
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path

from config import settings

logger = logging.getLogger(__name__)

# Branches that must never receive direct commits
_PROTECTED_BRANCHES = frozenset({"main", "master", "develop"})

# Test command used as the commit gate
_TEST_COMMAND = ["pytest", "tests/", "-x", "-q", "--tb=short"]

# Max time (seconds) to wait for the test suite
_TEST_TIMEOUT = 300


@dataclass
class LoopResult:
    """Result from one self-modification cycle."""

    success: bool
    branch: str = ""
    commit_sha: str = ""
    pr_url: str = ""
    pr_number: int = 0
    test_output: str = ""
    error: str = ""
    elapsed_ms: float = 0.0
    metadata: dict = field(default_factory=dict)


class SelfModifyLoop:
    """Orchestrate branch → edit → test → commit/revert → PR.

    Args:
        repo_root: Absolute path to the git repository (defaults to
                   ``settings.repo_root``).
        remote:    Git remote name (default ``origin``).
        base_branch: Branch to fork from and target for the PR
                     (default ``main``).
    """

    def __init__(
        self,
        repo_root: str | None = None,
        remote: str = "origin",
        base_branch: str = "main",
    ) -> None:
        self._repo_root = Path(repo_root or settings.repo_root)
        self._remote = remote
        self._base_branch = base_branch

    # ── public ──────────────────────────────────────────────────────────────

    async def run(
        self,
        slug: str,
        description: str,
        edit_fn: Callable[[str], None],
        issue_number: int | None = None,
        skip_tests: bool = False,
    ) -> LoopResult:
        """Execute one full self-modification cycle.

        Args:
            slug:         Short identifier used for the branch name
                          (e.g. ``"add-hello-tool"``).
            description:  Human-readable description for commit message
                          and PR body.
            edit_fn:      Callable that receives the repo root path (str)
                          and applies the desired code changes in-place.
            issue_number: Optional Gitea issue number to reference in PR.
            skip_tests:   If ``True``, skip the test gate (unit-test use
                          only — never use in production).

        Returns:
            :class:`LoopResult` describing the outcome.
        """
        start = time.time()
        branch = f"self-modify/{slug}"

        try:
            self._guard_branch(branch)
            self._checkout_base()
            self._create_branch(branch)

            try:
                edit_fn(str(self._repo_root))
            except Exception as exc:
                self._revert_branch(branch)
                return LoopResult(
                    success=False,
                    branch=branch,
                    error=f"edit_fn raised: {exc}",
                    elapsed_ms=self._elapsed(start),
                )

            if not skip_tests:
                test_output, passed = self._run_tests()
                if not passed:
                    self._revert_branch(branch)
                    return LoopResult(
                        success=False,
                        branch=branch,
                        test_output=test_output,
                        error="Tests failed — branch reverted",
                        elapsed_ms=self._elapsed(start),
                    )
            else:
                test_output = "(tests skipped)"

            sha = self._commit_all(description)
            self._push_branch(branch)

            pr = self._create_pr(
                branch=branch,
                description=description,
                test_output=test_output,
                issue_number=issue_number,
            )

            return LoopResult(
                success=True,
                branch=branch,
                commit_sha=sha,
                pr_url=pr.html_url if pr else "",
                pr_number=pr.number if pr else 0,
                test_output=test_output,
                elapsed_ms=self._elapsed(start),
            )

        except Exception as exc:
            logger.warning("Self-modify loop failed: %s", exc)
            return LoopResult(
                success=False,
                branch=branch,
                error=str(exc),
                elapsed_ms=self._elapsed(start),
            )

    # ── private helpers ──────────────────────────────────────────────────────

    @staticmethod
    def _elapsed(start: float) -> float:
        return (time.time() - start) * 1000

    def _git(self, *args: str, check: bool = True) -> subprocess.CompletedProcess:
        """Run a git command in the repo root."""
        cmd = ["git", *args]
        logger.debug("git %s", " ".join(args))
        return subprocess.run(
            cmd,
            cwd=str(self._repo_root),
            capture_output=True,
            text=True,
            check=check,
        )

    def _guard_branch(self, branch: str) -> None:
        """Raise if the target branch is a protected branch name."""
        if branch in _PROTECTED_BRANCHES:
            raise ValueError(
                f"Refusing to operate on protected branch '{branch}'. "
                "All self-modifications must go via PR."
            )

    def _checkout_base(self) -> None:
        """Checkout the base branch and pull latest."""
        self._git("checkout", self._base_branch)
        # Best-effort pull; ignore failures (e.g. no remote configured)
        self._git("pull", self._remote, self._base_branch, check=False)

    def _create_branch(self, branch: str) -> None:
        """Create and checkout a new branch, deleting an old one if needed."""
        # Delete local branch if it already exists (stale prior attempt)
        self._git("branch", "-D", branch, check=False)
        self._git("checkout", "-b", branch)
        logger.info("Created branch: %s", branch)

    def _revert_branch(self, branch: str) -> None:
        """Checkout base and delete the failed branch."""
        try:
            self._git("checkout", self._base_branch, check=False)
            self._git("branch", "-D", branch, check=False)
            logger.info("Reverted and deleted branch: %s", branch)
        except Exception as exc:
            logger.warning("Failed to revert branch %s: %s", branch, exc)

    def _run_tests(self) -> tuple[str, bool]:
        """Run the test suite. Returns (output, passed)."""
        logger.info("Running test suite: %s", " ".join(_TEST_COMMAND))
        try:
            result = subprocess.run(
                _TEST_COMMAND,
                cwd=str(self._repo_root),
                capture_output=True,
                text=True,
                timeout=_TEST_TIMEOUT,
            )
            output = (result.stdout + "\n" + result.stderr).strip()
            passed = result.returncode == 0
            logger.info(
                "Test suite %s (exit %d)", "PASSED" if passed else "FAILED", result.returncode
            )
            return output, passed
        except subprocess.TimeoutExpired:
            msg = f"Test suite timed out after {_TEST_TIMEOUT}s"
            logger.warning(msg)
            return msg, False
        except FileNotFoundError:
            msg = "pytest not found on PATH"
            logger.warning(msg)
            return msg, False

    def _commit_all(self, message: str) -> str:
        """Stage all changes and create a commit. Returns the new SHA."""
        self._git("add", "-A")
        self._git("commit", "-m", message)
        result = self._git("rev-parse", "HEAD")
        sha = result.stdout.strip()
        logger.info("Committed: %s  sha=%s", message[:60], sha[:12])
        return sha

    def _push_branch(self, branch: str) -> None:
        """Push the branch to the remote."""
        self._git("push", "-u", self._remote, branch)
        logger.info("Pushed branch: %s -> %s", branch, self._remote)

    def _create_pr(
        self,
        branch: str,
        description: str,
        test_output: str,
        issue_number: int | None,
    ):
        """Open a Gitea PR. Returns PullRequest or None on failure."""
        from self_coding.gitea_client import GiteaClient

        client = GiteaClient()

        issue_ref = f"\n\nFixes #{issue_number}" if issue_number else ""
        test_section = (
            f"\n\n## Test results\n```\n{test_output[:2000]}\n```"
            if test_output and test_output != "(tests skipped)"
            else ""
        )

        body = (
            f"## Summary\n{description}"
            f"{issue_ref}"
            f"{test_section}"
            "\n\n🤖 Generated by Timmy's self-modification loop"
        )

        return client.create_pull_request(
            title=f"[self-modify] {description[:60]}",
            body=body,
            head=branch,
            base=self._base_branch,
        )
