"""Extended tests for GitLabProvider methods not covered by test_forge_gitlab.py."""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest
import gitlab.exceptions

from project_hub.forge.gitlab import GitLabProvider


# ---------------------------------------------------------------------------
# Helpers: build a mocked client with GraphQL + REST stubs
# ---------------------------------------------------------------------------

def _make_client(gql_return=None, gql_side_effect=None, rest_project=None):
    """Return (client, mock_gl, mock_gq) with the given overrides."""
    mock_gl = MagicMock()
    mock_gq = MagicMock()

    if gql_return is not None:
        mock_gq.execute.return_value = gql_return
    if gql_side_effect is not None:
        mock_gq.execute.side_effect = gql_side_effect

    if rest_project is not None:
        mock_gl.projects.get.return_value = rest_project

    with patch("project_hub.forge.gitlab.gitlab.Gitlab", return_value=mock_gl), \
         patch("project_hub.forge.gitlab.gitlab.GraphQL", return_value=mock_gq):
        client = GitLabProvider("gitlab.com", "fake-token")

    return client, mock_gl, mock_gq


# ---------------------------------------------------------------------------
# GraphQL response builders
# ---------------------------------------------------------------------------

def _mr_node(iid, title="MR title", author="alice", state="opened",
             source_branch="feature/x", target_branch="main",
             assignees=None, reviewers=None, sha="deadbeef"):
    node = {
        "id": f"gid://gitlab/MergeRequest/{iid * 100}",
        "iid": str(iid),
        "title": title,
        "author": {"username": author},
        "state": state,
        "sourceBranch": source_branch,
        "targetBranch": target_branch,
        "webUrl": f"https://gitlab.com/group/proj/-/merge_requests/{iid}",
        "userNotesCount": 2,
        "approved": False,
        "createdAt": "2024-06-01T00:00:00Z",
        "updatedAt": "2024-06-02T00:00:00Z",
        "diffHeadSha": sha,
    }
    if assignees is not None:
        node["assignees"] = {"nodes": [{"username": u} for u in assignees]}
    if reviewers is not None:
        node["reviewers"] = {"nodes": [{"username": u} for u in reviewers]}
    return node


def _issue_node(iid, title="Issue title", author="bob", state="opened",
                labels=None, assignees=None, participants=None,
                confidential=False, milestone=None):
    node = {
        "id": f"gid://gitlab/Issue/{iid * 100}",
        "iid": str(iid),
        "title": title,
        "author": {"username": author},
        "state": state,
        "webUrl": f"https://gitlab.com/group/proj/-/issues/{iid}",
        "userNotesCount": 1,
        "createdAt": "2024-03-01T00:00:00Z",
        "updatedAt": "2024-03-10T00:00:00Z",
        "confidential": confidential,
    }
    if labels is not None:
        node["labels"] = {"nodes": [{"title": lb} for lb in labels]}
    if assignees is not None:
        node["assignees"] = {"nodes": [{"username": u} for u in assignees]}
    if participants is not None:
        node["participants"] = {"nodes": [{"username": u} for u in participants]}
    return node


def _vuln_node(vid, title="CVE-2024-0001", severity="HIGH", state="DETECTED",
               report_type="SAST", location=None, solution=None,
               identifier_name=None, dismissal_reason=None, state_comment=None):
    node = {
        "id": f"gid://gitlab/Vulnerability/{vid}",
        "title": title,
        "severity": severity,
        "state": state,
        "reportType": report_type,
        "detectedAt": "2024-05-01T00:00:00Z",
        "webUrl": f"https://gitlab.com/group/proj/-/security/vulnerabilities/{vid}",
        "scanner": {"name": report_type},
        "dismissalReason": dismissal_reason,
        "stateComment": state_comment,
        "falsePositive": False,
        "solution": solution,
    }
    if identifier_name:
        node["primaryIdentifier"] = {"name": identifier_name, "externalType": "cve", "externalId": identifier_name}
    else:
        node["primaryIdentifier"] = None
    if location is not None:
        node["location"] = location
    else:
        node["location"] = {}
    return node


