"""GitLab REST + GraphQL client for MRs, pipelines, vulnerabilities, and findings."""

from __future__ import annotations

import gitlab
import gitlab.exceptions

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


def _gql_id(gid: str) -> int:
    """Extract numeric ID from a GraphQL global ID like 'gid://gitlab/MergeRequest/123'."""
    return int(gid.split("/")[-1]) if "/" in gid else 0


def _gql_usernames(node: dict, field: str) -> list[str]:
    """Extract usernames from a nested GraphQL {nodes: [{username}]} field."""
    items = (node.get(field) or {}).get("nodes") or []
    return [n["username"] for n in items if n.get("username")]


def _lower_or_empty(value) -> str:
    """Safely lowercase a string that might be None."""
    return (value or "").lower()


class GitLabProvider(ForgeProvider):
    """Synchronous GitLab client wrapping python-gitlab REST and GraphQL APIs."""

    def __init__(self, instance: str, token: str):
        super().__init__(instance, token)
        url = f"https://{instance}"
        self._gl = gitlab.Gitlab(url, private_token=token)
        self._gq = gitlab.GraphQL(url, token=token)
        self._instance = instance
        self._username: str | None = None
        self._auth_failed: bool = False

    def _paginate_gql(self, query: str, variables: dict, data_key: str) -> list[dict]:
        """Execute a paginated GraphQL query and return all collected nodes."""
        variables = dict(variables)
        all_nodes: list[dict] = []
        cursor = None
        for _ in range(10):
            variables["after"] = cursor
            response = self._gq.execute(query, variable_values=variables)
            project_data = (response or {}).get("project")
            if not project_data:
                break
            collection = project_data.get(data_key, {})
            all_nodes.extend(collection.get("nodes") or [])
            page_info = collection.get("pageInfo") or {}
            if not page_info.get("hasNextPage"):
                break
            cursor = page_info["endCursor"]
        return all_nodes

    def get_username(self) -> str | None:
        """Get the authenticated user's username. Cached after first call."""
        if self._username is None and not self._auth_failed:
            try:
                self._gl.auth()
                self._username = self._gl.user.username
            except (gitlab.exceptions.GitlabAuthenticationError, gitlab.exceptions.GitlabGetError):
                self._auth_failed = True
        return self._username

    def _project(self, project_path: str):
        return self._gl.projects.get(project_path)

    _MR_QUERY = """
    query ProjectMRs($fullPath: ID!, $state: MergeRequestState, $after: String) {
      project(fullPath: $fullPath) {
        mergeRequests(state: $state, first: 100, sort: UPDATED_DESC, after: $after) {
          pageInfo { hasNextPage endCursor }
          nodes {
            id
            iid
            title
            author { username }
            assignees { nodes { username } }
            reviewers { nodes { username } }
            state
            sourceBranch
            targetBranch
            webUrl
            userNotesCount
            approved
            createdAt
            updatedAt
            diffHeadSha
          }
        }
      }
    }
    """

    @staticmethod
    def _parse_mr_node(node: dict) -> MR:
        return MR(
            id=_gql_id(node.get("id", "")),
            iid=int(node.get("iid", 0)),
            title=node.get("title", ""),
            author=(node.get("author") or {}).get("username", ""),
            state=node.get("state", "").lower(),
            source_branch=node.get("sourceBranch", ""),
            target_branch=node.get("targetBranch", ""),
            web_url=node.get("webUrl", ""),
            user_notes_count=node.get("userNotesCount", 0),
            approved=bool(node.get("approved")),
            created_at=node.get("createdAt", ""),
            updated_at=node.get("updatedAt", ""),
            repo_name="",
            sha=node.get("diffHeadSha"),
            assignees=_gql_usernames(node, "assignees"),
            reviewers=_gql_usernames(node, "reviewers"),
        )

    def list_merge_requests(self, project_path: str, state: str = "opened") -> list[MR]:
        """Fetch merge requests via GraphQL, defaulting to opened state."""
        import logging
        logger = logging.getLogger(__name__)
        try:
            nodes = self._paginate_gql(
                self._MR_QUERY, {"fullPath": project_path, "state": state}, "mergeRequests"
            )
            mrs = [self._parse_mr_node(n) for n in nodes]
            logger.debug(f"list_merge_requests({project_path}): {len(mrs)} MRs")
            return mrs
        except gitlab.exceptions.GitlabAuthenticationError as exc:
            raise AuthenticationError("GitLab token expired or invalid") from exc
        except Exception as e:
            logger.error(f"list_merge_requests({project_path}) failed: {e}")
            return None

    _ISSUE_QUERY = """
    query ProjectIssues($fullPath: ID!, $state: IssuableState, $after: String) {
      project(fullPath: $fullPath) {
        issues(state: $state, first: 100, sort: UPDATED_DESC, after: $after) {
          pageInfo { hasNextPage endCursor }
          nodes {
            id
            iid
            title
            author { username }
            state
            webUrl
            userNotesCount
            createdAt
            updatedAt
            confidential
            labels { nodes { title } }
            assignees { nodes { username } }
            participants { nodes { username } }
          }
        }
      }
    }
    """

    @staticmethod
    def _parse_issue_node(node: dict) -> Issue:
        label_nodes = (node.get("labels") or {}).get("nodes") or []
        return Issue(
            id=_gql_id(node.get("id", "")),
            iid=int(node.get("iid", 0)),
            title=node.get("title", ""),
            author=(node.get("author") or {}).get("username", ""),
            state=node.get("state", "").lower(),
            web_url=node.get("webUrl", ""),
            user_notes_count=node.get("userNotesCount", 0),
            created_at=node.get("createdAt", ""),
            updated_at=node.get("updatedAt", ""),
            repo_name="",
            labels=[n["title"] for n in label_nodes if n.get("title")],
            assignees=_gql_usernames(node, "assignees"),
            participants=_gql_usernames(node, "participants"),
            confidential=bool(node.get("confidential")),
        )

    def list_issues(self, project_path: str, state: str = "opened") -> list[Issue]:
        """Fetch issues via GraphQL, defaulting to opened state."""
        import logging
        logger = logging.getLogger(__name__)
        try:
            nodes = self._paginate_gql(
                self._ISSUE_QUERY, {"fullPath": project_path, "state": state}, "issues"
            )
            issues = [self._parse_issue_node(n) for n in nodes]
            logger.debug(f"list_issues({project_path}): {len(issues)} issues")
            return issues
        except gitlab.exceptions.GitlabAuthenticationError as exc:
            raise AuthenticationError("GitLab token expired or invalid") from exc
        except Exception as e:
            logger.error(f"list_issues({project_path}) failed: {e}")
            return None

    _ISSUES_BY_IIDS_QUERY = """
    query ProjectIssuesByIids($fullPath: ID!, $iids: [String!]) {
      project(fullPath: $fullPath) {
        issues(iids: $iids, first: 100) {
          nodes {
            id iid title author { username } state webUrl userNotesCount
            createdAt updatedAt confidential
            labels { nodes { title } }
            assignees { nodes { username } }
            participants { nodes { username } }
          }
        }
      }
    }
    """

    _MRS_BY_IIDS_QUERY = """
    query ProjectMRsByIids($fullPath: ID!, $iids: [String!]) {
      project(fullPath: $fullPath) {
        mergeRequests(iids: $iids, first: 100) {
          nodes {
            id iid title author { username }
            assignees { nodes { username } }
            reviewers { nodes { username } }
            state sourceBranch targetBranch webUrl
            userNotesCount approved createdAt updatedAt diffHeadSha
          }
        }
      }
    }
    """

    def get_issues_by_iids(self, project_path: str, iids: list[int]) -> list[Issue]:
        """Fetch specific issues by iid (any state) to detect transitions."""
        import logging
        logger = logging.getLogger(__name__)
        if not iids:
            return []
        try:
            variables: dict = {"fullPath": project_path, "iids": [str(i) for i in iids]}
            response = self._gq.execute(self._ISSUES_BY_IIDS_QUERY, variable_values=variables)
            project_data = (response or {}).get("project")
            if not project_data:
                return []
            nodes = project_data.get("issues", {}).get("nodes") or []
            return [self._parse_issue_node(n) for n in nodes]
        except Exception as e:
            logger.error(f"get_issues_by_iids({project_path}) failed: {e}")
            return []

    def get_mrs_by_iids(self, project_path: str, iids: list[int]) -> list[MR]:
        """Fetch specific MRs by iid (any state) to detect transitions."""
        import logging
        logger = logging.getLogger(__name__)
        if not iids:
            return []
        try:
            variables: dict = {"fullPath": project_path, "iids": [str(i) for i in iids]}
            response = self._gq.execute(self._MRS_BY_IIDS_QUERY, variable_values=variables)
            project_data = (response or {}).get("project")
            if not project_data:
                return []
            nodes = project_data.get("mergeRequests", {}).get("nodes") or []
            return [self._parse_mr_node(n) for n in nodes]
        except Exception as e:
            logger.error(f"get_mrs_by_iids({project_path}) failed: {e}")
            return []

    def list_pipelines(
        self, project_path: str, ref: str | None = None, per_page: int = 20
    ) -> list[Pipeline]:
        """Fetch recent pipelines via REST, optionally filtered by ref."""
        import logging

        logger = logging.getLogger(__name__)

        try:
            project = self._project(project_path)
            kwargs: dict = {"get_all": False, "per_page": per_page}
            if ref is not None:
                kwargs["ref"] = ref
            raw = project.pipelines.list(**kwargs)
            pipelines = [
                Pipeline(
                    id=pip.id,
                    status=pip.status,
                    ref=pip.ref,
                    web_url=pip.web_url,
                    created_at=pip.created_at,
                    repo_name="",
                )
                for pip in raw
            ]
            logger.debug(f"list_pipelines({project_path}): {len(pipelines)} pipelines")
            return pipelines
        except gitlab.exceptions.GitlabAuthenticationError as exc:
            raise AuthenticationError("GitLab token expired or invalid") from exc
        except Exception as e:
            logger.error(f"list_pipelines({project_path}) failed: {e}")
            return None

    def get_pipeline_jobs(self, project_path: str, pipeline_id: int | str) -> list[Job]:
        """Fetch all jobs for a specific pipeline."""
        try:
            project = self._project(project_path)
            pipeline = project.pipelines.get(pipeline_id)
            raw = pipeline.jobs.list(get_all=True)
            return [
                Job(
                    id=job.id,
                    name=job.name,
                    status=job.status,
                    stage=job.stage,
                    web_url=job.web_url,
                )
                for job in raw
            ]
        except gitlab.exceptions.GitlabAuthenticationError as exc:
            raise AuthenticationError("GitLab token expired or invalid") from exc
        except Exception:
            return []

    def get_job_log(self, project_path: str, job_id: int | str) -> str:
        """Fetch the trace log of a specific job."""
        try:
            project = self._project(project_path)
            return (
                project.jobs.get(job_id, lazy=True)
                .trace()
                .decode("utf-8", errors="replace")
            )
        except gitlab.exceptions.GitlabAuthenticationError as exc:
            raise AuthenticationError("GitLab token expired or invalid") from exc
        except Exception:
            return ""

    _VULN_QUERY = """
    query ProjectVulnerabilities(
      $fullPath: ID!
      $severity: [VulnerabilitySeverity!]
      $after: String
    ) {
      project(fullPath: $fullPath) {
        vulnerabilities(
          first: 100
          severity: $severity
          sort: severity_desc
          after: $after
        ) {
          pageInfo { hasNextPage endCursor }
          nodes {
            id
            title
            severity
            state
            reportType
            detectedAt
            webUrl
            primaryIdentifier {
              name
              externalType
              externalId
            }
            scanner {
              name
            }
            dismissalReason
            stateComment
            falsePositive
            solution
            location {
              ... on VulnerabilityLocationSast {
                file
                startLine
              }
              ... on VulnerabilityLocationDependencyScanning {
                file
                dependency { package { name } version }
              }
              ... on VulnerabilityLocationContainerScanning {
                image
                dependency { package { name } version }
              }
              ... on VulnerabilityLocationSecretDetection {
                file
                startLine
              }
            }
          }
        }
      }
    }
    """

    _VALID_SEVERITIES = {"LOW", "MEDIUM", "HIGH", "CRITICAL", "INFO", "UNKNOWN"}

    @staticmethod
    def _parse_vuln_location(loc: dict) -> str | None:
        dep = loc.get("dependency") or {}
        pkg = (dep.get("package") or {}).get("name", "")
        ver = dep.get("version", "")
        dep_suffix = f" ({pkg} {ver})" if pkg else ""

        if loc.get("image"):
            return f"{loc['image']}{dep_suffix}".strip()
        if loc.get("file"):
            line = loc.get("startLine")
            base = f"{loc['file']}:{line}" if line else loc["file"]
            return f"{base}{dep_suffix}".strip()
        return None

    @staticmethod
    def _parse_vuln_node(v: dict) -> Vulnerability:
        identifier = v.get("primaryIdentifier") or {}
        dismissal = _lower_or_empty(v.get("dismissalReason"))
        return Vulnerability(
            id=_gql_id(v.get("id", "")),
            name=identifier.get("name") or v.get("title", ""),
            severity=_lower_or_empty(v.get("severity")),
            state=_lower_or_empty(v.get("state")),
            source=_lower_or_empty(v.get("reportType")),
            web_url=v.get("webUrl", ""),
            repo_name="",
            mitigation_state=dismissal or None,
            mitigation_details=v.get("stateComment") or None,
            location=GitLabProvider._parse_vuln_location(v.get("location") or {}),
            solution=v.get("solution") or None,
        )

    def _build_severity_filter(self, severity: str | None) -> list[str] | None:
        if not severity:
            return None
        upper = severity.upper()
        return [upper] if upper in self._VALID_SEVERITIES else None

    def get_vulnerabilities(
        self, project_path: str, severity: str | None = None
    ) -> list[Vulnerability]:
        """Fetch project vulnerabilities via GraphQL, optionally filtered by severity."""
        import logging

        logger = logging.getLogger(__name__)
        severity_filter = self._build_severity_filter(severity)
        variables: dict = {"fullPath": project_path}
        if severity_filter:
            variables["severity"] = severity_filter

        try:
            nodes = self._paginate_gql(self._VULN_QUERY, variables, "vulnerabilities")
            vulns = [self._parse_vuln_node(v) for v in nodes]
            logger.debug(f"{project_path}: {len(vulns)} vulns")
            return vulns
        except gitlab.exceptions.GitlabAuthenticationError as exc:
            raise AuthenticationError("GitLab token expired or invalid") from exc
        except Exception as e:
            logger.error(
                f"get_vulnerabilities({project_path}) failed: {e}", exc_info=True
            )
            return None

    _FINDINGS_QUERY = """
    query PipelineFindings($fullPath: ID!, $pipelineIid: ID!) {
      project(fullPath: $fullPath) {
        pipeline(iid: $pipelineIid) {
          securityReportFindings(first: 100) {
            nodes {
              title
              severity
              identifiers { name externalId }
              solution
            }
          }
        }
      }
    }
    """

    def get_pipeline_findings(
        self, project_path: str, pipeline_id: int | str | None, ref: str | None = None
    ) -> tuple[set[str], list]:
        """Return (identifier_names, findings) from a pipeline's security findings.

        The *ref* parameter is accepted for interface compatibility but ignored;
        GitLab uses pipeline_id to look up findings.
        """
        import logging

        from ..core.models import Finding

        logger = logging.getLogger(__name__)
        try:
            project = self._project(project_path)
            pipeline = project.pipelines.get(pipeline_id)
            iid = str(pipeline.iid)

            response = self._gq.execute(
                self._FINDINGS_QUERY,
                variable_values={"fullPath": project_path, "pipelineIid": iid},
            )

            nodes = (
                (response or {})
                .get("project", {})
                .get("pipeline", {})
                .get("securityReportFindings", {})
                .get("nodes") or []
            )
            names: set[str] = set()
            findings: list[Finding] = []
            for f in nodes:
                idents = f.get("identifiers") or []
                finding_name = None
                for ident in idents:
                    if ident.get("name"):
                        names.add(ident["name"])
                        if finding_name is None:
                            finding_name = ident["name"]
                    if ident.get("externalId"):
                        names.add(ident["externalId"])
                if finding_name:
                    findings.append(Finding(
                        name=finding_name,
                        title=f.get("title", ""),
                        severity=(f.get("severity") or "").lower(),
                        solution=f.get("solution") or None,
                    ))
            logger.debug(f"{project_path} pipeline {pipeline_id}: {len(names)} finding identifiers")
            return names, findings
        except Exception as e:
            logger.debug(f"get_pipeline_findings({project_path}, {pipeline_id}) failed: {e}")
            return set(), []

    def get_default_branch(self, project_path: str) -> str | None:
        """Return the project's default branch name, or None on error."""
        try:
            project = self._project(project_path)
            return project.default_branch
        except Exception:
            return None

    def get_project_info(self, project_path: str) -> dict:
        """Return basic project metadata (name, URL, default branch)."""
        try:
            project = self._project(project_path)
            return {
                "name": project.name,
                "web_url": project.web_url,
                "default_branch": project.default_branch,
            }
        except gitlab.exceptions.GitlabAuthenticationError as exc:
            raise AuthenticationError("GitLab token expired or invalid") from exc
        except Exception:
            return {}
