"""GitHub REST client for PRs, issues, workflow runs, and security alerts."""

from __future__ import annotations

import logging

import github
import github.GithubException

from ..core.models import MR, Issue, Job, Pipeline, Vulnerability
from .provider import AuthenticationError, ForgeProvider

logger = logging.getLogger(__name__)


_VULN_STATE_MAP = {"open": "detected", "fixed": "resolved", "dismissed": "dismissed"}


def _map_vuln_state(state: str) -> str:
    """Map GitHub vulnerability state to the unified vocabulary."""
    return _VULN_STATE_MAP.get(state, state)


def _dependabot_location(dep) -> str:
    """Format Dependabot dependency as 'manifest (pkg version)' when possible."""
    manifest = getattr(dep, "manifest_path", "") or ""
    pkg = getattr(getattr(dep, "package", None), "name", "") or ""
    if manifest and pkg:
        return f"{manifest} ({pkg})"
    return manifest or pkg or ""


def _map_pr_state(pull) -> str:
    """Map GitHub PR state to the unified state used by project-hub."""
    if pull.state == "open":
        return "opened"
    # closed: check if merged
    if pull.merged:
        return "merged"
    return "closed"


def _map_run_status(status: str, conclusion: str | None) -> str:
    """Map (status, conclusion) from a GitHub Actions workflow run to a pipeline status."""
    if status == "queued":
        return "pending"
    if status == "in_progress":
        return "running"
    if status == "waiting":
        return "waiting_for_resource"
    if status == "completed":
        mapping = {
            "success": "success",
            "failure": "failed",
            "cancelled": "canceled",
            "skipped": "skipped",
            "timed_out": "failed",
        }
        return mapping.get(conclusion or "", "failed")
    return "pending"


def _map_job_status(status: str, conclusion: str | None) -> str:
    """Map a workflow job's (status, conclusion) to a unified status."""
    if status == "completed":
        mapping = {
            "success": "success",
            "failure": "failed",
            "cancelled": "canceled",
            "skipped": "skipped",
            "timed_out": "failed",
        }
        return mapping.get(conclusion or "", "failed")
    if status == "in_progress":
        return "running"
    if status == "queued":
        return "pending"
    return status


def _is_approved(reviews) -> bool:
    """Determine approval from a list of reviews: latest per user wins.

    Approved = at least one APPROVED and no CHANGES_REQUESTED among the
    latest review per user.
    """
    latest_by_user: dict[str, object] = {}
    for review in reviews:
        user = review.user.login
        existing = latest_by_user.get(user)
        if existing is None or review.submitted_at > existing.submitted_at:
            latest_by_user[user] = review

    if not latest_by_user:
        return False

    states = {r.state for r in latest_by_user.values()}
    return "APPROVED" in states and "CHANGES_REQUESTED" not in states