def _wrap_mrs(nodes):
    return {"project": {"mergeRequests": {"nodes": nodes}}}


def _wrap_issues(nodes):
    return {"project": {"issues": {"nodes": nodes}}}


def _wrap_vulns(nodes):
    return {"project": {"vulnerabilities": {"nodes": nodes}}}


def _wrap_findings(finding_nodes):
    return {
        "project": {
            "pipeline": {
                "securityReportFindings": {
                    "nodes": finding_nodes,
                }
            }
        }
    }


# ===========================================================================
# 1. get_mrs_by_iids
# ===========================================================================

class TestGetMrsByIids:

    def test_returns_matching_mrs(self):
        nodes = [
            _mr_node(1, title="First MR", author="alice"),
            _mr_node(5, title="Fifth MR", author="bob"),
        ]
        client, _, mock_gq = _make_client(gql_return=_wrap_mrs(nodes))
        result = client.get_mrs_by_iids("group/proj", [1, 5])

        assert len(result) == 2
        assert result[0].iid == 1
        assert result[0].title == "First MR"
        assert result[0].author == "alice"
        assert result[1].iid == 5
        assert result[1].title == "Fifth MR"
        assert result[1].author == "bob"

        # Verify iids were passed as strings to the query
        call_kwargs = mock_gq.execute.call_args
        variables = call_kwargs[1].get("variable_values") or call_kwargs[0][1] if len(call_kwargs[0]) > 1 else call_kwargs[1]["variable_values"]
        assert variables["iids"] == ["1", "5"]

    def test_empty_iids_returns_empty_list(self):
        client, _, mock_gq = _make_client(gql_return=_wrap_mrs([]))
        result = client.get_mrs_by_iids("group/proj", [])

        assert result == []
        # GraphQL should not even be called
        mock_gq.execute.assert_not_called()

    def test_some_iids_not_found(self):
        """Request 3 iids, API only returns 1 -- result has just that one."""
        nodes = [_mr_node(3, title="Third MR")]
        client, _, _ = _make_client(gql_return=_wrap_mrs(nodes))
        result = client.get_mrs_by_iids("group/proj", [1, 3, 99])

        assert len(result) == 1
        assert result[0].iid == 3
        assert result[0].title == "Third MR"

    def test_network_error_returns_empty(self):
        client, _, _ = _make_client(gql_side_effect=Exception("timeout"))
        result = client.get_mrs_by_iids("group/proj", [1])

        assert result == []


# ===========================================================================
# 2. get_issues_by_iids
# ===========================================================================

class TestGetIssuesByIids:

    def test_returns_matching_issues(self):
        nodes = [
            _issue_node(10, title="Login bug", labels=["bug"]),
            _issue_node(20, title="Feature request", labels=["enhancement"]),
        ]
        client, _, mock_gq = _make_client(gql_return=_wrap_issues(nodes))
        result = client.get_issues_by_iids("group/proj", [10, 20])

        assert len(result) == 2
        assert result[0].iid == 10
        assert result[0].title == "Login bug"
        assert result[0].labels == ["bug"]
        assert result[1].iid == 20
        assert result[1].labels == ["enhancement"]

    def test_empty_iids_returns_empty_list(self):
        client, _, mock_gq = _make_client(gql_return=_wrap_issues([]))
        result = client.get_issues_by_iids("group/proj", [])

        assert result == []
        mock_gq.execute.assert_not_called()

    def test_network_error_returns_empty(self):
        client, _, _ = _make_client(gql_side_effect=Exception("connection reset"))
        result = client.get_issues_by_iids("group/proj", [1])

        assert result == []


# ===========================================================================
# 3. get_pipeline_findings
# ===========================================================================

