import re
from sys import stderr

import pygit2

from pygittools.hooks import CommitMsg, Hook, HookResult, PreReceive, RefUpdate

NULL_OID: str = "0" * 40

"""
Commit-Msg Pattern Hook (Client-side)
Validates the commit message against a regular expression.
"""


class CommitMsgPattern(CommitMsg):
    def __init__(self, repo: pygit2.Repository, pattern: str):
        super().__init__(repo)
        self.exp = re.compile(pattern)

    def run(self, message_file: str) -> HookResult:
        with open(message_file) as f:
            message = f.read()
        if not self.exp.match(message):
            stderr.write(f"Commit message does not match pattern: {self.exp.pattern}\n")
            return HookResult.FAILURE
        return HookResult.SUCCESS


"""
Update Pattern Hook (Server-side)
Invoked by git-receive-pack once per ref being updated, before the ref is updated.
Use this hook to:
- Enforce fast-forward only (reject non-FF updates)
- Implement per-ref access control
- Log or validate old → new for specific refs
Takes three parameters: ref name, old object name, new object name.
"""


class UpdatePattern(Hook):
    def __init__(self, repo: pygit2.Repository, ref_pattern: str, msg_pattern: str):
        super().__init__(repo)
        self.ref_exp = re.compile(ref_pattern)
        self.msg_exp = re.compile(msg_pattern)

    def run(self, ref_name: str, old_oid: str, new_oid: str) -> HookResult:
        if self.ref_exp.match(ref_name):
            commit = self.repo.revparse_single(new_oid).peel(pygit2.Commit)
            if not self.msg_exp.match(commit.message):
                stderr.write(f"Commit message does not match pattern: {self.msg_exp.pattern}\n")
                return HookResult.FAILURE
        return HookResult.SUCCESS


"""
Pre-Receive Protected Pattern Hook (Server-side)
Invoked by git-receive-pack once before any refs are updated. For each ref update that
matches a protected ref pattern, walks every commit being introduced (old..new) and
validates non-merge commit messages against a pattern. Merge commits are skipped by
default; set reject_merge_commits to refuse merge commits and require squash/rebase.
"""


class PreReceiveProtectedPattern(PreReceive):
    def __init__(
        self,
        repo: pygit2.Repository,
        ref_pattern: str,
        msg_pattern: str,
        *,
        reject_merge_commits: bool = False,
    ) -> None:
        super().__init__(repo)
        self.ref_exp: re.Pattern[str] = re.compile(ref_pattern)
        self.msg_exp: re.Pattern[str] = re.compile(msg_pattern)
        self.reject_merge_commits: bool = reject_merge_commits

    def run(self, ref_updates: list[RefUpdate]) -> HookResult:
        for ref_name, old_oid, new_oid in ref_updates:
            if not self.ref_exp.match(ref_name):
                continue
            result: HookResult = self._validate_ref_update(ref_name, old_oid, new_oid)
            if result != HookResult.SUCCESS:
                return result
        return HookResult.SUCCESS

    def _validate_ref_update(self, ref_name: str, old_oid: str, new_oid: str) -> HookResult:
        if new_oid == NULL_OID:
            return HookResult.SUCCESS
        try:
            new_commit: pygit2.Commit = self.repo.revparse_single(new_oid).peel(pygit2.Commit)
        except (KeyError, pygit2.GitError):
            stderr.write(f"Invalid commit {new_oid} for {ref_name}\n")
            return HookResult.FAILURE

        old_commit: pygit2.Commit | None = None
        if old_oid != NULL_OID:
            try:
                old_commit = self.repo.revparse_single(old_oid).peel(pygit2.Commit)
            except (KeyError, pygit2.GitError):
                old_commit = None

        for commit in self._commits_in_range(new_commit, old_commit):
            if len(commit.parents) > 1:
                if self.reject_merge_commits:
                    stderr.write(
                        f"Merge commit {commit.id} rejected on protected ref {ref_name}; "
                        "use squash or rebase instead.\n",
                    )
                    return HookResult.FAILURE
                continue
            if not self.msg_exp.match(commit.message):
                stderr.write(
                    f"Commit {commit.id} on {ref_name} does not match pattern: {self.msg_exp.pattern}\n",
                )
                return HookResult.FAILURE
        return HookResult.SUCCESS

    def _commits_in_range(
        self,
        new_commit: pygit2.Commit,
        old_commit: pygit2.Commit | None,
    ) -> list[pygit2.Commit]:
        excluded: set[pygit2.Oid] = set()
        if old_commit is not None:
            for ancestor in self.repo.walk(old_commit.id):
                excluded.add(ancestor.id)
        commits: list[pygit2.Commit] = []
        for commit in self.repo.walk(new_commit.id):
            if commit.id in excluded:
                continue
            commits.append(commit)
        return commits