# [DEF:backend.src.services.git_service:Module]
#
# @TIER:      STANDARD
# @SEMANTICS: git, service, gitpython, repository, version_control
# @PURPOSE:   Core Git logic using GitPython to manage dashboard repositories.
# @LAYER:     Service
# @RELATION:  INHERITS_FROM -> None
# @RELATION:  USED_BY -> src.api.routes.git
# @RELATION:  USED_BY -> src.plugins.git_plugin
# @RELATION:  DEPENDS_ON -> src.core.database.SessionLocal
# @RELATION:  DEPENDS_ON -> src.models.config.AppConfigRecord
# @RELATION:  DEPENDS_ON -> src.models.git.GitRepository
#
# @INVARIANT: All Git operations must be performed on a valid local directory.

import os
import httpx
import re
import shutil
from git import Repo
from git.exc import GitCommandError
from git.exc import InvalidGitRepositoryError, NoSuchPathError
from git.objects.blob import Blob
from fastapi import HTTPException
from typing import Any, Dict, List, Optional
from datetime import datetime
from pathlib import Path
from urllib.parse import quote, urlparse
from src.core.logger import logger, belief_scope
from src.models.git import GitProvider
from src.models.git import GitRepository, GitServerConfig
from src.models.config import AppConfigRecord
from src.core.database import SessionLocal