class TestGetPipelineFindings:

    def test_returns_finding_identifiers(self):
        findings = [
            {
                "title": "SQL Injection",
                "severity": "HIGH",
                "identifiers": [
                    {"name": "CWE-89", "externalId": "CWE-89"},
                    {"name": "CVE-2024-1234", "externalId": "CVE-2024-1234"},
                ],
                "solution": "Parameterize queries",
            },
            {
                "title": "XSS",
                "severity": "MEDIUM",
                "identifiers": [
                    {"name": "CWE-79", "externalId": "CWE-79"},
                ],
                "solution": None,
            },
        ]
        # REST mock: project.pipelines.get(42) returns an object with .iid
        project_mock = MagicMock()
        pipeline_mock = MagicMock()
        pipeline_mock.iid = 99
        project_mock.pipelines.get.return_value = pipeline_mock

        client, mock_gl, mock_gq = _make_client(
            gql_return=_wrap_findings(findings),
            rest_project=project_mock,
        )
        result = client.get_pipeline_findings("group/proj", 42)

        assert isinstance(result, set)
        assert "CWE-89" in result
        assert "CVE-2024-1234" in result
        assert "CWE-79" in result
        assert len(result) == 3

    def test_pipeline_not_found_returns_empty(self):
        project_mock = MagicMock()
        project_mock.pipelines.get.side_effect = Exception("404 Not Found")

        client, _, _ = _make_client(rest_project=project_mock)
        result = client.get_pipeline_findings("group/proj", 999)

        assert result == set()

    def test_graphql_error_returns_empty(self):
        project_mock = MagicMock()
        pipeline_mock = MagicMock()
        pipeline_mock.iid = 10
        project_mock.pipelines.get.return_value = pipeline_mock

        client, _, mock_gq = _make_client(
            gql_side_effect=Exception("403 Forbidden"),
            rest_project=project_mock,
        )
        result = client.get_pipeline_findings("group/proj", 42)

        assert result == set()

    def test_findings_with_no_identifiers(self):
        findings = [
            {
                "title": "Unknown finding",
                "severity": "LOW",
                "identifiers": [],
                "solution": None,
            },
        ]
        project_mock = MagicMock()
        pipeline_mock = MagicMock()
        pipeline_mock.iid = 5
        project_mock.pipelines.get.return_value = pipeline_mock

        client, _, _ = _make_client(
            gql_return=_wrap_findings(findings),
            rest_project=project_mock,
        )
        result = client.get_pipeline_findings("group/proj", 10)

        assert result == set()

    def test_findings_with_duplicate_identifiers(self):
        """Same identifier name across two findings should appear once in the set."""
        findings = [
            {"title": "A", "severity": "HIGH", "identifiers": [{"name": "CWE-89", "externalId": "CWE-89"}], "solution": None},
            {"title": "B", "severity": "HIGH", "identifiers": [{"name": "CWE-89", "externalId": "CWE-89"}], "solution": None},
        ]
        project_mock = MagicMock()
        pipeline_mock = MagicMock()
        pipeline_mock.iid = 7
        project_mock.pipelines.get.return_value = pipeline_mock

        client, _, _ = _make_client(
            gql_return=_wrap_findings(findings),
            rest_project=project_mock,
        )
        result = client.get_pipeline_findings("group/proj", 7)

        assert result == {"CWE-89"}


# ===========================================================================
# 4. get_default_branch
# ===========================================================================