class GitHubProvider(ForgeProvider):
    """Synchronous GitHub client wrapping PyGithub for read operations."""

    def __init__(self, instance: str, token: str):
        super().__init__(instance, token)
        self._gh = github.Github(auth=github.Auth.Token(token))
        self._username: str | None = None
        self._auth_failed: bool = False

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def _repo(self, project_path: str):
        return self._gh.get_repo(project_path)

    def _check_auth(self, exc: github.GithubException) -> None:
        """Raise AuthenticationError on 401, otherwise re-raise."""
        if exc.status == 401:
            raise AuthenticationError("GitHub token expired or invalid") from exc

    # ------------------------------------------------------------------
    # Username
    # ------------------------------------------------------------------

    def get_username(self) -> str | None:
        if self._username is None and not self._auth_failed:
            try:
                user = self._gh.get_user()
                self._username = user.login
            except github.GithubException as exc:
                if exc.status == 401:
                    self._auth_failed = True
                else:
                    logger.error("get_username failed: %s", exc)
        return self._username

    # ------------------------------------------------------------------
    # Merge requests (pull requests)
    # ------------------------------------------------------------------

    def _pr_to_mr(self, pull, fetch_reviews: bool = True) -> MR:
        state = _map_pr_state(pull)
        approved = False
        if fetch_reviews and pull.state == "open":
            try:
                reviews = list(pull.get_reviews())
                approved = _is_approved(reviews)
            except github.GithubException:
                pass

        return MR(
            id=pull.id,
            iid=pull.number,
            title=pull.title,
            author=pull.user.login,
            state=state,
            source_branch=pull.head.ref,
            target_branch=pull.base.ref,
            web_url=pull.html_url,
            user_notes_count=pull.comments + pull.review_comments,
            approved=approved,
            created_at=str(pull.created_at),
            updated_at=str(pull.updated_at),
            repo_name="",
            sha=pull.head.sha,
            assignees=[a.login for a in pull.assignees],
            reviewers=[r.login for r in pull.requested_reviewers],
        )

    @staticmethod
    def _state_to_gh(state: str) -> str:
        """Convert unified state to GitHub API pull state filter."""
        if state in ("opened", "open"):
            return "open"
        if state in ("merged", "closed"):
            return "closed"
        return "all"

    def list_merge_requests(self, project_path: str, state: str = "opened") -> list[MR]:
        try:
            repo = self._repo(project_path)
            gh_state = self._state_to_gh(state)
            raw = list(repo.get_pulls(state=gh_state, sort="updated", direction="desc"))

            # Post-filter: if caller wants only "merged" or only "closed", filter
            mrs = []
            for pull in raw:
                fetch = state in ("opened", "open")
                mr = self._pr_to_mr(pull, fetch_reviews=fetch)
                if state == "merged" and mr.state != "merged":
                    continue
                if state == "closed" and mr.state != "closed":
                    continue
                mrs.append(mr)

            logger.debug("list_merge_requests(%s): %d MRs", project_path, len(mrs))
            return mrs
        except github.GithubException as exc:
            self._check_auth(exc)
            logger.error("list_merge_requests(%s) failed: %s", project_path, exc)
            return []

    def get_mrs_by_iids(self, project_path: str, iids: list[int]) -> list[MR]:
        if not iids:
            return []
        try:
            repo = self._repo(project_path)
            mrs = []
            for iid in iids:
                try:
                    pull = repo.get_pull(iid)
                    mrs.append(self._pr_to_mr(pull))
                except github.GithubException as inner_exc:
                    if inner_exc.status == 401:
                        raise
                    logger.debug("get_pull(%s, %d) failed: %s", project_path, iid, inner_exc)
            return mrs
        except github.GithubException as exc:
            self._check_auth(exc)
            logger.error("get_mrs_by_iids(%s) failed: %s", project_path, exc)
            return []

    # ------------------------------------------------------------------
    # Issues
    # ------------------------------------------------------------------

    def _issue_to_model(self, iss) -> Issue:
        state = "opened" if iss.state == "open" else "closed"
        return Issue(
            id=iss.id,
            iid=iss.number,
            title=iss.title,
            author=iss.user.login,
            state=state,
            web_url=iss.html_url,
            user_notes_count=iss.comments,
            created_at=str(iss.created_at),
            updated_at=str(iss.updated_at),
            repo_name="",
            labels=[lbl.name for lbl in iss.labels],
            assignees=[a.login for a in iss.assignees],
            participants=[],
            confidential=False,
        )

    @staticmethod
    def _issue_state_to_gh(state: str) -> str:
        if state in ("opened", "open"):
            return "open"
        if state == "closed":
            return "closed"
        return "all"

    def list_issues(self, project_path: str, state: str = "opened") -> list[Issue]:
        try:
            repo = self._repo(project_path)
            gh_state = self._issue_state_to_gh(state)
            raw = list(repo.get_issues(state=gh_state, sort="updated", direction="desc"))
            # Filter out PRs — GitHub API includes them in issues
            issues = [
                self._issue_to_model(i) for i in raw if not i.pull_request
            ]
            logger.debug("list_issues(%s): %d issues", project_path, len(issues))
            return issues
        except github.GithubException as exc:
            self._check_auth(exc)
            logger.error("list_issues(%s) failed: %s", project_path, exc)
            return []

    def get_issues_by_iids(self, project_path: str, iids: list[int]) -> list[Issue]:
        if not iids:
            return []
        try:
            repo = self._repo(project_path)
            issues = []
            for iid in iids:
                try:
                    iss = repo.get_issue(iid)
                    if not iss.pull_request:
                        issues.append(self._issue_to_model(iss))
                except github.GithubException as inner_exc:
                    if inner_exc.status == 401:
                        raise
                    logger.debug("get_issue(%s, %d) failed: %s", project_path, iid, inner_exc)
            return issues
        except github.GithubException as exc:
            self._check_auth(exc)
            logger.error("get_issues_by_iids(%s) failed: %s", project_path, exc)
            return []

    # ------------------------------------------------------------------
    # Pipelines (workflow runs)
    # ------------------------------------------------------------------

    def list_pipelines(
        self, project_path: str, ref: str | None = None, per_page: int = 20
    ) -> list[Pipeline]:
        try:
            repo = self._repo(project_path)
            kwargs: dict = {}
            if ref is not None:
                kwargs["branch"] = ref
            raw = repo.get_workflow_runs(**kwargs)
            # Avoid PaginatedList slice bug (IndexError on empty/short results)
            runs = []
            for run in raw:
                runs.append(run)
                if len(runs) >= per_page:
                    break

            pipelines = [
                Pipeline(
                    id=run.id,
                    status=_map_run_status(run.status, run.conclusion),
                    ref=run.head_branch,
                    web_url=run.html_url,
                    created_at=str(run.created_at),
                    repo_name="",
                )
                for run in runs
            ]
            logger.debug("list_pipelines(%s): %d runs", project_path, len(pipelines))
            return pipelines
        except github.GithubException as exc:
            self._check_auth(exc)
            logger.error("list_pipelines(%s) failed: %s", project_path, exc)
            return []

    def get_pipeline_jobs(self, project_path: str, pipeline_id: int | str) -> list[Job]:
        try:
            repo = self._repo(project_path)
            run = repo.get_workflow_run(pipeline_id)
            raw_jobs = run.jobs()
            return [
                Job(
                    id=job.id,
                    name=job.name,
                    status=_map_job_status(job.status, job.conclusion),
                    stage=getattr(job, "workflow_name", None) or "",
                    web_url=job.html_url,
                )
                for job in raw_jobs
            ]
        except github.GithubException as exc:
            self._check_auth(exc)
            return []

    def get_job_log(self, project_path: str, job_id: int | str) -> str:
        try:
            # pylint: disable=protected-access
            _headers, data = self._gh._Github__requester.requestBlobAndCheck(
                "GET", f"/repos/{project_path}/actions/jobs/{job_id}/logs"
            )
            return data.decode("utf-8", errors="replace")
        except Exception:  # pylint: disable=broad-exception-caught
            return ""

    # ------------------------------------------------------------------
    # Vulnerabilities (3 sources)
    # ------------------------------------------------------------------

    def _fetch_dependabot(self, repo) -> list[Vulnerability]:
        try:
            alerts = list(repo.get_dependabot_alerts(state="open"))
            return [
                Vulnerability(
                    id=f"dependabot-{a.number}",
                    name=a.security_advisory.summary,
                    severity=a.security_advisory.severity.upper(),
                    state=_map_vuln_state(getattr(a, "state", "open")),
                    source="DEPENDENCY_SCANNING",
                    web_url=getattr(a, "html_url", ""),
                    repo_name="",
                    location=_dependabot_location(a.dependency),
                    solution=(a.security_advisory.description or "")[:500],
                )
                for a in alerts
            ]
        except github.GithubException as exc:
            if exc.status in (403, 404):
                logger.debug("Dependabot alerts unavailable (HTTP %d)", exc.status)
                return []
            raise

    def _fetch_codescan(self, repo) -> list[Vulnerability]:
        try:
            alerts = list(repo.get_codescan_alerts())
            vulns = []
            for a in alerts:
                sev = getattr(a.rule, "security_severity_level", None)
                if sev:
                    sev = sev.upper()
                else:
                    sev = getattr(a.rule, "severity", "MEDIUM").upper()

                loc = a.most_recent_instance.location
                location = f"{loc.path}:{loc.start_line}"

                vulns.append(Vulnerability(
                    id=f"codescan-{a.number}",
                    name=a.rule.description,
                    severity=sev,
                    state=_map_vuln_state(getattr(a, "state", "open")),
                    source="SAST",
                    web_url=getattr(a, "html_url", ""),
                    repo_name="",
                    location=location,
                ))
            return vulns
        except github.GithubException as exc:
            if exc.status in (403, 404):
                logger.debug("Code scanning alerts unavailable (HTTP %d)", exc.status)
                return []
            raise

    def _fetch_secret_scanning(self, repo) -> list[Vulnerability]:
        try:
            alerts = list(repo.get_secret_scanning_alerts(state="open"))
            vulns = []
            for a in alerts:
                loc = getattr(a, "first_location_detected", None)
                location = None
                if loc:
                    path = getattr(loc, "path", None)
                    line = getattr(loc, "start_line", None)
                    if path and line:
                        location = f"{path}:{line}"
                    elif path:
                        location = path

                vulns.append(Vulnerability(
                    id=f"secret-{a.number}",
                    name=a.secret_type_display_name,
                    severity="HIGH",
                    state=_map_vuln_state(getattr(a, "state", "open")),
                    source="SECRET_DETECTION",
                    web_url=getattr(a, "html_url", ""),
                    repo_name="",
                    location=location,
                    mitigation_state=getattr(a, "resolution", None),
                    mitigation_details=getattr(a, "resolution_comment", None),
                ))
            return vulns
        except github.GithubException as exc:
            if exc.status in (403, 404):
                logger.debug("Secret scanning alerts unavailable (HTTP %d)", exc.status)
                return []
            raise

    def get_vulnerabilities(
        self, project_path: str, severity: str | None = None
    ) -> list[Vulnerability]:
        try:
            repo = self._repo(project_path)
            vulns: list[Vulnerability] = []
            vulns.extend(self._fetch_dependabot(repo))
            vulns.extend(self._fetch_codescan(repo))
            vulns.extend(self._fetch_secret_scanning(repo))

            if severity:
                target = severity.upper()
                vulns = [v for v in vulns if v.severity == target]

            logger.debug("get_vulnerabilities(%s): %d vulns", project_path, len(vulns))
            return vulns
        except github.GithubException as exc:
            self._check_auth(exc)
            logger.error("get_vulnerabilities(%s) failed: %s", project_path, exc)
            return []

    # ------------------------------------------------------------------
    # Pipeline findings (code scanning, ref-based)
    # ------------------------------------------------------------------

    def get_pipeline_findings(
        self,
        project_path: str,
        pipeline_id: int | str | None,
        ref: str | None = None,
    ) -> set[str]:
        try:
            repo = self._repo(project_path)
            kwargs: dict = {}
            if ref:
                kwargs["ref"] = ref
            alerts = list(repo.get_codescan_alerts(**kwargs))
            return {a.rule.description for a in alerts}
        except github.GithubException as exc:
            if exc.status in (403, 404):
                logger.debug("Code scanning unavailable for findings (HTTP %d)", exc.status)
                return set()
            self._check_auth(exc)
            logger.error("get_pipeline_findings(%s) failed: %s", project_path, exc)
            return set()

    # ------------------------------------------------------------------
    # Project info
    # ------------------------------------------------------------------

    def get_default_branch(self, project_path: str) -> str | None:
        try:
            repo = self._repo(project_path)
            return repo.default_branch
        except Exception:  # pylint: disable=broad-exception-caught
            return None

    def get_project_info(self, project_path: str) -> dict:
        try:
            repo = self._repo(project_path)
            return {
                "name": repo.name,
                "web_url": repo.html_url,
                "default_branch": repo.default_branch,
            }
        except github.GithubException as exc:
            self._check_auth(exc)
            logger.error("get_project_info(%s) failed: %s", project_path, exc)
            return {}