# [DEF:GitService:Class]
# @PURPOSE: Wrapper for GitPython operations with semantic logging and error handling.
class GitService:
    """
    Wrapper for GitPython operations.
    """

    # [DEF:__init__:Function]
    # @PURPOSE: Initializes the GitService with a base path for repositories.
    # @PARAM: base_path (str) - Root directory for all Git clones.
    # @PRE: base_path is a valid string path.
    # @POST: GitService is initialized; base_path directory exists.
    def __init__(self, base_path: str = "git_repos"):
        with belief_scope("GitService.__init__"):
            backend_root = Path(__file__).parents[2]
            self.legacy_base_path = str((backend_root / "git_repos").resolve())
            self.base_path = self._resolve_base_path(base_path)
            self._ensure_base_path_exists()
    # [/DEF:__init__:Function]

    # [DEF:_ensure_base_path_exists:Function]
    # @PURPOSE: Ensure the repositories root directory exists and is a directory.
    # @PRE: self.base_path is resolved to filesystem path.
    # @POST: self.base_path exists as directory or raises ValueError.
    # @RETURN: None
    def _ensure_base_path_exists(self) -> None:
        base = Path(self.base_path)
        if base.exists() and not base.is_dir():
            raise ValueError(f"Git repositories base path is not a directory: {self.base_path}")
        base.mkdir(parents=True, exist_ok=True)
    # [/DEF:_ensure_base_path_exists:Function]

    # [DEF:_resolve_base_path:Function]
    # @PURPOSE: Resolve base repository directory from explicit argument or global storage settings.
    # @PRE: base_path is a string path.
    # @POST: Returns absolute path for Git repositories root.
    # @RETURN: str
    def _resolve_base_path(self, base_path: str) -> str:
        # Resolve relative to backend directory for backward compatibility.
        backend_root = Path(__file__).parents[2]
        fallback_path = str((backend_root / base_path).resolve())

        if base_path != "git_repos":
            return fallback_path

        try:
            session = SessionLocal()
            try:
                config_row = session.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first()
            finally:
                session.close()

            payload = (config_row.payload if config_row and config_row.payload else {}) if config_row else {}
            storage_cfg = payload.get("settings", {}).get("storage", {}) if isinstance(payload, dict) else {}

            root_path = str(storage_cfg.get("root_path", "")).strip()
            repo_path = str(storage_cfg.get("repo_path", "")).strip()
            if not root_path:
                return fallback_path

            project_root = Path(__file__).parents[3]
            root = Path(root_path)
            if not root.is_absolute():
                root = (project_root / root).resolve()

            repo_root = Path(repo_path) if repo_path else Path("repositorys")
            if repo_root.is_absolute():
                return str(repo_root.resolve())
            return str((root / repo_root).resolve())
        except Exception as e:
            logger.warning(f"[_resolve_base_path][Coherence:Failed] Falling back to default path: {e}")
            return fallback_path
    # [/DEF:_resolve_base_path:Function]

    # [DEF:_normalize_repo_key:Function]
    # @PURPOSE: Convert user/dashboard-provided key to safe filesystem directory name.
    # @PRE: repo_key can be None/empty.
    # @POST: Returns normalized non-empty key.
    # @RETURN: str
    def _normalize_repo_key(self, repo_key: Optional[str]) -> str:
        raw_key = str(repo_key or "").strip().lower()
        normalized = re.sub(r"[^a-z0-9._-]+", "-", raw_key).strip("._-")
        return normalized or "dashboard"
    # [/DEF:_normalize_repo_key:Function]

    # [DEF:_update_repo_local_path:Function]
    # @PURPOSE: Persist repository local_path in GitRepository table when record exists.
    # @PRE: dashboard_id is valid integer.
    # @POST: local_path is updated for existing record.
    # @RETURN: None
    def _update_repo_local_path(self, dashboard_id: int, local_path: str) -> None:
        try:
            session = SessionLocal()
            try:
                db_repo = (
                    session.query(GitRepository)
                    .filter(GitRepository.dashboard_id == int(dashboard_id))
                    .first()
                )
                if db_repo:
                    db_repo.local_path = local_path
                    session.commit()
            finally:
                session.close()
        except Exception as e:
            logger.warning(f"[_update_repo_local_path][Coherence:Failed] {e}")
    # [/DEF:_update_repo_local_path:Function]

    # [DEF:_migrate_repo_directory:Function]
    # @PURPOSE: Move legacy repository directory to target path and sync DB metadata.
    # @PRE: source_path exists.
    # @POST: Repository content available at target_path.
    # @RETURN: str
    def _migrate_repo_directory(self, dashboard_id: int, source_path: str, target_path: str) -> str:
        source_abs = os.path.abspath(source_path)
        target_abs = os.path.abspath(target_path)
        if source_abs == target_abs:
            return source_abs

        if os.path.exists(target_abs):
            logger.warning(
                f"[_migrate_repo_directory][Action] Target already exists, keeping source path: {target_abs}"
            )
            return source_abs

        Path(target_abs).parent.mkdir(parents=True, exist_ok=True)
        try:
            os.replace(source_abs, target_abs)
        except OSError:
            shutil.move(source_abs, target_abs)

        self._update_repo_local_path(dashboard_id, target_abs)
        logger.info(
            f"[_migrate_repo_directory][Coherence:OK] Repository migrated for dashboard {dashboard_id}: {source_abs} -> {target_abs}"
        )
        return target_abs
    # [/DEF:_migrate_repo_directory:Function]

    # [DEF:_ensure_gitflow_branches:Function]
    # @PURPOSE: Ensure standard GitFlow branches (main/dev/preprod) exist locally and on origin.
    # @PRE: repo is a valid GitPython Repo instance.
    # @POST: main, dev, preprod are available in local repository and pushed to origin when available.
    # @RETURN: None
    def _ensure_gitflow_branches(self, repo: Repo, dashboard_id: int) -> None:
        with belief_scope("GitService._ensure_gitflow_branches"):
            required_branches = ["main", "dev", "preprod"]
            local_heads = {head.name: head for head in getattr(repo, "heads", [])}

            base_commit = None
            try:
                base_commit = repo.head.commit
            except Exception:
                base_commit = None

            if "main" in local_heads:
                base_commit = local_heads["main"].commit

            if base_commit is None:
                logger.warning(
                    f"[_ensure_gitflow_branches][Action] Skipping branch bootstrap for dashboard {dashboard_id}: repository has no commits"
                )
                return

            if "main" not in local_heads:
                local_heads["main"] = repo.create_head("main", base_commit)
                logger.info(f"[_ensure_gitflow_branches][Action] Created local branch main for dashboard {dashboard_id}")

            for branch_name in ("dev", "preprod"):
                if branch_name in local_heads:
                    continue
                local_heads[branch_name] = repo.create_head(branch_name, local_heads["main"].commit)
                logger.info(
                    f"[_ensure_gitflow_branches][Action] Created local branch {branch_name} for dashboard {dashboard_id}"
                )

            try:
                origin = repo.remote(name="origin")
            except ValueError:
                logger.info(
                    f"[_ensure_gitflow_branches][Action] Remote origin is not configured for dashboard {dashboard_id}; skipping remote branch creation"
                )
                return

            remote_branch_names = set()
            try:
                origin.fetch()
                for ref in origin.refs:
                    remote_head = getattr(ref, "remote_head", None)
                    if remote_head:
                        remote_branch_names.add(str(remote_head))
            except Exception as e:
                logger.warning(f"[_ensure_gitflow_branches][Action] Failed to fetch origin refs: {e}")

            for branch_name in required_branches:
                if branch_name in remote_branch_names:
                    continue
                try:
                    origin.push(refspec=f"{branch_name}:{branch_name}")
                    logger.info(
                        f"[_ensure_gitflow_branches][Action] Pushed branch {branch_name} to origin for dashboard {dashboard_id}"
                    )
                except Exception as e:
                    logger.error(
                        f"[_ensure_gitflow_branches][Coherence:Failed] Failed to push branch {branch_name} to origin: {e}"
                    )
                    raise HTTPException(
                        status_code=500,
                        detail=f"Failed to create default branch '{branch_name}' on remote: {str(e)}",
                    )

            # Keep default working branch on DEV for day-to-day changes.
            try:
                repo.git.checkout("dev")
                logger.info(
                    f"[_ensure_gitflow_branches][Action] Checked out default branch dev for dashboard {dashboard_id}"
                )
            except Exception as e:
                logger.warning(
                    f"[_ensure_gitflow_branches][Action] Could not checkout dev branch for dashboard {dashboard_id}: {e}"
                )
    # [/DEF:_ensure_gitflow_branches:Function]

    # [DEF:_get_repo_path:Function]
    # @PURPOSE: Resolves the local filesystem path for a dashboard's repository.
    # @PARAM: dashboard_id (int)
    # @PARAM: repo_key (Optional[str]) - Slug-like key used when DB local_path is absent.
    # @PRE: dashboard_id is an integer.
    # @POST: Returns DB-local_path when present, otherwise base_path/<normalized repo_key>.
    # @RETURN: str
    def _get_repo_path(self, dashboard_id: int, repo_key: Optional[str] = None) -> str:
        with belief_scope("GitService._get_repo_path"):
            if dashboard_id is None:
                raise ValueError("dashboard_id cannot be None")
            self._ensure_base_path_exists()
            fallback_key = repo_key if repo_key is not None else str(dashboard_id)
            normalized_key = self._normalize_repo_key(fallback_key)
            target_path = os.path.join(self.base_path, normalized_key)

            try:
                session = SessionLocal()
                try:
                    db_repo = (
                        session.query(GitRepository)
                        .filter(GitRepository.dashboard_id == int(dashboard_id))
                        .first()
                    )
                finally:
                    session.close()
                if db_repo and db_repo.local_path:
                    db_path = os.path.abspath(db_repo.local_path)
                    if os.path.exists(db_path):
                        if (
                            os.path.abspath(self.base_path) != os.path.abspath(self.legacy_base_path)
                            and db_path.startswith(os.path.abspath(self.legacy_base_path) + os.sep)
                        ):
                            return self._migrate_repo_directory(dashboard_id, db_path, target_path)
                        return db_path
            except Exception as e:
                logger.warning(f"[_get_repo_path][Coherence:Failed] Could not resolve local_path from DB: {e}")

            legacy_id_path = os.path.join(self.legacy_base_path, str(dashboard_id))
            if os.path.exists(legacy_id_path) and not os.path.exists(target_path):
                return self._migrate_repo_directory(dashboard_id, legacy_id_path, target_path)

            if os.path.exists(target_path):
                self._update_repo_local_path(dashboard_id, target_path)

            return target_path
    # [/DEF:_get_repo_path:Function]

    # [DEF:init_repo:Function]
    # @PURPOSE: Initialize or clone a repository for a dashboard.
    # @PARAM: dashboard_id (int)
    # @PARAM: remote_url (str)
    # @PARAM: pat (str) - Personal Access Token for authentication.
    # @PARAM: repo_key (Optional[str]) - Slug-like key for deterministic folder naming on first init.
    # @PRE: dashboard_id is int, remote_url is valid Git URL, pat is provided.
    # @POST: Repository is cloned or opened at the local path.
    # @RETURN: Repo - GitPython Repo object.
    def init_repo(self, dashboard_id: int, remote_url: str, pat: str, repo_key: Optional[str] = None) -> Repo:
        with belief_scope("GitService.init_repo"):
            self._ensure_base_path_exists()
            repo_path = self._get_repo_path(dashboard_id, repo_key=repo_key or str(dashboard_id))
            Path(repo_path).parent.mkdir(parents=True, exist_ok=True)
            
            # Inject PAT into remote URL if needed
            if pat and "://" in remote_url:
                proto, rest = remote_url.split("://", 1)
                auth_url = f"{proto}://oauth2:{pat}@{rest}"
            else:
                auth_url = remote_url

            if os.path.exists(repo_path):
                logger.info(f"[init_repo][Action] Opening existing repo at {repo_path}")
                try:
                    repo = Repo(repo_path)
                except (InvalidGitRepositoryError, NoSuchPathError):
                    logger.warning(
                        f"[init_repo][Action] Existing path is not a Git repository, recreating: {repo_path}"
                    )
                    if os.path.isdir(repo_path):
                        shutil.rmtree(repo_path)
                    else:
                        os.remove(repo_path)
                    repo = Repo.clone_from(auth_url, repo_path)
                self._ensure_gitflow_branches(repo, dashboard_id)
                return repo
            
            logger.info(f"[init_repo][Action] Cloning {remote_url} to {repo_path}")
            repo = Repo.clone_from(auth_url, repo_path)
            self._ensure_gitflow_branches(repo, dashboard_id)
            return repo
    # [/DEF:init_repo:Function]

    # [DEF:delete_repo:Function]
    # @PURPOSE: Remove local repository and DB binding for a dashboard.
    # @PRE: dashboard_id is a valid integer.
    # @POST: Local path is deleted when present and GitRepository row is removed.
    # @RETURN: None
    def delete_repo(self, dashboard_id: int) -> None:
        with belief_scope("GitService.delete_repo"):
            repo_path = self._get_repo_path(dashboard_id)
            removed_files = False
            if os.path.exists(repo_path):
                if os.path.isdir(repo_path):
                    shutil.rmtree(repo_path)
                else:
                    os.remove(repo_path)
                removed_files = True

            session = SessionLocal()
            try:
                db_repo = (
                    session.query(GitRepository)
                    .filter(GitRepository.dashboard_id == int(dashboard_id))
                    .first()
                )
                if db_repo:
                    session.delete(db_repo)
                    session.commit()
                    return

                if removed_files:
                    return

                raise HTTPException(
                    status_code=404,
                    detail=f"Repository for dashboard {dashboard_id} not found",
                )
            except HTTPException:
                session.rollback()
                raise
            except Exception as e:
                session.rollback()
                logger.error(
                    f"[delete_repo][Coherence:Failed] Failed to delete repository for dashboard {dashboard_id}: {e}"
                )
                raise HTTPException(status_code=500, detail=f"Failed to delete repository: {str(e)}")
            finally:
                session.close()
    # [/DEF:delete_repo:Function]

    # [DEF:get_repo:Function]
    # @PURPOSE: Get Repo object for a dashboard.
    # @PRE: Repository must exist on disk for the given dashboard_id.
    # @POST: Returns a GitPython Repo instance for the dashboard.
    # @RETURN: Repo
    def get_repo(self, dashboard_id: int) -> Repo:
        with belief_scope("GitService.get_repo"):
            repo_path = self._get_repo_path(dashboard_id)
            if not os.path.exists(repo_path):
                logger.error(f"[get_repo][Coherence:Failed] Repository for dashboard {dashboard_id} does not exist")
                raise HTTPException(status_code=404, detail=f"Repository for dashboard {dashboard_id} not found")
            try:
                return Repo(repo_path)
            except Exception as e:
                logger.error(f"[get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}")
                raise HTTPException(status_code=500, detail="Failed to open local Git repository")
    # [/DEF:get_repo:Function]

    # [DEF:configure_identity:Function]
    # @PURPOSE: Configure repository-local Git committer identity for user-scoped operations.
    # @PRE: dashboard_id repository exists; git_username/git_email may be empty.
    # @POST: Repository config has user.name and user.email when both identity values are provided.
    # @RETURN: None
    def configure_identity(
        self,
        dashboard_id: int,
        git_username: Optional[str],
        git_email: Optional[str],
    ) -> None:
        with belief_scope("GitService.configure_identity"):
            normalized_username = str(git_username or "").strip()
            normalized_email = str(git_email or "").strip()
            if not normalized_username or not normalized_email:
                return

            repo = self.get_repo(dashboard_id)
            try:
                with repo.config_writer(config_level="repository") as config_writer:
                    config_writer.set_value("user", "name", normalized_username)
                    config_writer.set_value("user", "email", normalized_email)
                logger.info(
                    "[configure_identity][Action] Applied repository-local git identity for dashboard %s",
                    dashboard_id,
                )
            except Exception as e:
                logger.error(f"[configure_identity][Coherence:Failed] Failed to configure git identity: {e}")
                raise HTTPException(status_code=500, detail=f"Failed to configure git identity: {str(e)}")
    # [/DEF:configure_identity:Function]

    # [DEF:list_branches:Function]
    # @PURPOSE: List all branches for a dashboard's repository.
    # @PRE: Repository for dashboard_id exists.
    # @POST: Returns a list of branch metadata dictionaries.
    # @RETURN: List[dict]
    def list_branches(self, dashboard_id: int) -> List[dict]:
        with belief_scope("GitService.list_branches"):
            repo = self.get_repo(dashboard_id)
            logger.info(f"[list_branches][Action] Listing branches for {dashboard_id}. Refs: {repo.refs}")
            branches = []
            
            # Add existing refs
            for ref in repo.refs:
                try:
                    # Strip prefixes for UI
                    name = ref.name.replace('refs/heads/', '').replace('refs/remotes/origin/', '')
                    
                    # Avoid duplicates (e.g. local and remote with same name)
                    if any(b['name'] == name for b in branches):
                        continue
                        
                    branches.append({
                        "name": name,
                        "commit_hash": ref.commit.hexsha if hasattr(ref, 'commit') else "0000000",
                        "is_remote": ref.is_remote() if hasattr(ref, 'is_remote') else False,
                        "last_updated": datetime.fromtimestamp(ref.commit.committed_date) if hasattr(ref, 'commit') else datetime.utcnow()
                    })
                except Exception as e:
                    logger.warning(f"[list_branches][Action] Skipping ref {ref}: {e}")

            # Ensure the current active branch is in the list even if it has no commits or refs
            try:
                active_name = repo.active_branch.name
                if not any(b['name'] == active_name for b in branches):
                    branches.append({
                        "name": active_name,
                        "commit_hash": "0000000",
                        "is_remote": False,
                        "last_updated": datetime.utcnow()
                    })
            except Exception as e:
                logger.warning(f"[list_branches][Action] Could not determine active branch: {e}")
                # If everything else failed and list is still empty, add default
                if not branches:
                    branches.append({
                        "name": "dev",
                        "commit_hash": "0000000",
                        "is_remote": False,
                        "last_updated": datetime.utcnow()
                    })

            return branches
    # [/DEF:list_branches:Function]

    # [DEF:create_branch:Function]
    # @PURPOSE: Create a new branch from an existing one.
    # @PARAM: name (str) - New branch name.
    # @PARAM: from_branch (str) - Source branch.
    # @PRE: Repository exists; name is valid; from_branch exists or repo is empty.
    # @POST: A new branch is created in the repository.
    def create_branch(self, dashboard_id: int, name: str, from_branch: str = "main"):
        with belief_scope("GitService.create_branch"):
            repo = self.get_repo(dashboard_id)
            logger.info(f"[create_branch][Action] Creating branch {name} from {from_branch}")
            
            # Handle empty repository case (no commits)
            if not repo.heads and not repo.remotes:
                logger.warning("[create_branch][Action] Repository is empty. Creating initial commit to enable branching.")
                readme_path = os.path.join(repo.working_dir, "README.md")
                if not os.path.exists(readme_path):
                    with open(readme_path, "w") as f:
                        f.write(f"# Dashboard {dashboard_id}\nGit repository for Superset dashboard integration.")
                repo.index.add(["README.md"])
                repo.index.commit("Initial commit")
            
            # Verify source branch exists
            try:
                repo.commit(from_branch)
            except Exception:
                logger.warning(f"[create_branch][Action] Source branch {from_branch} not found, using HEAD")
                from_branch = repo.head

            try:
                new_branch = repo.create_head(name, from_branch)
                return new_branch
            except Exception as e:
                logger.error(f"[create_branch][Coherence:Failed] {e}")
                raise
    # [/DEF:create_branch:Function]

    # [DEF:checkout_branch:Function]
    # @PURPOSE: Switch to a specific branch.
    # @PRE: Repository exists and the specified branch name exists.
    # @POST: The repository working directory is updated to the specified branch.
    def checkout_branch(self, dashboard_id: int, name: str):
        with belief_scope("GitService.checkout_branch"):
            repo = self.get_repo(dashboard_id)
            logger.info(f"[checkout_branch][Action] Checking out branch {name}")
            repo.git.checkout(name)
    # [/DEF:checkout_branch:Function]

    # [DEF:commit_changes:Function]
    # @PURPOSE: Stage and commit changes.
    # @PARAM: message (str) - Commit message.
    # @PARAM: files (List[str]) - Optional list of specific files to stage.
    # @PRE: Repository exists and has changes (dirty) or files are specified.
    # @POST: Changes are staged and a new commit is created.
    def commit_changes(self, dashboard_id: int, message: str, files: List[str] = None):
        with belief_scope("GitService.commit_changes"):
            repo = self.get_repo(dashboard_id)
            
            # Check if there are any changes to commit
            if not repo.is_dirty(untracked_files=True) and not files:
                logger.info(f"[commit_changes][Action] No changes to commit for dashboard {dashboard_id}")
                return

            if files:
                logger.info(f"[commit_changes][Action] Staging files: {files}")
                repo.index.add(files)
            else:
                logger.info("[commit_changes][Action] Staging all changes")
                repo.git.add(A=True)
                
            repo.index.commit(message)
            logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}")
    # [/DEF:commit_changes:Function]

    # [DEF:_extract_http_host:Function]
    # @PURPOSE: Extract normalized host[:port] from HTTP(S) URL.
    # @PRE: url_value may be empty.
    # @POST: Returns lowercase host token or None.
    # @RETURN: Optional[str]
    def _extract_http_host(self, url_value: Optional[str]) -> Optional[str]:
        normalized = str(url_value or "").strip()
        if not normalized:
            return None
        try:
            parsed = urlparse(normalized)
        except Exception:
            return None
        if parsed.scheme not in {"http", "https"}:
            return None
        host = parsed.hostname
        if not host:
            return None
        if parsed.port:
            return f"{host.lower()}:{parsed.port}"
        return host.lower()
    # [/DEF:_extract_http_host:Function]

    # [DEF:_strip_url_credentials:Function]
    # @PURPOSE: Remove credentials from URL while preserving scheme/host/path.
    # @PRE: url_value may contain credentials.
    # @POST: Returns URL without username/password.
    # @RETURN: str
    def _strip_url_credentials(self, url_value: str) -> str:
        normalized = str(url_value or "").strip()
        if not normalized:
            return normalized
        try:
            parsed = urlparse(normalized)
        except Exception:
            return normalized
        if parsed.scheme not in {"http", "https"} or not parsed.hostname:
            return normalized
        host = parsed.hostname
        if parsed.port:
            host = f"{host}:{parsed.port}"
        return parsed._replace(netloc=host).geturl()
    # [/DEF:_strip_url_credentials:Function]

    # [DEF:_replace_host_in_url:Function]
    # @PURPOSE: Replace source URL host with host from configured server URL.
    # @PRE: source_url and config_url are HTTP(S) URLs.
    # @POST: Returns source URL with updated host (credentials preserved) or None.
    # @RETURN: Optional[str]
    def _replace_host_in_url(self, source_url: Optional[str], config_url: Optional[str]) -> Optional[str]:
        source = str(source_url or "").strip()
        config = str(config_url or "").strip()
        if not source or not config:
            return None
        try:
            source_parsed = urlparse(source)
            config_parsed = urlparse(config)
        except Exception:
            return None

        if source_parsed.scheme not in {"http", "https"}:
            return None
        if config_parsed.scheme not in {"http", "https"}:
            return None
        if not source_parsed.hostname or not config_parsed.hostname:
            return None

        target_host = config_parsed.hostname
        if config_parsed.port:
            target_host = f"{target_host}:{config_parsed.port}"

        auth_part = ""
        if source_parsed.username:
            auth_part = quote(source_parsed.username, safe="")
            if source_parsed.password is not None:
                auth_part = f"{auth_part}:{quote(source_parsed.password, safe='')}"
            auth_part = f"{auth_part}@"

        new_netloc = f"{auth_part}{target_host}"
        return source_parsed._replace(netloc=new_netloc).geturl()
    # [/DEF:_replace_host_in_url:Function]

    # [DEF:_align_origin_host_with_config:Function]
    # @PURPOSE: Auto-align local origin host to configured Git server host when they drift.
    # @PRE: origin remote exists.
    # @POST: origin URL host updated and DB binding normalized when mismatch detected.
    # @RETURN: Optional[str]
    def _align_origin_host_with_config(
        self,
        dashboard_id: int,
        origin,
        config_url: Optional[str],
        current_origin_url: Optional[str],
        binding_remote_url: Optional[str],
    ) -> Optional[str]:
        config_host = self._extract_http_host(config_url)
        source_origin_url = str(current_origin_url or "").strip() or str(binding_remote_url or "").strip()
        origin_host = self._extract_http_host(source_origin_url)

        if not config_host or not origin_host:
            return None
        if config_host == origin_host:
            return None

        aligned_url = self._replace_host_in_url(source_origin_url, config_url)
        if not aligned_url:
            return None

        logger.warning(
            "[_align_origin_host_with_config][Action] Host mismatch for dashboard %s: config_host=%s origin_host=%s, applying origin.set_url",
            dashboard_id,
            config_host,
            origin_host,
        )

        try:
            origin.set_url(aligned_url)
        except Exception as e:
            logger.warning(
                "[_align_origin_host_with_config][Coherence:Failed] Failed to set origin URL for dashboard %s: %s",
                dashboard_id,
                e,
            )
            return None

        try:
            session = SessionLocal()
            try:
                db_repo = (
                    session.query(GitRepository)
                    .filter(GitRepository.dashboard_id == int(dashboard_id))
                    .first()
                )
                if db_repo:
                    db_repo.remote_url = self._strip_url_credentials(aligned_url)
                    session.commit()
            finally:
                session.close()
        except Exception as e:
            logger.warning(
                "[_align_origin_host_with_config][Action] Failed to persist aligned remote_url for dashboard %s: %s",
                dashboard_id,
                e,
            )

        return aligned_url
    # [/DEF:_align_origin_host_with_config:Function]

    # [DEF:push_changes:Function]
    # @PURPOSE: Push local commits to remote.
    # @PRE: Repository exists and has an 'origin' remote.
    # @POST: Local branch commits are pushed to origin.
    def push_changes(self, dashboard_id: int):
        with belief_scope("GitService.push_changes"):
            repo = self.get_repo(dashboard_id)
            
            # Ensure we have something to push
            if not repo.heads:
                logger.warning(f"[push_changes][Coherence:Failed] No local branches to push for dashboard {dashboard_id}")
                return

            try:
                origin = repo.remote(name='origin')
            except ValueError:
                logger.error(f"[push_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
                raise HTTPException(status_code=400, detail="Remote 'origin' not configured")

            # Emit diagnostic context to verify config-url vs repository-origin mismatch.
            try:
                origin_urls = list(origin.urls)
            except Exception:
                origin_urls = []
            binding_remote_url = None
            binding_config_id = None
            binding_config_url = None
            try:
                session = SessionLocal()
                try:
                    db_repo = (
                        session.query(GitRepository)
                        .filter(GitRepository.dashboard_id == int(dashboard_id))
                        .first()
                    )
                    if db_repo:
                        binding_remote_url = db_repo.remote_url
                        binding_config_id = db_repo.config_id
                        db_config = (
                            session.query(GitServerConfig)
                            .filter(GitServerConfig.id == db_repo.config_id)
                            .first()
                        )
                        if db_config:
                            binding_config_url = db_config.url
                finally:
                    session.close()
            except Exception as diag_error:
                logger.warning(
                    "[push_changes][Action] Failed to load repository binding diagnostics for dashboard %s: %s",
                    dashboard_id,
                    diag_error,
                )

            realigned_origin_url = self._align_origin_host_with_config(
                dashboard_id=dashboard_id,
                origin=origin,
                config_url=binding_config_url,
                current_origin_url=(origin_urls[0] if origin_urls else None),
                binding_remote_url=binding_remote_url,
            )
            try:
                origin_urls = list(origin.urls)
            except Exception:
                origin_urls = []

            logger.info(
                "[push_changes][Action] Push diagnostics dashboard=%s config_id=%s config_url=%s binding_remote_url=%s origin_urls=%s origin_realigned=%s",
                dashboard_id,
                binding_config_id,
                binding_config_url,
                binding_remote_url,
                origin_urls,
                bool(realigned_origin_url),
            )

            # Check if current branch has an upstream
            try:
                current_branch = repo.active_branch
                logger.info(f"[push_changes][Action] Pushing branch {current_branch.name} to origin")
                tracking_branch = None
                try:
                    tracking_branch = current_branch.tracking_branch()
                except Exception:
                    tracking_branch = None

                # First push for a new branch must set upstream, otherwise future pull fails.
                if tracking_branch is None:
                    repo.git.push("--set-upstream", "origin", f"{current_branch.name}:{current_branch.name}")
                else:
                    push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}')
                    for info in push_info:
                        if info.flags & info.ERROR:
                            logger.error(f"[push_changes][Coherence:Failed] Error pushing ref {info.remote_ref_string}: {info.summary}")
                            raise Exception(f"Git push error for {info.remote_ref_string}: {info.summary}")
            except GitCommandError as e:
                details = str(e)
                lowered = details.lower()
                if "non-fast-forward" in lowered or "rejected" in lowered:
                    raise HTTPException(
                        status_code=409,
                        detail=(
                            "Push rejected: remote branch contains newer commits. "
                            "Run Pull first, resolve conflicts if any, then push again."
                        ),
                    )
                logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}")
                raise HTTPException(status_code=500, detail=f"Git push failed: {details}")
            except Exception as e:
                logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}")
                raise HTTPException(status_code=500, detail=f"Git push failed: {str(e)}")
    # [/DEF:push_changes:Function]

    # [DEF:pull_changes:Function]
    # @PURPOSE: Pull changes from remote.
    # @PRE: Repository exists and has an 'origin' remote.
    # @POST: Changes from origin are pulled and merged into the active branch.
    def _read_blob_text(self, blob: Blob) -> str:
        with belief_scope("GitService._read_blob_text"):
            if blob is None:
                return ""
            try:
                return blob.data_stream.read().decode("utf-8", errors="replace")
            except Exception:
                return ""

    def _get_unmerged_file_paths(self, repo: Repo) -> List[str]:
        with belief_scope("GitService._get_unmerged_file_paths"):
            try:
                return sorted(list(repo.index.unmerged_blobs().keys()))
            except Exception:
                return []

    def _build_unfinished_merge_payload(self, repo: Repo) -> Dict[str, Any]:
        with belief_scope("GitService._build_unfinished_merge_payload"):
            merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
            merge_head_value = ""
            merge_msg_preview = ""
            current_branch = "unknown"
            try:
                merge_head_value = Path(merge_head_path).read_text(encoding="utf-8").strip()
            except Exception:
                merge_head_value = "<unreadable>"
            try:
                merge_msg_path = os.path.join(repo.git_dir, "MERGE_MSG")
                if os.path.exists(merge_msg_path):
                    merge_msg_preview = (
                        Path(merge_msg_path).read_text(encoding="utf-8").strip().splitlines()[:1] or [""]
                    )[0]
            except Exception:
                merge_msg_preview = "<unreadable>"
            try:
                current_branch = repo.active_branch.name
            except Exception:
                current_branch = "detached_or_unknown"

            conflicts_count = len(self._get_unmerged_file_paths(repo))
            return {
                "error_code": "GIT_UNFINISHED_MERGE",
                "message": (
                    "В репозитории есть незавершённое слияние. "
                    "Завершите или отмените слияние вручную."
                ),
                "repository_path": repo.working_tree_dir,
                "git_dir": repo.git_dir,
                "current_branch": current_branch,
                "merge_head": merge_head_value,
                "merge_message_preview": merge_msg_preview,
                "conflicts_count": conflicts_count,
                "next_steps": [
                    "Откройте локальный репозиторий по пути repository_path",
                    "Проверьте состояние: git status",
                    "Разрешите конфликты и выполните commit, либо отмените: git merge --abort",
                    "После завершения/отмены слияния повторите Pull из интерфейса",
                ],
                "manual_commands": [
                    "git status",
                    "git add <resolved-files>",
                    "git commit -m \"resolve merge conflicts\"",
                    "git merge --abort",
                ],
            }

    def get_merge_status(self, dashboard_id: int) -> Dict[str, Any]:
        with belief_scope("GitService.get_merge_status"):
            repo = self.get_repo(dashboard_id)
            merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
            if not os.path.exists(merge_head_path):
                current_branch = "unknown"
                try:
                    current_branch = repo.active_branch.name
                except Exception:
                    current_branch = "detached_or_unknown"
                return {
                    "has_unfinished_merge": False,
                    "repository_path": repo.working_tree_dir,
                    "git_dir": repo.git_dir,
                    "current_branch": current_branch,
                    "merge_head": None,
                    "merge_message_preview": None,
                    "conflicts_count": 0,
                }
            payload = self._build_unfinished_merge_payload(repo)
            return {
                "has_unfinished_merge": True,
                "repository_path": payload["repository_path"],
                "git_dir": payload["git_dir"],
                "current_branch": payload["current_branch"],
                "merge_head": payload["merge_head"],
                "merge_message_preview": payload["merge_message_preview"],
                "conflicts_count": int(payload.get("conflicts_count") or 0),
            }

    def get_merge_conflicts(self, dashboard_id: int) -> List[Dict[str, Any]]:
        with belief_scope("GitService.get_merge_conflicts"):
            repo = self.get_repo(dashboard_id)
            conflicts = []
            unmerged = repo.index.unmerged_blobs()
            for file_path, stages in unmerged.items():
                mine_blob = None
                theirs_blob = None
                for stage, blob in stages:
                    if stage == 2:
                        mine_blob = blob
                    elif stage == 3:
                        theirs_blob = blob
                conflicts.append(
                    {
                        "file_path": file_path,
                        "mine": self._read_blob_text(mine_blob) if mine_blob else "",
                        "theirs": self._read_blob_text(theirs_blob) if theirs_blob else "",
                    }
                )
            return sorted(conflicts, key=lambda item: item["file_path"])

    def resolve_merge_conflicts(self, dashboard_id: int, resolutions: List[Dict[str, Any]]) -> List[str]:
        with belief_scope("GitService.resolve_merge_conflicts"):
            repo = self.get_repo(dashboard_id)
            resolved_files: List[str] = []
            repo_root = os.path.abspath(str(repo.working_tree_dir or ""))
            if not repo_root:
                raise HTTPException(status_code=500, detail="Repository working tree directory is unavailable")

            for item in resolutions or []:
                file_path = str(item.get("file_path") or "").strip()
                strategy = str(item.get("resolution") or "").strip().lower()
                content = item.get("content")
                if not file_path:
                    raise HTTPException(status_code=400, detail="resolution.file_path is required")
                if strategy not in {"mine", "theirs", "manual"}:
                    raise HTTPException(status_code=400, detail=f"Unsupported resolution strategy: {strategy}")

                if strategy == "mine":
                    repo.git.checkout("--ours", "--", file_path)
                elif strategy == "theirs":
                    repo.git.checkout("--theirs", "--", file_path)
                else:
                    abs_target = os.path.abspath(os.path.join(repo_root, file_path))
                    if abs_target != repo_root and not abs_target.startswith(repo_root + os.sep):
                        raise HTTPException(status_code=400, detail=f"Invalid conflict file path: {file_path}")
                    os.makedirs(os.path.dirname(abs_target), exist_ok=True)
                    with open(abs_target, "w", encoding="utf-8") as file_obj:
                        file_obj.write(str(content or ""))

                repo.git.add(file_path)
                resolved_files.append(file_path)

            return resolved_files

    def abort_merge(self, dashboard_id: int) -> Dict[str, Any]:
        with belief_scope("GitService.abort_merge"):
            repo = self.get_repo(dashboard_id)
            try:
                repo.git.merge("--abort")
            except GitCommandError as e:
                details = str(e)
                lowered = details.lower()
                if "there is no merge to abort" in lowered or "no merge to abort" in lowered:
                    return {"status": "no_merge_in_progress"}
                raise HTTPException(status_code=409, detail=f"Cannot abort merge: {details}")
            return {"status": "aborted"}

    def continue_merge(self, dashboard_id: int, message: Optional[str] = None) -> Dict[str, Any]:
        with belief_scope("GitService.continue_merge"):
            repo = self.get_repo(dashboard_id)
            unmerged_files = self._get_unmerged_file_paths(repo)
            if unmerged_files:
                raise HTTPException(
                    status_code=409,
                    detail={
                        "error_code": "GIT_MERGE_CONFLICTS_REMAIN",
                        "message": "Невозможно завершить merge: остались неразрешённые конфликты.",
                        "unresolved_files": unmerged_files,
                    },
                )
            try:
                normalized_message = str(message or "").strip()
                if normalized_message:
                    repo.git.commit("-m", normalized_message)
                else:
                    repo.git.commit("--no-edit")
            except GitCommandError as e:
                details = str(e)
                lowered = details.lower()
                if "nothing to commit" in lowered:
                    return {"status": "already_clean"}
                raise HTTPException(status_code=409, detail=f"Cannot continue merge: {details}")

            commit_hash = ""
            try:
                commit_hash = repo.head.commit.hexsha
            except Exception:
                commit_hash = ""
            return {"status": "committed", "commit_hash": commit_hash}

    def pull_changes(self, dashboard_id: int):
        with belief_scope("GitService.pull_changes"):
            repo = self.get_repo(dashboard_id)
            
            # Check for unfinished merge (MERGE_HEAD exists)
            merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
            if os.path.exists(merge_head_path):
                payload = self._build_unfinished_merge_payload(repo)

                logger.warning(
                    "[pull_changes][Action] Unfinished merge detected for dashboard %s "
                    "(repo_path=%s git_dir=%s branch=%s merge_head=%s merge_msg=%s)",
                    dashboard_id,
                    payload["repository_path"],
                    payload["git_dir"],
                    payload["current_branch"],
                    payload["merge_head"],
                    payload["merge_message_preview"],
                )
                raise HTTPException(status_code=409, detail=payload)
            
            try:
                origin = repo.remote(name='origin')
                current_branch = repo.active_branch.name
                try:
                    origin_urls = list(origin.urls)
                except Exception:
                    origin_urls = []

                logger.info(
                    "[pull_changes][Action] Pull diagnostics dashboard=%s repo_path=%s branch=%s origin_urls=%s",
                    dashboard_id,
                    repo.working_tree_dir,
                    current_branch,
                    origin_urls,
                )

                origin.fetch(prune=True)
                remote_ref = f"origin/{current_branch}"
                has_remote_branch = any(ref.name == remote_ref for ref in repo.refs)
                logger.info(
                    "[pull_changes][Action] Pull remote branch check dashboard=%s branch=%s remote_ref=%s exists=%s",
                    dashboard_id,
                    current_branch,
                    remote_ref,
                    has_remote_branch,
                )
                if not has_remote_branch:
                    raise HTTPException(
                        status_code=409,
                        detail=f"Remote branch '{current_branch}' does not exist yet. Push this branch first.",
                    )

                logger.info(f"[pull_changes][Action] Pulling changes from origin/{current_branch}")
                # Force deterministic merge strategy for modern git versions.
                repo.git.pull("--no-rebase", "origin", current_branch)
            except ValueError:
                logger.error(f"[pull_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
                raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
            except GitCommandError as e:
                details = str(e)
                lowered = details.lower()
                if "conflict" in lowered or "not possible to fast-forward" in lowered:
                    raise HTTPException(
                        status_code=409,
                        detail=(
                            "Pull requires conflict resolution. Resolve conflicts in repository "
                            "and repeat operation."
                        ),
                    )
                logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}")
                raise HTTPException(status_code=500, detail=f"Git pull failed: {details}")
            except HTTPException:
                raise
            except Exception as e:
                logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}")
                raise HTTPException(status_code=500, detail=f"Git pull failed: {str(e)}")
    # [/DEF:pull_changes:Function]

    # [DEF:get_status:Function]
    # @PURPOSE: Get current repository status (dirty files, untracked, etc.)
    # @PRE: Repository for dashboard_id exists.
    # @POST: Returns a dictionary representing the Git status.
    # @RETURN: dict
    def get_status(self, dashboard_id: int) -> dict:
        with belief_scope("GitService.get_status"):
            repo = self.get_repo(dashboard_id)
            
            # Handle empty repository (no commits)
            has_commits = False
            try:
                repo.head.commit
                has_commits = True
            except (ValueError, Exception):
                has_commits = False

            current_branch = repo.active_branch.name
            tracking_branch = None
            has_upstream = False
            ahead_count = 0
            behind_count = 0

            try:
                tracking_branch = repo.active_branch.tracking_branch()
                has_upstream = tracking_branch is not None
            except Exception:
                tracking_branch = None
                has_upstream = False

            if has_upstream and tracking_branch is not None:
                try:
                    # Commits present locally but not in upstream.
                    ahead_count = sum(
                        1 for _ in repo.iter_commits(f"{tracking_branch.name}..{current_branch}")
                    )
                    # Commits present in upstream but not local.
                    behind_count = sum(
                        1 for _ in repo.iter_commits(f"{current_branch}..{tracking_branch.name}")
                    )
                except Exception:
                    ahead_count = 0
                    behind_count = 0

            is_dirty = repo.is_dirty(untracked_files=True)
            untracked_files = repo.untracked_files
            modified_files = [item.a_path for item in repo.index.diff(None)]
            staged_files = [item.a_path for item in repo.index.diff("HEAD")] if has_commits else []
            is_diverged = ahead_count > 0 and behind_count > 0

            if is_diverged:
                sync_state = "DIVERGED"
            elif behind_count > 0:
                sync_state = "BEHIND_REMOTE"
            elif ahead_count > 0:
                sync_state = "AHEAD_REMOTE"
            elif is_dirty or modified_files or staged_files or untracked_files:
                sync_state = "CHANGES"
            else:
                sync_state = "SYNCED"

            return {
                "is_dirty": is_dirty,
                "untracked_files": untracked_files,
                "modified_files": modified_files,
                "staged_files": staged_files,
                "current_branch": current_branch,
                "upstream_branch": tracking_branch.name if tracking_branch is not None else None,
                "has_upstream": has_upstream,
                "ahead_count": ahead_count,
                "behind_count": behind_count,
                "is_diverged": is_diverged,
                "sync_state": sync_state,
            }
    # [/DEF:get_status:Function]

    # [DEF:get_diff:Function]
    # @PURPOSE: Generate diff for a file or the whole repository.
    # @PARAM: file_path (str) - Optional specific file.
    # @PARAM: staged (bool) - Whether to show staged changes.
    # @PRE: Repository for dashboard_id exists.
    # @POST: Returns the diff text as a string.
    # @RETURN: str
    def get_diff(self, dashboard_id: int, file_path: str = None, staged: bool = False) -> str:
        with belief_scope("GitService.get_diff"):
            repo = self.get_repo(dashboard_id)
            diff_args = []
            if staged:
                diff_args.append("--staged")
            
            if file_path:
                return repo.git.diff(*diff_args, "--", file_path)
            return repo.git.diff(*diff_args)
    # [/DEF:get_diff:Function]

    # [DEF:get_commit_history:Function]
    # @PURPOSE: Retrieve commit history for a repository.
    # @PARAM: limit (int) - Max number of commits to return.
    # @PRE: Repository for dashboard_id exists.
    # @POST: Returns a list of dictionaries for each commit in history.
    # @RETURN: List[dict]
    def get_commit_history(self, dashboard_id: int, limit: int = 50) -> List[dict]:
        with belief_scope("GitService.get_commit_history"):
            repo = self.get_repo(dashboard_id)
            commits = []
            try:
                # Check if there are any commits at all
                if not repo.heads and not repo.remotes:
                    return []
                
                for commit in repo.iter_commits(max_count=limit):
                    commits.append({
                        "hash": commit.hexsha,
                        "author": commit.author.name,
                        "email": commit.author.email,
                        "timestamp": datetime.fromtimestamp(commit.committed_date),
                        "message": commit.message.strip(),
                        "files_changed": list(commit.stats.files.keys())
                    })
            except Exception as e:
                logger.warning(f"[get_commit_history][Action] Could not retrieve commit history for dashboard {dashboard_id}: {e}")
                return []
            return commits
    # [/DEF:get_commit_history:Function]

    # [DEF:test_connection:Function]
    # @PURPOSE: Test connection to Git provider using PAT.
    # @PARAM: provider (GitProvider)
    # @PARAM: url (str)
    # @PARAM: pat (str)
    # @PRE: provider is valid; url is a valid HTTP(S) URL; pat is provided.
    # @POST: Returns True if connection to the provider's API succeeds.
    # @RETURN: bool
    async def test_connection(self, provider: GitProvider, url: str, pat: str) -> bool:
        with belief_scope("GitService.test_connection"):
            # Check for offline mode or local-only URLs
            if ".local" in url or "localhost" in url:
                logger.info("[test_connection][Action] Local/Offline mode detected for URL")
                return True

            if not url.startswith(('http://', 'https://')):
                logger.error(f"[test_connection][Coherence:Failed] Invalid URL protocol: {url}")
                return False

            if not pat or not pat.strip():
                logger.error("[test_connection][Coherence:Failed] Git PAT is missing or empty")
                return False

            pat = pat.strip()

            try:
                async with httpx.AsyncClient() as client:
                    if provider == GitProvider.GITHUB:
                        headers = {"Authorization": f"token {pat}"}
                        api_url = "https://api.github.com/user" if "github.com" in url else f"{url.rstrip('/')}/api/v3/user"
                        resp = await client.get(api_url, headers=headers)
                    elif provider == GitProvider.GITLAB:
                        headers = {"PRIVATE-TOKEN": pat}
                        api_url = f"{url.rstrip('/')}/api/v4/user"
                        resp = await client.get(api_url, headers=headers)
                    elif provider == GitProvider.GITEA:
                        headers = {"Authorization": f"token {pat}"}
                        api_url = f"{url.rstrip('/')}/api/v1/user"
                        resp = await client.get(api_url, headers=headers)
                    else:
                        return False
                    
                    if resp.status_code != 200:
                        logger.error(f"[test_connection][Coherence:Failed] Git connection test failed for {provider} at {api_url}. Status: {resp.status_code}")
                    return resp.status_code == 200
            except Exception as e:
                logger.error(f"[test_connection][Coherence:Failed] Error testing git connection: {e}")
                return False
    # [/DEF:test_connection:Function]

    # [DEF:_normalize_git_server_url:Function]
    # @PURPOSE: Normalize Git server URL for provider API calls.
    # @PRE: raw_url is non-empty.
    # @POST: Returns URL without trailing slash.
    # @RETURN: str
    def _normalize_git_server_url(self, raw_url: str) -> str:
        normalized = (raw_url or "").strip()
        if not normalized:
            raise HTTPException(status_code=400, detail="Git server URL is required")
        return normalized.rstrip("/")
    # [/DEF:_normalize_git_server_url:Function]

    # [DEF:_gitea_headers:Function]
    # @PURPOSE: Build Gitea API authorization headers.
    # @PRE: pat is provided.
    # @POST: Returns headers with token auth.
    # @RETURN: Dict[str, str]
    def _gitea_headers(self, pat: str) -> Dict[str, str]:
        token = (pat or "").strip()
        if not token:
            raise HTTPException(status_code=400, detail="Git PAT is required for Gitea operations")
        return {
            "Authorization": f"token {token}",
            "Content-Type": "application/json",
            "Accept": "application/json",
        }
    # [/DEF:_gitea_headers:Function]

    # [DEF:_gitea_request:Function]
    # @PURPOSE: Execute HTTP request against Gitea API with stable error mapping.
    # @PRE: method and endpoint are valid.
    # @POST: Returns decoded JSON payload.
    # @RETURN: Any
    async def _gitea_request(
        self,
        method: str,
        server_url: str,
        pat: str,
        endpoint: str,
        payload: Optional[Dict[str, Any]] = None,
    ) -> Any:
        base_url = self._normalize_git_server_url(server_url)
        url = f"{base_url}/api/v1{endpoint}"
        headers = self._gitea_headers(pat)
        try:
            async with httpx.AsyncClient(timeout=20.0) as client:
                response = await client.request(
                    method=method,
                    url=url,
                    headers=headers,
                    json=payload,
                )
        except Exception as e:
            logger.error(f"[gitea_request][Coherence:Failed] Network error: {e}")
            raise HTTPException(status_code=503, detail=f"Gitea API is unavailable: {str(e)}")

        if response.status_code >= 400:
            detail = response.text
            try:
                parsed = response.json()
                detail = parsed.get("message") or parsed.get("error") or detail
            except Exception:
                pass
            logger.error(
                f"[gitea_request][Coherence:Failed] method={method} endpoint={endpoint} status={response.status_code} detail={detail}"
            )
            raise HTTPException(
                status_code=response.status_code,
                detail=f"Gitea API error: {detail}",
            )

        if response.status_code == 204:
            return None
        return response.json()
    # [/DEF:_gitea_request:Function]

    # [DEF:get_gitea_current_user:Function]
    # @PURPOSE: Resolve current Gitea user for PAT.
    # @PRE: server_url and pat are valid.
    # @POST: Returns current username.
    # @RETURN: str
    async def get_gitea_current_user(self, server_url: str, pat: str) -> str:
        payload = await self._gitea_request("GET", server_url, pat, "/user")
        username = payload.get("login") or payload.get("username")
        if not username:
            raise HTTPException(status_code=500, detail="Failed to resolve Gitea username")
        return str(username)
    # [/DEF:get_gitea_current_user:Function]

    # [DEF:list_gitea_repositories:Function]
    # @PURPOSE: List repositories visible to authenticated Gitea user.
    # @PRE: server_url and pat are valid.
    # @POST: Returns repository list from Gitea.
    # @RETURN: List[dict]
    async def list_gitea_repositories(self, server_url: str, pat: str) -> List[dict]:
        payload = await self._gitea_request(
            "GET",
            server_url,
            pat,
            "/user/repos?limit=100&page=1",
        )
        if not isinstance(payload, list):
            return []
        return payload
    # [/DEF:list_gitea_repositories:Function]

    # [DEF:create_gitea_repository:Function]
    # @PURPOSE: Create repository in Gitea for authenticated user.
    # @PRE: name is non-empty and PAT has repo creation permission.
    # @POST: Returns created repository payload.
    # @RETURN: dict
    async def create_gitea_repository(
        self,
        server_url: str,
        pat: str,
        name: str,
        private: bool = True,
        description: Optional[str] = None,
        auto_init: bool = True,
        default_branch: Optional[str] = "main",
    ) -> Dict[str, Any]:
        payload = {
            "name": name,
            "private": bool(private),
            "auto_init": bool(auto_init),
        }
        if description:
            payload["description"] = description
        if default_branch:
            payload["default_branch"] = default_branch
        created = await self._gitea_request(
            "POST",
            server_url,
            pat,
            "/user/repos",
            payload=payload,
        )
        if not isinstance(created, dict):
            raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating repository")
        return created
    # [/DEF:create_gitea_repository:Function]

    # [DEF:delete_gitea_repository:Function]
    # @PURPOSE: Delete repository in Gitea.
    # @PRE: owner and repo_name are non-empty.
    # @POST: Repository deleted on Gitea server.
    async def delete_gitea_repository(
        self,
        server_url: str,
        pat: str,
        owner: str,
        repo_name: str,
    ) -> None:
        if not owner or not repo_name:
            raise HTTPException(status_code=400, detail="owner and repo_name are required")
        await self._gitea_request(
            "DELETE",
            server_url,
            pat,
            f"/repos/{owner}/{repo_name}",
        )
    # [/DEF:delete_gitea_repository:Function]

    # [DEF:_gitea_branch_exists:Function]
    # @PURPOSE: Check whether a branch exists in Gitea repository.
    # @PRE: owner/repo/branch are non-empty.
    # @POST: Returns True when branch exists, False when 404.
    # @RETURN: bool
    async def _gitea_branch_exists(
        self,
        server_url: str,
        pat: str,
        owner: str,
        repo: str,
        branch: str,
    ) -> bool:
        if not owner or not repo or not branch:
            return False
        endpoint = f"/repos/{owner}/{repo}/branches/{quote(branch, safe='')}"
        try:
            await self._gitea_request("GET", server_url, pat, endpoint)
            return True
        except HTTPException as exc:
            if exc.status_code == 404:
                return False
            raise
    # [/DEF:_gitea_branch_exists:Function]

    # [DEF:_build_gitea_pr_404_detail:Function]
    # @PURPOSE: Build actionable error detail for Gitea PR 404 responses.
    # @PRE: owner/repo/from_branch/to_branch are provided.
    # @POST: Returns specific branch-missing message when detected.
    # @RETURN: Optional[str]
    async def _build_gitea_pr_404_detail(
        self,
        server_url: str,
        pat: str,
        owner: str,
        repo: str,
        from_branch: str,
        to_branch: str,
    ) -> Optional[str]:
        source_exists = await self._gitea_branch_exists(
            server_url=server_url,
            pat=pat,
            owner=owner,
            repo=repo,
            branch=from_branch,
        )
        target_exists = await self._gitea_branch_exists(
            server_url=server_url,
            pat=pat,
            owner=owner,
            repo=repo,
            branch=to_branch,
        )
        if not source_exists:
            return f"Gitea branch not found: source branch '{from_branch}' in {owner}/{repo}"
        if not target_exists:
            return f"Gitea branch not found: target branch '{to_branch}' in {owner}/{repo}"
        return None
    # [/DEF:_build_gitea_pr_404_detail:Function]

    # [DEF:create_github_repository:Function]
    # @PURPOSE: Create repository in GitHub or GitHub Enterprise.
    # @PRE: PAT has repository create permission.
    # @POST: Returns created repository payload.
    # @RETURN: dict
    async def create_github_repository(
        self,
        server_url: str,
        pat: str,
        name: str,
        private: bool = True,
        description: Optional[str] = None,
        auto_init: bool = True,
        default_branch: Optional[str] = "main",
    ) -> Dict[str, Any]:
        base_url = self._normalize_git_server_url(server_url)
        if "github.com" in base_url:
            api_url = "https://api.github.com/user/repos"
        else:
            api_url = f"{base_url}/api/v3/user/repos"
        headers = {
            "Authorization": f"token {pat.strip()}",
            "Content-Type": "application/json",
            "Accept": "application/vnd.github+json",
        }
        payload: Dict[str, Any] = {
            "name": name,
            "private": bool(private),
            "auto_init": bool(auto_init),
        }
        if description:
            payload["description"] = description
        # GitHub API does not reliably support setting default branch on create without template/import.
        if default_branch:
            payload["default_branch"] = default_branch
        try:
            async with httpx.AsyncClient(timeout=20.0) as client:
                response = await client.post(api_url, headers=headers, json=payload)
        except Exception as e:
            raise HTTPException(status_code=503, detail=f"GitHub API is unavailable: {str(e)}")

        if response.status_code >= 400:
            detail = response.text
            try:
                parsed = response.json()
                detail = parsed.get("message") or detail
            except Exception:
                pass
            raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {detail}")
        return response.json()
    # [/DEF:create_github_repository:Function]

    # [DEF:create_gitlab_repository:Function]
    # @PURPOSE: Create repository(project) in GitLab.
    # @PRE: PAT has api scope.
    # @POST: Returns created repository payload.
    # @RETURN: dict
    async def create_gitlab_repository(
        self,
        server_url: str,
        pat: str,
        name: str,
        private: bool = True,
        description: Optional[str] = None,
        auto_init: bool = True,
        default_branch: Optional[str] = "main",
    ) -> Dict[str, Any]:
        base_url = self._normalize_git_server_url(server_url)
        api_url = f"{base_url}/api/v4/projects"
        headers = {
            "PRIVATE-TOKEN": pat.strip(),
            "Content-Type": "application/json",
            "Accept": "application/json",
        }
        payload: Dict[str, Any] = {
            "name": name,
            "visibility": "private" if private else "public",
            "initialize_with_readme": bool(auto_init),
        }
        if description:
            payload["description"] = description
        if default_branch:
            payload["default_branch"] = default_branch
        try:
            async with httpx.AsyncClient(timeout=20.0) as client:
                response = await client.post(api_url, headers=headers, json=payload)
        except Exception as e:
            raise HTTPException(status_code=503, detail=f"GitLab API is unavailable: {str(e)}")

        if response.status_code >= 400:
            detail = response.text
            try:
                parsed = response.json()
                if isinstance(parsed, dict):
                    detail = parsed.get("message") or detail
            except Exception:
                pass
            raise HTTPException(status_code=response.status_code, detail=f"GitLab API error: {detail}")

        data = response.json()
        # Normalize clone URL key to keep route response stable.
        if "clone_url" not in data:
            data["clone_url"] = data.get("http_url_to_repo")
        if "html_url" not in data:
            data["html_url"] = data.get("web_url")
        if "ssh_url" not in data:
            data["ssh_url"] = data.get("ssh_url_to_repo")
        if "full_name" not in data:
            data["full_name"] = data.get("path_with_namespace") or data.get("name")
        return data
    # [/DEF:create_gitlab_repository:Function]

    # [DEF:_parse_remote_repo_identity:Function]
    # @PURPOSE: Parse owner/repo from remote URL for Git server API operations.
    # @PRE: remote_url is a valid git URL.
    # @POST: Returns owner/repo tokens.
    # @RETURN: Dict[str, str]
    def _parse_remote_repo_identity(self, remote_url: str) -> Dict[str, str]:
        normalized = str(remote_url or "").strip()
        if not normalized:
            raise HTTPException(status_code=400, detail="Repository remote_url is empty")

        if normalized.startswith("git@"):
            # git@host:owner/repo.git
            path = normalized.split(":", 1)[1] if ":" in normalized else ""
        else:
            parsed = urlparse(normalized)
            path = parsed.path or ""

        path = path.strip("/")
        if path.endswith(".git"):
            path = path[:-4]
        parts = [segment for segment in path.split("/") if segment]
        if len(parts) < 2:
            raise HTTPException(status_code=400, detail=f"Cannot parse repository owner/name from remote URL: {remote_url}")

        owner = parts[0]
        repo = parts[-1]
        namespace = "/".join(parts[:-1])
        return {
            "owner": owner,
            "repo": repo,
            "namespace": namespace,
            "full_name": f"{namespace}/{repo}",
        }
    # [/DEF:_parse_remote_repo_identity:Function]

    # [DEF:_derive_server_url_from_remote:Function]
    # @PURPOSE: Build API base URL from remote repository URL without credentials.
    # @PRE: remote_url may be any git URL.
    # @POST: Returns normalized http(s) base URL or None when derivation is impossible.
    # @RETURN: Optional[str]
    def _derive_server_url_from_remote(self, remote_url: str) -> Optional[str]:
        normalized = str(remote_url or "").strip()
        if not normalized or normalized.startswith("git@"):
            return None

        parsed = urlparse(normalized)
        if parsed.scheme not in {"http", "https"}:
            return None
        if not parsed.hostname:
            return None

        netloc = parsed.hostname
        if parsed.port:
            netloc = f"{netloc}:{parsed.port}"
        return f"{parsed.scheme}://{netloc}".rstrip("/")
    # [/DEF:_derive_server_url_from_remote:Function]

    # [DEF:promote_direct_merge:Function]
    # @PURPOSE: Perform direct merge between branches in local repo and push target branch.
    # @PRE: Repository exists and both branches are valid.
    # @POST: Target branch contains merged changes from source branch.
    # @RETURN: Dict[str, Any]
    def promote_direct_merge(
        self,
        dashboard_id: int,
        from_branch: str,
        to_branch: str,
    ) -> Dict[str, Any]:
        with belief_scope("GitService.promote_direct_merge"):
            if not from_branch or not to_branch:
                raise HTTPException(status_code=400, detail="from_branch and to_branch are required")
            repo = self.get_repo(dashboard_id)
            source = from_branch.strip()
            target = to_branch.strip()
            if source == target:
                raise HTTPException(status_code=400, detail="from_branch and to_branch must be different")

            try:
                origin = repo.remote(name="origin")
            except ValueError:
                raise HTTPException(status_code=400, detail="Remote 'origin' not configured")

            try:
                origin.fetch()
                # Ensure local source branch exists.
                if source not in [head.name for head in repo.heads]:
                    if f"origin/{source}" in [ref.name for ref in repo.refs]:
                        repo.git.checkout("-b", source, f"origin/{source}")
                    else:
                        raise HTTPException(status_code=404, detail=f"Source branch '{source}' not found")

                # Ensure local target branch exists and is checked out.
                if target in [head.name for head in repo.heads]:
                    repo.git.checkout(target)
                elif f"origin/{target}" in [ref.name for ref in repo.refs]:
                    repo.git.checkout("-b", target, f"origin/{target}")
                else:
                    raise HTTPException(status_code=404, detail=f"Target branch '{target}' not found")

                # Bring target up to date and merge source into target.
                try:
                    origin.pull(target)
                except Exception:
                    pass
                repo.git.merge(source, "--no-ff", "-m", f"chore(flow): promote {source} -> {target}")
                origin.push(refspec=f"{target}:{target}")
            except HTTPException:
                raise
            except Exception as e:
                message = str(e)
                if "CONFLICT" in message.upper():
                    raise HTTPException(status_code=409, detail=f"Merge conflict during direct promote: {message}")
                raise HTTPException(status_code=500, detail=f"Direct promote failed: {message}")

            return {
                "mode": "direct",
                "from_branch": source,
                "to_branch": target,
                "status": "merged",
            }
    # [/DEF:promote_direct_merge:Function]

    # [DEF:create_gitea_pull_request:Function]
    # @PURPOSE: Create pull request in Gitea.
    # @PRE: Config and remote URL are valid.
    # @POST: Returns normalized PR metadata.
    # @RETURN: Dict[str, Any]
    async def create_gitea_pull_request(
        self,
        server_url: str,
        pat: str,
        remote_url: str,
        from_branch: str,
        to_branch: str,
        title: str,
        description: Optional[str] = None,
    ) -> Dict[str, Any]:
        identity = self._parse_remote_repo_identity(remote_url)
        payload = {
            "title": title,
            "head": from_branch,
            "base": to_branch,
            "body": description or "",
        }
        endpoint = f"/repos/{identity['owner']}/{identity['repo']}/pulls"
        active_server_url = server_url
        try:
            data = await self._gitea_request(
                "POST",
                active_server_url,
                pat,
                endpoint,
                payload=payload,
            )
        except HTTPException as exc:
            fallback_url = self._derive_server_url_from_remote(remote_url)
            normalized_primary = self._normalize_git_server_url(server_url)
            should_retry_with_fallback = (
                exc.status_code == 404 and fallback_url and fallback_url != normalized_primary
            )
            if should_retry_with_fallback:
                logger.warning(
                    "[create_gitea_pull_request][Action] Primary Gitea URL not found, retrying with remote host: %s",
                    fallback_url,
                )
                active_server_url = fallback_url
                try:
                    data = await self._gitea_request(
                        "POST",
                        active_server_url,
                        pat,
                        endpoint,
                        payload=payload,
                    )
                except HTTPException as retry_exc:
                    if retry_exc.status_code == 404:
                        branch_detail = await self._build_gitea_pr_404_detail(
                            server_url=active_server_url,
                            pat=pat,
                            owner=identity["owner"],
                            repo=identity["repo"],
                            from_branch=from_branch,
                            to_branch=to_branch,
                        )
                        if branch_detail:
                            raise HTTPException(status_code=400, detail=branch_detail)
                    raise
            else:
                if exc.status_code == 404:
                    branch_detail = await self._build_gitea_pr_404_detail(
                        server_url=active_server_url,
                        pat=pat,
                        owner=identity["owner"],
                        repo=identity["repo"],
                        from_branch=from_branch,
                        to_branch=to_branch,
                    )
                    if branch_detail:
                        raise HTTPException(status_code=400, detail=branch_detail)
                raise

        if not isinstance(data, dict):
            raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating pull request")
        return {
            "id": data.get("number") or data.get("id"),
            "url": data.get("html_url") or data.get("url"),
            "status": data.get("state") or "open",
        }
    # [/DEF:create_gitea_pull_request:Function]

    # [DEF:create_github_pull_request:Function]
    # @PURPOSE: Create pull request in GitHub or GitHub Enterprise.
    # @PRE: Config and remote URL are valid.
    # @POST: Returns normalized PR metadata.
    # @RETURN: Dict[str, Any]
    async def create_github_pull_request(
        self,
        server_url: str,
        pat: str,
        remote_url: str,
        from_branch: str,
        to_branch: str,
        title: str,
        description: Optional[str] = None,
        draft: bool = False,
    ) -> Dict[str, Any]:
        identity = self._parse_remote_repo_identity(remote_url)
        base_url = self._normalize_git_server_url(server_url)
        if "github.com" in base_url:
            api_url = f"https://api.github.com/repos/{identity['namespace']}/{identity['repo']}/pulls"
        else:
            api_url = f"{base_url}/api/v3/repos/{identity['namespace']}/{identity['repo']}/pulls"
        headers = {
            "Authorization": f"token {pat.strip()}",
            "Content-Type": "application/json",
            "Accept": "application/vnd.github+json",
        }
        payload = {
            "title": title,
            "head": from_branch,
            "base": to_branch,
            "body": description or "",
            "draft": bool(draft),
        }
        try:
            async with httpx.AsyncClient(timeout=20.0) as client:
                response = await client.post(api_url, headers=headers, json=payload)
        except Exception as e:
            raise HTTPException(status_code=503, detail=f"GitHub API is unavailable: {str(e)}")
        if response.status_code >= 400:
            detail = response.text
            try:
                detail = response.json().get("message") or detail
            except Exception:
                pass
            raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {detail}")
        data = response.json()
        return {
            "id": data.get("number") or data.get("id"),
            "url": data.get("html_url") or data.get("url"),
            "status": data.get("state") or "open",
        }
    # [/DEF:create_github_pull_request:Function]

    # [DEF:create_gitlab_merge_request:Function]
    # @PURPOSE: Create merge request in GitLab.
    # @PRE: Config and remote URL are valid.
    # @POST: Returns normalized MR metadata.
    # @RETURN: Dict[str, Any]
    async def create_gitlab_merge_request(
        self,
        server_url: str,
        pat: str,
        remote_url: str,
        from_branch: str,
        to_branch: str,
        title: str,
        description: Optional[str] = None,
        remove_source_branch: bool = False,
    ) -> Dict[str, Any]:
        identity = self._parse_remote_repo_identity(remote_url)
        base_url = self._normalize_git_server_url(server_url)
        project_id = quote(identity["full_name"], safe="")
        api_url = f"{base_url}/api/v4/projects/{project_id}/merge_requests"
        headers = {
            "PRIVATE-TOKEN": pat.strip(),
            "Content-Type": "application/json",
            "Accept": "application/json",
        }
        payload = {
            "source_branch": from_branch,
            "target_branch": to_branch,
            "title": title,
            "description": description or "",
            "remove_source_branch": bool(remove_source_branch),
        }
        try:
            async with httpx.AsyncClient(timeout=20.0) as client:
                response = await client.post(api_url, headers=headers, json=payload)
        except Exception as e:
            raise HTTPException(status_code=503, detail=f"GitLab API is unavailable: {str(e)}")
        if response.status_code >= 400:
            detail = response.text
            try:
                parsed = response.json()
                if isinstance(parsed, dict):
                    detail = parsed.get("message") or detail
            except Exception:
                pass
            raise HTTPException(status_code=response.status_code, detail=f"GitLab API error: {detail}")
        data = response.json()
        return {
            "id": data.get("iid") or data.get("id"),
            "url": data.get("web_url") or data.get("url"),
            "status": data.get("state") or "opened",
        }
    # [/DEF:create_gitlab_merge_request:Function]

# [/DEF:GitService:Class]
# [/DEF:backend.src.services.git_service:Module]