class TestGetDefaultBranch:

    def test_returns_default_branch(self):
        project_mock = MagicMock()
        project_mock.default_branch = "develop"

        client, _, _ = _make_client(rest_project=project_mock)
        result = client.get_default_branch("group/proj")

        assert result == "develop"

    def test_project_not_found_returns_none(self):
        mock_gl = MagicMock()
        mock_gl.projects.get.side_effect = Exception("404 Not Found")

        with patch("project_hub.forge.gitlab.gitlab.Gitlab", return_value=mock_gl), \
             patch("project_hub.forge.gitlab.gitlab.GraphQL", return_value=MagicMock()):
            client = GitLabProvider("gitlab.com", "fake-token")
            result = client.get_default_branch("group/nonexistent")

        assert result is None

    def test_returns_main_when_main(self):
        project_mock = MagicMock()
        project_mock.default_branch = "main"

        client, _, _ = _make_client(rest_project=project_mock)
        assert client.get_default_branch("group/proj") == "main"


# ===========================================================================
# 5. _parse_mr_node with assignees and reviewers
# ===========================================================================

class TestParseMrNode:

    def test_with_assignees_and_reviewers(self):
        node = _mr_node(1, assignees=["alice", "charlie"], reviewers=["bob", "dave"])
        mr = GitLabProvider._parse_mr_node(node)

        assert mr.assignees == ["alice", "charlie"]
        assert mr.reviewers == ["bob", "dave"]

    def test_with_empty_assignees_and_reviewers(self):
        node = _mr_node(2)
        node["assignees"] = {"nodes": []}
        node["reviewers"] = {"nodes": []}
        mr = GitLabProvider._parse_mr_node(node)

        assert mr.assignees == []
        assert mr.reviewers == []

    def test_with_missing_assignees_and_reviewers_keys(self):
        """No assignees/reviewers keys at all -- should default to empty lists."""
        node = _mr_node(3)
        node.pop("assignees", None)
        node.pop("reviewers", None)
        mr = GitLabProvider._parse_mr_node(node)

        assert mr.assignees == []
        assert mr.reviewers == []

    def test_with_none_assignees_value(self):
        """assignees is explicitly None (GraphQL can return null)."""
        node = _mr_node(4)
        node["assignees"] = None
        node["reviewers"] = None
        mr = GitLabProvider._parse_mr_node(node)

        assert mr.assignees == []
        assert mr.reviewers == []

    def test_parses_gid_correctly(self):
        node = _mr_node(7, title="GID test")
        mr = GitLabProvider._parse_mr_node(node)

        assert mr.id == 700  # 7 * 100
        assert mr.iid == 7
        assert mr.sha == "deadbeef"

    def test_parses_state_lowercase(self):
        node = _mr_node(8)
        node["state"] = "MERGED"
        mr = GitLabProvider._parse_mr_node(node)

        assert mr.state == "merged"

    def test_missing_author(self):
        node = _mr_node(9)
        node["author"] = None
        mr = GitLabProvider._parse_mr_node(node)

        assert mr.author == ""


# ===========================================================================
# 6. _parse_vuln_node and _parse_vuln_location
# ===========================================================================

