"""Git repository collector - extracts commit history and metadata."""

import hashlib
import logging
import os
import re
import shutil
import subprocess
import tempfile
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional

from git import Repo
from git.exc import GitCommandError, InvalidGitRepositoryError

from ossuary.collectors.base import BaseCollector

logger = logging.getLogger(__name__)

# GitHub noreply format: 12345+username@users.noreply.github.com
_GITHUB_NOREPLY_RE = re.compile(r"^\d+\+(.+)@users\.noreply\.github\.com$")

# Generic email domains where multiple unrelated people share the same domain.
# Excluded from org-continuity checks in takeover detection — a new gmail.com
# contributor on a project with historical gmail.com contributors is NOT an
# internal handoff.
_GENERIC_EMAIL_DOMAINS = {
    "gmail.com", "hotmail.com", "outlook.com", "yahoo.com",
    "protonmail.com", "proton.me", "mail.com", "icloud.com",
    "users.noreply.github.com", "live.com", "aol.com",
    "yandex.ru", "qq.com", "163.com",
}

# Country-code second-level domains (for org key extraction)
_COUNTRY_CODE_SLDS = {"co.uk", "co.jp", "com.au", "co.nz", "com.br", "co.kr", "co.in"}


def _domain_org_key(domain: str) -> str:
    """Extract org identifier from email domain for continuity matching.

    suse.de → suse, suse.com → suse, suse.cz → suse
    redhat.com → redhat, linux.intel.com → intel
    cybozu.co.jp → cybozu
    """
    parts = domain.lower().split(".")
    if len(parts) < 2:
        return domain
    # Handle country-code SLDs: example.co.uk → example
    if len(parts) >= 3:
        sld_tld = ".".join(parts[-2:])
        if sld_tld in _COUNTRY_CODE_SLDS:
            return parts[-3]
    # Standard: second-level domain
    return parts[-2]


def _normalize_email(email: str) -> str:
    """Normalize an email address to a canonical identity key.

    Only handles the unambiguous GitHub noreply case:
      - 12345+cfconrad@users.noreply.github.com → cfconrad@users.noreply.github.com

    General emails are lowercased but otherwise preserved. Merging by local
    part (e.g. user@suse.de + user@suse.com) was too aggressive — it falsely
    merges unrelated people who share common usernames.
    """
    email = email.lower().strip()
    if not email or "@" not in email:
        return email

    # Handle GitHub noreply: strip numeric prefix
    # 12345+user@users.noreply.github.com → user@users.noreply.github.com
    m = _GITHUB_NOREPLY_RE.match(email)
    if m:
        return f"{m.group(1)}@users.noreply.github.com"

    return email


@dataclass
class CommitData:
    """Extracted commit data."""

    sha: str
    author_name: str
    author_email: str
    authored_date: datetime
    committer_name: str
    committer_email: str
    committed_date: datetime
    message: str


@dataclass
class GitMetrics:
    """Metrics extracted from git history."""

    total_commits: int = 0
    commits_last_year: int = 0
    unique_contributors: int = 0
    maintainer_concentration: float = 0.0
    top_contributor_email: str = ""
    top_contributor_name: str = ""
    top_contributor_commits: int = 0
    last_commit_date: Optional[datetime] = None
    first_commit_date: Optional[datetime] = None
    commits: list[CommitData] = None

    # Maturity detection fields
    lifetime_contributors: int = 0
    lifetime_concentration: float = 0.0
    is_mature: bool = False
    repo_age_years: float = 0.0

    # CHAOSS-aligned governance signals (informational, not used in score)
    bus_factor: int = 0              # minimum contributors for 50% of commits
    bus_factor_contributors: list[str] = None
    elephant_factor: int = 0         # minimum ORGANIZATIONS for 50% of commits
    elephant_factor_orgs: list[str] = None
    inactive_contributor_ratio: float = 0.0  # fraction of lifetime contributors absent in last year

    # Takeover detection (proportion shift)
    takeover_shift: float = 0.0       # max % shift of any contributor (historical→recent)
    takeover_suspect: str = ""        # email of the contributor with highest shift
    takeover_suspect_name: str = ""   # display name

    def __post_init__(self):
        if self.commits is None:
            self.commits = []


