#****************************************************************************
#* package_git.py
#*
#* Copyright 2023 Matthew Ballance and Contributors
#*
#* Licensed under the Apache License, Version 2.0 (the "License"); you may 
#* not use this file except in compliance with the License.  
#* You may obtain a copy of the License at:
#*
#*   http://www.apache.org/licenses/LICENSE-2.0
#*
#* Unless required by applicable law or agreed to in writing, software 
#* distributed under the License is distributed on an "AS IS" BASIS, 
#* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  
#* See the License for the specific language governing permissions and 
#* limitations under the License.
#*
#* Created on:
#*     Author: 
#*
#****************************************************************************
import logging
import os
import sys
import subprocess
import dataclasses as dc
from typing import Optional
from .package_url import PackageURL
from ..proj_info import ProjInfo
from ..project_ops_info import ProjectUpdateInfo, ProjectStatusInfo, ProjectSyncInfo
from ..utils import note, fatal
from ..cache import Cache, is_github_url, parse_github_url

_logger = logging.getLogger("ivpm.pkg_types.package_git")


@dc.dataclass
class PackageGit(PackageURL):
    branch : str = None
    commit : str = None
    tag : str = None
    depth : str = None
    anonymous : bool = None
    resolved_commit : str = None  # actual commit hash after fetch

    def update(self, update_info : ProjectUpdateInfo) -> ProjInfo:
        pkg_dir = os.path.join(update_info.deps_dir, self.name)
        self.path = pkg_dir.replace("\\", "/")

        # Report this package for cache statistics
        # Git packages are cacheable if cache=True, editable if cache is not True
        is_cacheable = self.cache is True
        is_editable = self.cache is not True  # Could be cached but isn't
        update_info.report_package(cacheable=is_cacheable, editable=is_editable)

        if os.path.exists(pkg_dir) or os.path.islink(pkg_dir):
            note("package %s is already loaded" % self.name)
            self._capture_resolved_commit(pkg_dir)
        else:
            # Check if caching is enabled and supported
            if self.cache is True:
                # For GitHub URLs, use GitHub API; for others, use git ls-remote
                return self._update_with_cache(update_info, pkg_dir)
            elif self.cache is False:
                # Explicitly no cache - clone without history and make read-only
                return self._update_no_cache_readonly(update_info, pkg_dir)
            else:
                # cache not specified - clone with full history
                return self._update_full_clone(update_info, pkg_dir)

        return ProjInfo.mkFromProj(pkg_dir)

    def _get_github_commit_hash(self, owner: str, repo: str, ref: str = None) -> str:
        """Get the commit hash for a GitHub repo using the API or git ls-remote.
        
        For general git URLs, uses git ls-remote to get the hash.
        """
        import httpx
        
        if ref is None:
            ref = self.branch or self.tag or "HEAD"
        
        # Try GitHub API first if it's a GitHub URL
        if is_github_url(self.url):
            try:
                # Use GitHub API to get the commit hash
                api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/{ref}"
                response = httpx.get(api_url, follow_redirects=True, timeout=30)
                if response.status_code == 200:
                    data = response.json()
                    return data["sha"]
            except Exception:
                pass
        
        # Fallback to git ls-remote for any git URL
        return self._get_commit_hash_ls_remote(ref)
    
    def _get_commit_hash_ls_remote(self, ref: str = None) -> str:
        """Get commit hash using git ls-remote."""
        if ref is None:
            ref = self.branch or self.tag or "HEAD"
        
        try:
            # Use git ls-remote to get the hash
            result = subprocess.run(
                ["git", "ls-remote", self.url, ref],
                capture_output=True,
                text=True,
                timeout=30
            )
            if result.returncode == 0 and result.stdout.strip():
                # Output format: "hash\tref"
                return result.stdout.strip().split()[0]
            
            # Try refs/heads/ prefix for branches
            if not ref.startswith("refs/"):
                result = subprocess.run(
                    ["git", "ls-remote", self.url, f"refs/heads/{ref}"],
                    capture_output=True,
                    text=True,
                    timeout=30
                )
                if result.returncode == 0 and result.stdout.strip():
                    return result.stdout.strip().split()[0]
                
                # Try refs/tags/ prefix for tags
                result = subprocess.run(
                    ["git", "ls-remote", self.url, f"refs/tags/{ref}"],
                    capture_output=True,
                    text=True,
                    timeout=30
                )
                if result.returncode == 0 and result.stdout.strip():
                    return result.stdout.strip().split()[0]
        except Exception:
            pass
        
        return None

    def _update_with_cache(self, update_info: ProjectUpdateInfo, pkg_dir: str) -> ProjInfo:
        """Update using the cache."""
        note("loading package %s with cache" % self.name)
        
        ref = self.branch or self.tag or "HEAD"
        
        # Get the commit hash - use GitHub API for GitHub URLs, git ls-remote otherwise
        commit_hash = None
        if is_github_url(self.url):
            owner, repo = parse_github_url(self.url)
            commit_hash = self._get_github_commit_hash(owner, repo, ref)
        else:
            # Use git ls-remote for general git URLs
            commit_hash = self._get_commit_hash_ls_remote(ref)
        
        if commit_hash is None:
            fatal("Failed to get commit hash for %s (ref: %s)" % (self.url, ref))
        
        self.resolved_commit = commit_hash
        
        cache = update_info.cache
        if cache is None:
            cache = Cache()
        
        # If cache is not properly configured, fall back to full clone
        if not cache.is_enabled():
            note("IVPM_CACHE not set - falling back to full clone for %s" % self.name)
            return self._update_full_clone(update_info, pkg_dir)
        
        # Check if this version is cached
        if cache.has_version(self.name, commit_hash):
            # Cache hit - symlink to deps
            note("Cache hit for %s at %s" % (self.name, commit_hash[:12]))
            cache.link_to_deps(self.name, commit_hash, update_info.deps_dir)
            update_info.report_cache_hit()
            return ProjInfo.mkFromProj(pkg_dir)
        
        # Cache miss - clone without history
        note("Cache miss for %s - cloning" % self.name)
        update_info.report_cache_miss()
        
        # Clone to a temporary location first
        temp_dir = os.path.join(update_info.deps_dir, f".cache_temp_{self.name}")
        if os.path.exists(temp_dir):
            import shutil
            shutil.rmtree(temp_dir)
        
        self._clone_to_dir(update_info, temp_dir, depth=1)
        
        # Store in cache and link
        cache.store_version(self.name, commit_hash, temp_dir)
        cache.link_to_deps(self.name, commit_hash, update_info.deps_dir)
        
        return ProjInfo.mkFromProj(pkg_dir)

    def _update_no_cache_readonly(self, update_info: ProjectUpdateInfo, pkg_dir: str) -> ProjInfo:
        """Clone without history and make read-only (cache=False)."""
        note("loading package %s (no cache, read-only)" % self.name)
        
        self._clone_to_dir(update_info, pkg_dir, depth=1)
        self._capture_resolved_commit(pkg_dir)
        
        # Make read-only
        self._make_readonly(pkg_dir)
        
        return ProjInfo.mkFromProj(pkg_dir)

    def _update_full_clone(self, update_info: ProjectUpdateInfo, pkg_dir: str) -> ProjInfo:
        """Full clone with history (cache unspecified)."""
        note("loading package %s" % self.name)
        
        self._clone_to_dir(update_info, pkg_dir, depth=self.depth)
        self._capture_resolved_commit(pkg_dir)
        
        return ProjInfo.mkFromProj(pkg_dir)

    def _capture_resolved_commit(self, pkg_dir: str):
        """Read the HEAD commit hash from a cloned repo and store in resolved_commit."""
        if self.resolved_commit is not None:
            return  # already set (e.g. by cache path)
        try:
            result = subprocess.run(
                ["git", "rev-parse", "HEAD"],
                capture_output=True, text=True, cwd=pkg_dir, timeout=10
            )
            if result.returncode == 0:
                self.resolved_commit = result.stdout.strip()
        except Exception:
            pass

    def _clone_to_dir(self, update_info: ProjectUpdateInfo, target_dir: str, depth=None):
        """Clone the repo to the specified directory."""
        cwd = os.getcwd()
        parent_dir = os.path.dirname(target_dir)
        target_name = os.path.basename(target_dir)
        
        if not os.path.isdir(parent_dir):
            os.makedirs(parent_dir)
        
        os.chdir(parent_dir)
        sys.stdout.flush()

        git_cmd = ["git", "clone"]
    
        if depth is not None:
            git_cmd.extend(["--depth", str(depth)])

        if self.branch is not None:
            git_cmd.extend(["-b", str(self.branch)])

        # Modify the URL to use SSH/key-based clones
        # unless anonymous cloning was requested
        if update_info.args is not None and hasattr(update_info.args, "anonymous"):
            use_anonymous = getattr(update_info.args, "anonymous")
        else:
            use_anonymous = False

        if self.anonymous is not None:
            use_anonymous = self.anonymous

        if not use_anonymous:
            _logger.debug("Using dev URL")
            delim_idx = self.url.find("://")
            protocol = self.url[:delim_idx]
            if protocol != "file":
                url = self.url[delim_idx+3:]
                first_sl_idx = url.find('/')
                url = "git@" + url[:first_sl_idx] + ":" + url[first_sl_idx+1:]
                _logger.debug("Final URL: %s", url)
                git_cmd.append(url)
            else:
                _logger.debug("Using original file-based URL")
                git_cmd.append(self.url)
        else:
            _logger.debug("Using anonymous URL")
            git_cmd.append(self.url)

        # Clone to the target directory name
        git_cmd.append(target_name)
        
        _logger.debug("git_cmd: %s", str(git_cmd))
        
        # Suppress output when in Rich TUI mode
        if update_info.suppress_output:
            status = subprocess.run(git_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        else:
            status = subprocess.run(git_cmd)
        os.chdir(cwd)
    
        if status.returncode != 0:
            fatal("Git command \"%s\" failed" % str(git_cmd))

        # Checkout a specific commit            
        if self.commit is not None:
            os.chdir(target_dir)
            git_cmd = ["git", "reset", "--hard", self.commit]
            _logger.debug("git_cmd: %s", str(git_cmd))
            if update_info.suppress_output:
                status = subprocess.run(git_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            else:
                status = subprocess.run(git_cmd)
        
            if status.returncode != 0:
                fatal("Git command \"%s\" failed" % str(git_cmd))
            os.chdir(cwd)
        
    
        # TODO: Existence of .gitmodules should trigger this
        if os.path.isfile(os.path.join(target_dir, ".gitmodules")):
            os.chdir(target_dir)
            sys.stdout.flush()
            git_cmd = ["git", "submodule", "update", "--init", "--recursive"]
            _logger.debug("git_cmd: %s", str(git_cmd))
            if update_info.suppress_output:
                status = subprocess.run(git_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            else:
                status = subprocess.run(git_cmd)
            os.chdir(cwd)

    def _make_readonly(self, path: str):
        """Make all files in a directory tree read-only."""
        import stat
        for root, dirs, files in os.walk(path):
            for d in dirs:
                dir_path = os.path.join(root, d)
                mode = os.stat(dir_path).st_mode
                os.chmod(dir_path, mode & ~stat.S_IWUSR & ~stat.S_IWGRP & ~stat.S_IWOTH)
            for f in files:
                file_path = os.path.join(root, f)
                mode = os.stat(file_path).st_mode
                os.chmod(file_path, mode & ~stat.S_IWUSR & ~stat.S_IWGRP & ~stat.S_IWOTH)
        mode = os.stat(path).st_mode
        os.chmod(path, mode & ~stat.S_IWUSR & ~stat.S_IWGRP & ~stat.S_IWOTH)
    
    def status(self, status_info: ProjectStatusInfo):
        from ..pkg_status import PkgVcsStatus

        pkg_dir = os.path.join(status_info.deps_dir, self.name)

        if not os.path.isdir(os.path.join(pkg_dir, ".git")):
            return PkgVcsStatus(
                name=self.name,
                src_type="git",
                path=pkg_dir,
                vcs="git",
                branch="(not fetched)",
                error="directory not found or not a git repo",
            )

        def _git(args):
            r = subprocess.run(
                ["git"] + args,
                capture_output=True, text=True, cwd=pkg_dir, timeout=10
            )
            return r.returncode, r.stdout.strip()

        # Branch
        _, branch_raw = _git(["rev-parse", "--abbrev-ref", "HEAD"])
        branch = None if branch_raw == "HEAD" else branch_raw

        # Tag (only when exactly on a tag)
        rc_tag, tag_raw = _git(["describe", "--tags", "--exact-match", "HEAD"])
        tag = tag_raw if rc_tag == 0 else None

        # Short commit hash
        _, commit = _git(["rev-parse", "--short", "HEAD"])

        # Dirty / modified files
        _, porcelain = _git(["status", "--porcelain"])
        modified = [line for line in porcelain.splitlines() if line.strip()]
        is_dirty = len(modified) > 0

        # Ahead / behind upstream (silenced if no upstream)
        ahead: Optional[int] = None
        behind: Optional[int] = None
        rc_ab, ab_raw = _git(["rev-list", "--left-right", "--count", "@{u}...HEAD"])
        if rc_ab == 0 and ab_raw:
            parts = ab_raw.split()
            if len(parts) == 2:
                try:
                    behind = int(parts[0])
                    ahead = int(parts[1])
                except ValueError:
                    pass

        return PkgVcsStatus(
            name=self.name,
            src_type="git",
            path=pkg_dir,
            vcs="git",
            branch=branch,
            tag=tag,
            commit=commit,
            is_dirty=is_dirty,
            modified=modified,
            ahead=ahead,
            behind=behind,
        )
    
    def sync(self, sync_info: ProjectSyncInfo):
        from ..pkg_sync import PkgSyncResult, SyncOutcome
        import stat as _stat

        pkg_dir = os.path.join(sync_info.deps_dir, self.name)
        dry_run = sync_info.dry_run

        # Tag-pinned packages have no meaningful "latest" to pull.
        if self.tag is not None:
            return PkgSyncResult(
                name=self.name, src_type="git", path=pkg_dir,
                outcome=SyncOutcome.SKIPPED,
                skipped_reason="pinned to tag %s" % self.tag,
            )

        # Read-only packages are cached; skip silently.
        try:
            mode = os.stat(pkg_dir).st_mode
            is_writable = bool(mode & _stat.S_IWUSR)
        except Exception:
            is_writable = False
        if not is_writable:
            return PkgSyncResult(
                name=self.name, src_type="git", path=pkg_dir,
                outcome=SyncOutcome.SKIPPED,
                skipped_reason="read-only (cached)",
            )

        # Must be a real git checkout.
        if not os.path.isdir(os.path.join(pkg_dir, ".git")):
            return PkgSyncResult(
                name=self.name, src_type="git", path=pkg_dir,
                outcome=SyncOutcome.ERROR,
                error="not a git repository",
            )

        def _git(*args):
            r = subprocess.run(
                ["git"] + list(args),
                capture_output=True, text=True, cwd=pkg_dir, timeout=60,
            )
            return r.returncode, r.stdout.strip(), r.stderr.strip()

        # Current branch (detached HEAD → skip).
        rc, branch, _ = _git("rev-parse", "--abbrev-ref", "HEAD")
        if rc != 0:
            return PkgSyncResult(
                name=self.name, src_type="git", path=pkg_dir,
                outcome=SyncOutcome.ERROR, error="failed to determine branch",
            )
        if branch == "HEAD":
            return PkgSyncResult(
                name=self.name, src_type="git", path=pkg_dir,
                outcome=SyncOutcome.SKIPPED, skipped_reason="detached HEAD",
            )

        _, old_commit, _ = _git("rev-parse", "--short", "HEAD")

        # Detect dirty working tree.
        _, porcelain, _ = _git("status", "--porcelain")
        dirty_files = [ln for ln in porcelain.splitlines() if ln.strip()]

        # Fetch from origin (safe in both real and dry-run modes).
        rc, _, err = _git("fetch", "origin")
        if rc != 0:
            return PkgSyncResult(
                name=self.name, src_type="git", path=pkg_dir,
                outcome=SyncOutcome.ERROR, branch=branch,
                old_commit=old_commit,
                error="git fetch failed: %s" % err,
            )

        # Ahead / behind upstream.
        rc, ab_raw, _ = _git("rev-list", "--left-right", "--count",
                              "@{u}...HEAD")
        behind = ahead = 0
        if rc == 0 and ab_raw:
            parts = ab_raw.split()
            if len(parts) == 2:
                try:
                    behind, ahead = int(parts[0]), int(parts[1])
                except ValueError:
                    pass

        # Nothing to pull and no local commits → up-to-date.
        if behind == 0 and ahead == 0:
            return PkgSyncResult(
                name=self.name, src_type="git", path=pkg_dir,
                outcome=SyncOutcome.UP_TO_DATE,
                branch=branch, old_commit=old_commit,
            )

        # Strictly ahead (nothing to pull, but local work exists).
        if behind == 0 and ahead > 0:
            return PkgSyncResult(
                name=self.name, src_type="git", path=pkg_dir,
                outcome=SyncOutcome.AHEAD,
                branch=branch, old_commit=old_commit, commits_ahead=ahead,
            )

        # There are upstream commits to pull (behind > 0).
        # Dirty tree blocks a real merge.
        if dirty_files and not dry_run:
            return PkgSyncResult(
                name=self.name, src_type="git", path=pkg_dir,
                outcome=SyncOutcome.DIRTY,
                branch=branch, old_commit=old_commit,
                dirty_files=dirty_files,
            )

        if dry_run:
            # Diverged history (ahead > 0 as well) → would conflict.
            if ahead > 0:
                return PkgSyncResult(
                    name=self.name, src_type="git", path=pkg_dir,
                    outcome=SyncOutcome.DRY_WOULD_CONFLICT,
                    branch=branch, old_commit=old_commit,
                    commits_behind=behind, commits_ahead=ahead,
                )
            # Check whether a fast-forward is possible.
            rc_ff, _, _ = _git("merge-base", "--is-ancestor",
                               "HEAD", "origin/%s" % branch)
            if rc_ff == 0:
                if dirty_files:
                    return PkgSyncResult(
                        name=self.name, src_type="git", path=pkg_dir,
                        outcome=SyncOutcome.DRY_DIRTY,
                        branch=branch, old_commit=old_commit,
                        commits_behind=behind, dirty_files=dirty_files,
                    )
                return PkgSyncResult(
                    name=self.name, src_type="git", path=pkg_dir,
                    outcome=SyncOutcome.DRY_WOULD_SYNC,
                    branch=branch, old_commit=old_commit,
                    commits_behind=behind,
                )
            return PkgSyncResult(
                name=self.name, src_type="git", path=pkg_dir,
                outcome=SyncOutcome.DRY_WOULD_CONFLICT,
                branch=branch, old_commit=old_commit, commits_behind=behind,
            )

        # Real merge.
        rc, _, _ = _git("merge", "origin/%s" % branch)
        if rc != 0:
            # Collect unmerged files from index before aborting.
            _, st_out, _ = _git("status", "--porcelain")
            conflict_files = [
                ln[3:] for ln in st_out.splitlines()
                if ln[:2] in ("UU", "AA", "DD", "AU", "UA", "DU", "UD")
            ]
            _git("merge", "--abort")
            return PkgSyncResult(
                name=self.name, src_type="git", path=pkg_dir,
                outcome=SyncOutcome.CONFLICT,
                branch=branch, old_commit=old_commit, commits_behind=behind,
                conflict_files=conflict_files,
                next_steps=[
                    "cd %s && git status" % pkg_dir,
                    "cd %s && git mergetool" % pkg_dir,
                    "cd %s && git merge --abort" % pkg_dir,
                ],
            )

        _, new_commit, _ = _git("rev-parse", "--short", "HEAD")

        # Update submodules if the project uses them.
        if os.path.isfile(os.path.join(pkg_dir, ".gitmodules")):
            _git("submodule", "update", "--init", "--recursive")

        return PkgSyncResult(
            name=self.name, src_type="git", path=pkg_dir,
            outcome=SyncOutcome.SYNCED,
            branch=branch, old_commit=old_commit, new_commit=new_commit,
            commits_behind=behind,
        )
    
    def process_options(self, opts, si):
        super().process_options(opts, si)
        self.src_type = "git"

        if "anonymous" in opts.keys():
            self.anonymous = opts["anonymous"]
                
        if "depth" in opts.keys():
            self.depth = opts["depth"]
                
        if "dep-set" in opts.keys():
            self.dep_set = opts["dep-set"]
               
        if "branch" in opts.keys():
            self.branch = opts["branch"]
                
        if "commit" in opts.keys():
            self.commit = opts["commit"]
               
        if "tag" in opts.keys():
            self.tag = opts["tag"]


    @staticmethod
    def create(name, opts, si) -> 'PackageGit':
        pkg = PackageGit(name)
        pkg.process_options(opts, si)
        return pkg