class TestParseVulnNode:

    def test_sast_with_file_and_line(self):
        loc = {"file": "src/auth.py", "startLine": 42}
        node = _vuln_node(100, identifier_name="CVE-2024-0001", location=loc)
        vuln = GitLabProvider._parse_vuln_node(node)

        assert vuln.id == 100
        assert vuln.name == "CVE-2024-0001"
        assert vuln.severity == "high"
        assert vuln.state == "detected"
        assert vuln.source == "sast"
        assert vuln.location == "src/auth.py:42"

    def test_dependency_scanning_with_package(self):
        loc = {
            "file": "requirements.txt",
            "dependency": {"package": {"name": "requests"}, "version": "2.28.0"},
        }
        node = _vuln_node(200, report_type="DEPENDENCY_SCANNING",
                          identifier_name="CVE-2024-5678", location=loc)
        vuln = GitLabProvider._parse_vuln_node(node)

        assert vuln.source == "dependency_scanning"
        # location now includes the dependency package when available
        assert vuln.location == "requirements.txt (requests 2.28.0)"

    def test_container_scanning_with_image(self):
        loc = {
            "image": "registry.example.com/app:latest",
            "dependency": {"package": {"name": "libssl"}, "version": "1.1.1"},
        }
        node = _vuln_node(300, report_type="CONTAINER_SCANNING",
                          identifier_name="CVE-2024-9999", location=loc)
        vuln = GitLabProvider._parse_vuln_node(node)

        assert vuln.source == "container_scanning"
        assert "registry.example.com/app:latest" in vuln.location
        assert "libssl" in vuln.location
        assert "1.1.1" in vuln.location

    def test_no_location(self):
        node = _vuln_node(400, identifier_name="CVE-2024-0000", location={})
        vuln = GitLabProvider._parse_vuln_node(node)

        assert vuln.location is None

    def test_unknown_location_type(self):
        """Location with unrecognized keys (no file, no image) -- returns None."""
        loc = {"someOtherField": "value"}
        node = _vuln_node(500, identifier_name="CVE-2024-1111", location=loc)
        vuln = GitLabProvider._parse_vuln_node(node)

        assert vuln.location is None

    def test_file_without_line(self):
        loc = {"file": "Dockerfile"}
        node = _vuln_node(600, identifier_name="CVE-2024-2222", location=loc)
        vuln = GitLabProvider._parse_vuln_node(node)

        assert vuln.location == "Dockerfile"

    def test_uses_title_when_no_identifier(self):
        node = _vuln_node(700, title="Hardcoded Password", identifier_name=None)
        vuln = GitLabProvider._parse_vuln_node(node)

        assert vuln.name == "Hardcoded Password"

    def test_dismissal_reason_and_comment(self):
        node = _vuln_node(800, identifier_name="CVE-X",
                          dismissal_reason="ACCEPTABLE_RISK",
                          state_comment="We accept this risk.")
        vuln = GitLabProvider._parse_vuln_node(node)

        assert vuln.mitigation_state == "acceptable_risk"
        assert vuln.mitigation_details == "We accept this risk."

    def test_solution_parsed(self):
        node = _vuln_node(900, identifier_name="CVE-Y", solution="Upgrade to 2.0")
        vuln = GitLabProvider._parse_vuln_node(node)

        assert vuln.solution == "Upgrade to 2.0"

    def test_container_image_without_dependency(self):
        """Image present but no dependency info."""
        loc = {"image": "nginx:latest"}
        node = _vuln_node(1000, identifier_name="CVE-Z", location=loc)
        vuln = GitLabProvider._parse_vuln_node(node)

        assert "nginx:latest" in vuln.location


class TestParseVulnLocation:

    def test_file_with_line(self):
        result = GitLabProvider._parse_vuln_location({"file": "app.py", "startLine": 10})
        assert result == "app.py:10"

    def test_file_without_line(self):
        result = GitLabProvider._parse_vuln_location({"file": "app.py"})
        assert result == "app.py"

    def test_image_with_dependency(self):
        loc = {
            "image": "myimage:v1",
            "dependency": {"package": {"name": "openssl"}, "version": "3.0.0"},
        }
        result = GitLabProvider._parse_vuln_location(loc)
        assert result == "myimage:v1 (openssl 3.0.0)"

    def test_image_without_dependency(self):
        loc = {"image": "myimage:v1"}
        result = GitLabProvider._parse_vuln_location(loc)
        assert "myimage:v1" in result

    def test_empty_location(self):
        result = GitLabProvider._parse_vuln_location({})
        assert result is None

    def test_unrecognized_keys(self):
        result = GitLabProvider._parse_vuln_location({"blobPath": "/some/path"})
        assert result is None


# ===========================================================================
# 7. _parse_issue_node with full data
# ===========================================================================