class GitCollector(BaseCollector):
    """Collector for git repository data."""

    def __init__(self, repos_path: Optional[str] = None):
        """
        Initialize the git collector.

        Args:
            repos_path: Path to store cloned repositories. Defaults to ./repos
        """
        self.repos_path = Path(repos_path or os.getenv("REPOS_PATH", "./repos"))
        self.repos_path.mkdir(parents=True, exist_ok=True)

    def is_available(self) -> bool:
        """Git collector is always available."""
        return True

    def _get_repo_path(self, repo_url: str) -> Path:
        """Get local path for a repository."""
        # Create a hash-based directory name to avoid path issues
        url_hash = hashlib.md5(repo_url.encode()).hexdigest()[:12]
        # Extract repo name for readability
        repo_name = repo_url.rstrip("/").split("/")[-1].replace(".git", "")
        return self.repos_path / f"{repo_name}_{url_hash}"

    def clone_or_update(self, repo_url: str) -> Path:
        """
        Clone a repository or update if it already exists.

        Args:
            repo_url: Git repository URL

        Returns:
            Path to the local repository
        """
        repo_path = self._get_repo_path(repo_url)

        if repo_path.exists():
            try:
                logger.info(f"Updating existing repository: {repo_path}")
                repo = Repo(repo_path)
                repo.remotes.origin.fetch()
                return repo_path
            except (InvalidGitRepositoryError, GitCommandError) as e:
                logger.warning(f"Failed to update repository, re-cloning: {e}")
                shutil.rmtree(repo_path)

        logger.info(f"Cloning repository: {repo_url}")
        try:
            # Blobless partial clone: fetches commit metadata only, no file content.
            # We need full commit history for maturity detection (repo age,
            # lifetime contributors) but never actual file blobs.
            Repo.clone_from(
                repo_url,
                repo_path,
                multi_options=[
                    "--filter=blob:none",
                    "--single-branch",
                ],
            )
            return repo_path
        except GitCommandError as e:
            logger.debug(f"Failed to clone repository: {e}")
            raise

    # git log format: fields separated by \x00, subject line as message
    _LOG_FORMAT = "%H%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%s"

    def extract_commits(
        self,
        repo_path: Path,
        since: Optional[datetime] = None,
        until: Optional[datetime] = None,
    ) -> list[CommitData]:
        """
        Extract commit data from a repository.

        Uses raw `git log` instead of GitPython iteration — orders of
        magnitude faster on large repos (seconds vs. minutes for 70K commits).

        Args:
            repo_path: Path to the local repository
            since: Only include commits after this date
            until: Only include commits before this date

        Returns:
            List of CommitData objects
        """
        cmd = ["git", "log", "--all", f"--format={self._LOG_FORMAT}"]
        if since:
            cmd.append(f"--since={since.isoformat()}")
        if until:
            cmd.append(f"--until={until.isoformat()}")

        result = subprocess.run(
            cmd, cwd=repo_path,
            capture_output=True, text=False, timeout=300,
        )
        if result.returncode != 0:
            logger.warning(f"git log failed: {result.stderr[:200]}")
            return []

        # Decode with replacement for non-UTF8 author names (e.g. Latin-1)
        output = result.stdout.decode("utf-8", errors="replace")

        commits = []
        for line in output.split("\n"):
            if not line:
                continue
            parts = line.split("\x00")
            if len(parts) < 8:
                continue
            try:
                commits.append(
                    CommitData(
                        sha=parts[0],
                        author_name=parts[1],
                        author_email=parts[2],
                        authored_date=datetime.fromtimestamp(int(parts[3])),
                        committer_name=parts[4],
                        committer_email=parts[5],
                        committed_date=datetime.fromtimestamp(int(parts[6])),
                        message=parts[7],
                    )
                )
            except (ValueError, OSError):
                continue  # skip malformed entries

        return commits

    def calculate_metrics(
        self,
        commits: list[CommitData],
        cutoff_date: Optional[datetime] = None,
    ) -> GitMetrics:
        """
        Calculate metrics from commit data.

        Args:
            commits: List of commits to analyze
            cutoff_date: Date to use as "now" for calculations (for T-1 analysis)

        Returns:
            GitMetrics with calculated values
        """
        if not commits:
            return GitMetrics()

        cutoff = cutoff_date or datetime.now()

        # Sort commits by date (needed for first/last and historical analysis)
        sorted_commits = sorted(commits, key=lambda c: c.authored_date)
        first_commit_date = sorted_commits[0].authored_date
        last_commit_date = sorted_commits[-1].authored_date

        # --- Lifetime stats (all commits) ---
        # Use normalized email to merge identities (e.g. user@suse.de + user@suse.com)
        lifetime_author_counts: dict[str, int] = defaultdict(int)
        for commit in commits:
            lifetime_author_counts[_normalize_email(commit.author_email)] += 1

        lifetime_contributors = len(lifetime_author_counts)
        if lifetime_author_counts:
            lt_top_id = max(lifetime_author_counts, key=lifetime_author_counts.get)
            lifetime_concentration = (lifetime_author_counts[lt_top_id] / len(commits) * 100)
        else:
            lifetime_concentration = 100.0

        # --- Recent stats (last 12 months, tapered concentration) ---
        # Activity count uses a hard 12-month cutoff (the activity modifier
        # uses coarse buckets >50/≥12/≥4/<4, so boundary noise doesn't matter).
        # Concentration uses a tapered window to smooth the ±2-3% swings that
        # occur when individual commits cross the 365-day boundary, which
        # previously caused scores to jump ±20 points between weekly runs
        # (e.g. sidekiq 40→60 from a 68.9%→71.2% concentration shift).
        one_year_ago = cutoff - timedelta(days=365)
        taper_full = cutoff - timedelta(days=300)   # 10 months: full weight
        taper_start = cutoff - timedelta(days=425)  # ~14 months: zero weight

        # Hard-window commits (for activity count)
        recent_commits = [c for c in commits if c.authored_date >= one_year_ago and c.authored_date <= cutoff]

        # Tapered-window commits (for concentration)
        taper_commits = [c for c in commits if c.authored_date >= taper_start and c.authored_date <= cutoff]

        # Weighted concentration calculation
        weighted_counts: dict[str, float] = defaultdict(float)
        total_weight = 0.0
        for commit in taper_commits:
            if commit.authored_date >= taper_full:
                weight = 1.0
            else:
                days_into_taper = (taper_full - commit.authored_date).total_seconds()
                taper_range = (taper_full - taper_start).total_seconds()
                weight = max(0.0, 1.0 - days_into_taper / taper_range)
            identity = _normalize_email(commit.author_email)
            weighted_counts[identity] += weight
            total_weight += weight

        # Unweighted counts (for activity, contributor enumeration, names)
        author_counts: dict[str, int] = defaultdict(int)
        author_names: dict[str, str] = {}
        for commit in recent_commits:
            identity = _normalize_email(commit.author_email)
            author_counts[identity] += 1
            author_names[identity] = commit.author_name

        total_recent = len(recent_commits)
        unique_contributors = len(author_counts)

        if author_counts:
            # Use weighted counts for concentration, unweighted for top contributor
            top_email = max(author_counts, key=author_counts.get)
            if total_weight > 0:
                top_weighted = weighted_counts.get(top_email, 0)
                concentration = (top_weighted / total_weight * 100)
            else:
                concentration = (author_counts[top_email] / total_recent * 100) if total_recent > 0 else 0
        else:
            top_email = ""
            concentration = 100  # No commits = maximum concentration (abandoned)

        # --- Bus factor (CHAOSS: minimum contributors for 50% of commits) ---
        # Uses unweighted recent counts (same data as activity).
        # Excludes bots (dependabot, renovate, etc.) from the count.
        bus_factor = 0
        bus_factor_names = []
        if author_counts:
            human_counts = {
                e: c for e, c in author_counts.items()
                if "[bot]" not in e and "[bot]" not in author_names.get(e, "")
            }
            human_total = sum(human_counts.values())
            if human_total > 0:
                sorted_contributors = sorted(human_counts.items(), key=lambda x: -x[1])
                cumulative = 0
                for email, count in sorted_contributors:
                    cumulative += count
                    bus_factor += 1
                    bus_factor_names.append(author_names.get(email, email))
                    if cumulative / human_total >= 0.5:
                        break

        # --- Elephant factor (CHAOSS: minimum ORGANIZATIONS for 50% of commits) ---
        # Uses email domain as org proxy. Generic domains (gmail, etc.) are grouped
        # as "independent" and counted as one pseudo-org.
        elephant_factor = 0
        elephant_factor_orgs = []
        if author_counts:
            org_counts: dict[str, int] = defaultdict(int)
            for email, count in author_counts.items():
                if "@" in email:
                    domain = email.split("@")[1]
                    if domain in _GENERIC_EMAIL_DOMAINS:
                        org_counts["(independent)"] += count
                    else:
                        org_counts[_domain_org_key(domain)] += count
                else:
                    org_counts["(independent)"] += count

            if org_counts:
                sorted_orgs = sorted(org_counts.items(), key=lambda x: -x[1])
                org_total = sum(org_counts.values())
                cumulative = 0
                for org, count in sorted_orgs:
                    cumulative += count
                    elephant_factor += 1
                    elephant_factor_orgs.append(org)
                    if cumulative / org_total >= 0.5:
                        break

        # --- Inactive contributor ratio ---
        # Fraction of lifetime contributors who did NOT commit in the last year.
        # High ratio suggests contributor attrition.
        if lifetime_contributors > 1:
            recent_identities = set(author_counts.keys())
            inactive_ratio = 1.0 - (len(recent_identities) / lifetime_contributors)
            inactive_ratio = max(0.0, min(1.0, inactive_ratio))
        else:
            inactive_ratio = 0.0

        # --- Maturity detection ---
        repo_age_years = (cutoff - first_commit_date).days / 365.25
        days_since_last_commit = (cutoff - last_commit_date).days

        is_mature = (
            repo_age_years >= 5
            and len(commits) >= 30
            and days_since_last_commit < 5 * 365  # not truly dead
        )

        # --- Takeover detection: proportion shift ---
        # Detects when a minor historical contributor suddenly dominates recent commits.
        # This is the xz/Jia Tan pattern: 0.8% historical → 50% recent = +49% shift.
        takeover_shift = 0.0
        takeover_suspect = ""
        takeover_suspect_name = ""

        if recent_commits and is_mature and total_recent >= 5:
            historical_commits = [c for c in commits if c.authored_date < taper_start]
            hist_total = len(historical_commits)

            # Historical share per contributor (using normalized identities)
            hist_counts: dict[str, int] = defaultdict(int)
            hist_names: dict[str, str] = {}
            for c in historical_commits:
                email = _normalize_email(c.author_email)
                hist_counts[email] += 1
                hist_names[email] = c.author_name

            # Build name→emails map for identity merging (same person,
            # different emails: e.g. tqdm@cdcl.ml + casper.dcl@physics.org)
            name_to_hist: dict[str, int] = defaultdict(int)
            for email, count in hist_counts.items():
                n = hist_names.get(email, "").strip().lower()
                if n:
                    name_to_hist[n] += count

            # Find the contributor with the largest upward shift.
            # Only flag genuinely minor/new contributors — not established
            # maintainers whose share naturally fluctuates.
            for identity, recent_count in author_counts.items():
                # Skip bots (dependabot, renovate, etc.)
                name = author_names.get(identity, "")
                if "[bot]" in identity or "[bot]" in name:
                    continue

                recent_pct = recent_count / total_recent * 100
                hist_pct = (hist_counts.get(identity, 0) / hist_total * 100) if hist_total > 0 else 0
                shift = recent_pct - hist_pct

                # Only flag if the contributor was minor historically (<10% of commits).
                # Established maintainers (e.g. project creator at 20%) naturally
                # fluctuate — that's not a takeover signal. The 10% threshold
                # catches the xz/Jia Tan pattern (7.6% historical → 56% recent).
                # Also check merged identity by name — catches email changes where
                # the same person uses multiple addresses (e.g. Casper da Costa-Luis
                # uses tqdm@cdcl.ml + casper.dcl@physics.org → combined 23%).
                name_key = name.strip().lower()
                merged_hist_pct = (name_to_hist.get(name_key, 0) / hist_total * 100) if (hist_total > 0 and name_key) else 0
                if hist_pct >= 10 or merged_hist_pct >= 10:
                    continue

                # Tenure guard for mega-repos: on projects with huge commit counts
                # (e.g. curl with 34K), even long-time contributors have low
                # percentages. Someone with 100+ commits spanning 4+ years is an
                # established contributor, not a takeover actor.
                # (Jia Tan: 135 commits over 2.2 years → NOT suppressed.
                #  Viktor Szakats: 1160 commits over 4.9 years → suppressed.)
                # Threshold is 4 years (not 5) to handle email identity changes
                # where a contributor switches domains mid-history.
                hist_abs = hist_counts.get(identity, 0)
                if hist_abs >= 100:
                    contributor_commits = sorted(
                        [c for c in historical_commits
                         if _normalize_email(c.author_email) == identity],
                        key=lambda c: c.authored_date,
                    )
                    if contributor_commits:
                        tenure_years = (contributor_commits[-1].authored_date
                                        - contributor_commits[0].authored_date).days / 365.25
                        if tenure_years >= 4:
                            continue

                # Org-continuity check: if the suspect's org had significant
                # historical presence, this is an internal handoff (e.g. new
                # @suse.com employee on a @suse.de project), not a hostile
                # takeover. Uses org key extraction (suse.de → "suse") to
                # handle domain changes. Skip generic domains (gmail, etc.).
                if "@" in identity:
                    suspect_domain = identity.split("@")[1]
                    if suspect_domain not in _GENERIC_EMAIL_DOMAINS:
                        suspect_org = _domain_org_key(suspect_domain)
                        domain_hist_commits = sum(
                            count for email, count in hist_counts.items()
                            if "@" in email
                            and email.split("@")[1] not in _GENERIC_EMAIL_DOMAINS
                            and _domain_org_key(email.split("@")[1]) == suspect_org
                        )
                        domain_hist_pct = (domain_hist_commits / hist_total * 100) if hist_total > 0 else 0
                        if domain_hist_pct >= 30:
                            continue  # Same org continuity

                if shift > takeover_shift:
                    takeover_shift = shift
                    takeover_suspect = identity
                    takeover_suspect_name = name

        return GitMetrics(
            total_commits=len(commits),
            commits_last_year=total_recent,
            unique_contributors=unique_contributors,
            maintainer_concentration=concentration,
            top_contributor_email=top_email,
            top_contributor_name=author_names.get(top_email, ""),
            top_contributor_commits=author_counts.get(top_email, 0),
            last_commit_date=last_commit_date,
            first_commit_date=first_commit_date,
            commits=recent_commits,
            lifetime_contributors=lifetime_contributors,
            lifetime_concentration=lifetime_concentration,
            is_mature=is_mature,
            repo_age_years=repo_age_years,
            bus_factor=bus_factor,
            bus_factor_contributors=bus_factor_names,
            elephant_factor=elephant_factor,
            elephant_factor_orgs=elephant_factor_orgs,
            inactive_contributor_ratio=inactive_ratio,
            takeover_shift=takeover_shift,
            takeover_suspect=takeover_suspect,
            takeover_suspect_name=takeover_suspect_name,
        )

    async def collect(self, repo_url: str, cutoff_date: Optional[datetime] = None) -> GitMetrics:
        """
        Collect git data for a repository.

        Args:
            repo_url: Git repository URL
            cutoff_date: Date to use as "now" for T-1 analysis

        Returns:
            GitMetrics with all calculated values
        """
        repo_path = self.clone_or_update(repo_url)
        commits = self.extract_commits(repo_path)
        return self.calculate_metrics(commits, cutoff_date)