class TestParseIssueNode:

    def test_with_labels_assignees_participants(self):
        node = _issue_node(
            10, title="Deploy broken", labels=["bug", "P0"],
            assignees=["alice", "charlie"],
            participants=["alice", "bob", "charlie"],
            confidential=True,
        )
        issue = GitLabProvider._parse_issue_node(node)

        assert issue.id == 1000
        assert issue.iid == 10
        assert issue.title == "Deploy broken"
        assert issue.author == "bob"
        assert issue.state == "opened"
        assert issue.labels == ["bug", "P0"]
        assert issue.assignees == ["alice", "charlie"]
        assert issue.participants == ["alice", "bob", "charlie"]
        assert issue.confidential is True
        assert issue.web_url == "https://gitlab.com/group/proj/-/issues/10"

    def test_with_none_optional_fields(self):
        """labels, assignees, participants all None from GraphQL."""
        node = _issue_node(20, title="Bare issue")
        node["labels"] = None
        node["assignees"] = None
        node["participants"] = None
        issue = GitLabProvider._parse_issue_node(node)

        assert issue.labels == []
        assert issue.assignees == []
        assert issue.participants == []

    def test_with_missing_optional_keys(self):
        """keys entirely absent from the dict."""
        node = _issue_node(30)
        node.pop("labels", None)
        node.pop("assignees", None)
        node.pop("participants", None)
        node.pop("confidential", None)
        issue = GitLabProvider._parse_issue_node(node)

        assert issue.labels == []
        assert issue.assignees == []
        assert issue.participants == []
        assert issue.confidential is False

    def test_closed_state_lowercase(self):
        node = _issue_node(40, state="CLOSED")
        issue = GitLabProvider._parse_issue_node(node)

        assert issue.state == "closed"

    def test_missing_author(self):
        node = _issue_node(50)
        node["author"] = None
        issue = GitLabProvider._parse_issue_node(node)

        assert issue.author == ""


# ===========================================================================
# 8. get_mrs (list_merge_requests) with pagination / edge cases
# ===========================================================================

class TestListMergeRequestsEdgeCases:

    def test_single_page_returns_results(self):
        nodes = [_mr_node(1, title="Alpha"), _mr_node(2, title="Beta")]
        client, _, _ = _make_client(gql_return=_wrap_mrs(nodes))
        result = client.list_merge_requests("group/proj")

        assert len(result) == 2
        assert result[0].title == "Alpha"
        assert result[1].title == "Beta"

    def test_empty_project_data_returns_empty(self):
        """GraphQL returns project: null."""
        client, _, _ = _make_client(gql_return={"project": None})
        result = client.list_merge_requests("group/proj")

        assert result == []

    def test_empty_nodes_returns_empty(self):
        client, _, _ = _make_client(gql_return=_wrap_mrs([]))
        result = client.list_merge_requests("group/proj")

        assert result == []

    def test_state_parameter_forwarded(self):
        """Verify the state variable is passed to the GraphQL query."""
        client, _, mock_gq = _make_client(gql_return=_wrap_mrs([]))
        client.list_merge_requests("group/proj", state="merged")

        call_kwargs = mock_gq.execute.call_args
        variables = call_kwargs[1].get("variable_values") or call_kwargs[0][1] if len(call_kwargs[0]) > 1 else call_kwargs[1]["variable_values"]
        assert variables["state"] == "merged"


# ===========================================================================
# 9. get_vulnerabilities GraphQL fallback / error handling
# ===========================================================================

class TestGetVulnerabilitiesExtended:

    def test_graphql_returns_vulns(self):
        nodes = [
            _vuln_node(100, identifier_name="CVE-2024-0001", severity="HIGH"),
            _vuln_node(200, identifier_name="CVE-2024-0002", severity="MEDIUM"),
        ]
        client, _, _ = _make_client(gql_return=_wrap_vulns(nodes))
        result = client.get_vulnerabilities("group/proj")

        assert len(result) == 2
        assert result[0].name == "CVE-2024-0001"
        assert result[0].severity == "high"
        assert result[1].name == "CVE-2024-0002"
        assert result[1].severity == "medium"

    def test_graphql_error_returns_empty(self):
        client, _, _ = _make_client(gql_side_effect=Exception("500 Internal Server Error"))
        result = client.get_vulnerabilities("group/proj")

        assert result == []

    def test_auth_error_raises(self):
        client, _, _ = _make_client(
            gql_side_effect=gitlab.exceptions.GitlabAuthenticationError("401", 401)
        )
        with pytest.raises(Exception):
            client.get_vulnerabilities("group/proj")

    def test_severity_filter_forwarded(self):
        client, _, mock_gq = _make_client(gql_return=_wrap_vulns([]))
        client.get_vulnerabilities("group/proj", severity="critical")

        call_kwargs = mock_gq.execute.call_args
        variables = call_kwargs[1].get("variable_values") or call_kwargs[0][1] if len(call_kwargs[0]) > 1 else call_kwargs[1]["variable_values"]
        assert variables["severity"] == ["CRITICAL"]

    def test_invalid_severity_not_forwarded(self):
        client, _, mock_gq = _make_client(gql_return=_wrap_vulns([]))
        client.get_vulnerabilities("group/proj", severity="nonsense")

        call_kwargs = mock_gq.execute.call_args
        variables = call_kwargs[1].get("variable_values") or call_kwargs[0][1] if len(call_kwargs[0]) > 1 else call_kwargs[1]["variable_values"]
        assert "severity" not in variables

    def test_project_null_returns_empty(self):
        client, _, _ = _make_client(gql_return={"project": None})
        result = client.get_vulnerabilities("group/proj")

        assert result == []


# ===========================================================================
# 10. get_username
# ===========================================================================

class TestGetUsername:

    def test_returns_username(self):
        mock_gl = MagicMock()
        mock_gl.user = MagicMock()
        mock_gl.user.username = "baptiste"

        with patch("project_hub.forge.gitlab.gitlab.Gitlab", return_value=mock_gl), \
             patch("project_hub.forge.gitlab.gitlab.GraphQL", return_value=MagicMock()):
            client = GitLabProvider("gitlab.com", "fake-token")
            result = client.get_username()

        assert result == "baptiste"
        mock_gl.auth.assert_called_once()

    def test_caches_after_first_call(self):
        mock_gl = MagicMock()
        mock_gl.user = MagicMock()
        mock_gl.user.username = "baptiste"

        with patch("project_hub.forge.gitlab.gitlab.Gitlab", return_value=mock_gl), \
             patch("project_hub.forge.gitlab.gitlab.GraphQL", return_value=MagicMock()):
            client = GitLabProvider("gitlab.com", "fake-token")
            client.get_username()
            client.get_username()
            client.get_username()

        mock_gl.auth.assert_called_once()

    def test_auth_failure_returns_none(self):
        mock_gl = MagicMock()
        mock_gl.auth.side_effect = gitlab.exceptions.GitlabAuthenticationError("401 Unauthorized")

        with patch("project_hub.forge.gitlab.gitlab.Gitlab", return_value=mock_gl), \
             patch("project_hub.forge.gitlab.gitlab.GraphQL", return_value=MagicMock()):
            client = GitLabProvider("gitlab.com", "fake-token")
            result = client.get_username()

        assert result is None

    def test_auth_failure_does_not_retry(self):
        """After auth failure, subsequent calls return None without retrying."""
        mock_gl = MagicMock()
        mock_gl.auth.side_effect = gitlab.exceptions.GitlabAuthenticationError("401 Unauthorized")

        with patch("project_hub.forge.gitlab.gitlab.Gitlab", return_value=mock_gl), \
             patch("project_hub.forge.gitlab.gitlab.GraphQL", return_value=MagicMock()):
            client = GitLabProvider("gitlab.com", "fake-token")
            assert client.get_username() is None
            assert client.get_username() is None

        # auth() was only attempted once, not twice
        mock_gl.auth.assert_called_once()
