"""Tests for manage_labels.py.

All API calls are mocked — no real network or keychain access.
"""
import io
import json
import sys
import unittest
import contextlib
from unittest.mock import MagicMock, call, patch

sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import manage_labels  # noqa: E402
import issue_workflow_labels  # noqa: E402


FAKE_AUTH = "Basic dGVzdDp0ZXN0"  # base64("test:test")


def _make_label(name, lid):
    return {"id": lid, "name": name, "color": "000000"}


# ---------------------------------------------------------------------------
# Label creation
# ---------------------------------------------------------------------------
class TestLabelCreation(unittest.TestCase):
    """Verify create-or-skip logic for the label set."""

    @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
    @patch("manage_labels.api_get_all")
    @patch("manage_labels.api")
    def test_skips_existing_labels(self, mock_api, mock_get_all, _auth):
        # Simulate all labels already exist (paginated inventory #627)
        existing = [_make_label(l["name"], i) for i, l in enumerate(manage_labels.LABELS)]
        mock_get_all.return_value = existing

        # Patch sys.argv to avoid --dry
        with patch.object(sys, "argv", ["manage_labels.py"]):
            with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
                manage_labels.main()

        mock_get_all.assert_called()
        post_label_calls = [
            c for c in mock_api.call_args_list
            if c[0][0] == "POST" and c[0][1] == "/labels"
        ]
        self.assertEqual(len(post_label_calls), 0)

    @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
    @patch("manage_labels.api_get_all", return_value=[])
    @patch("manage_labels.api")
    def test_creates_missing_labels(self, mock_api, _get_all, _auth):
        # Simulate no existing labels
        def side_effect(method, path, auth, payload=None):
            if method == "POST" and path == "/labels":
                return {"id": 999, "name": payload["name"]}
            if method == "PUT":
                return [{"name": n} for n in (payload or {}).get("labels", [])]
            return None

        mock_api.side_effect = side_effect
        with patch.object(sys, "argv", ["manage_labels.py"]):
            with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
                manage_labels.main()

        post_calls = [
            c for c in mock_api.call_args_list
            if c[0][0] == "POST" and c[0][1] == "/labels"
        ]
        self.assertEqual(len(post_calls), len(manage_labels.LABELS))


# ---------------------------------------------------------------------------
# Dry run mode
# ---------------------------------------------------------------------------
class TestDryRun(unittest.TestCase):

    @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
    @patch("manage_labels.api_get_all", return_value=[])
    @patch("manage_labels.api")
    def test_dry_run_makes_no_writes(self, mock_api, _get_all, _auth):
        with patch.object(sys, "argv", ["manage_labels.py", "--dry"]):
            with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
                manage_labels.main()

        # Dry run should not call write methods via api()
        for c in mock_api.call_args_list:
            method = c[0][0]
            self.assertEqual(method, "GET",
                             f"Dry run should not call {method}")


# ---------------------------------------------------------------------------
# Label mapping
# ---------------------------------------------------------------------------
class TestLabelMapping(unittest.TestCase):

    @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
    @patch("manage_labels.api_get_all")
    @patch("manage_labels.api")
    def test_applies_mapping_to_issues(self, mock_api, mock_get_all, _auth):
        existing = [_make_label(l["name"], i + 1) for i, l in enumerate(manage_labels.LABELS)]
        mock_get_all.return_value = existing

        def side_effect(method, path, auth, payload=None):
            if method == "PUT":
                return [{"name": "applied"}]
            return None

        mock_api.side_effect = side_effect
        with patch.object(sys, "argv", ["manage_labels.py"]):
            with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
                manage_labels.main()

        put_calls = [c for c in mock_api.call_args_list if c[0][0] == "PUT"]
        self.assertEqual(len(put_calls), len(manage_labels.MAPPING))


# ---------------------------------------------------------------------------
# LABELS and MAPPING constants
# ---------------------------------------------------------------------------
class TestConstants(unittest.TestCase):
    """Sanity-check the hardcoded label set and mapping."""

    def test_status_in_progress_label_exists(self):
        names = [l["name"] for l in manage_labels.LABELS]
        self.assertIn("status:in-progress", names)

    def test_canonical_workflow_labels_are_defined(self):
        names = {l["name"] for l in manage_labels.LABELS}
        for spec in issue_workflow_labels.CANONICAL_LABEL_SPECS:
            self.assertIn(spec.name, names)

    def test_all_mapped_labels_are_defined(self):
        defined = {l["name"] for l in manage_labels.LABELS}
        for issue, names in manage_labels.MAPPING.items():
            for name in names:
                self.assertIn(name, defined,
                              f"Issue #{issue} maps to undefined label '{name}'")

    def test_label_colors_are_valid_hex(self):
        import re
        for label in manage_labels.LABELS:
            self.assertRegex(label["color"], r"^[0-9a-fA-F]{6}$",
                             f"Label '{label['name']}' has invalid color")


# ---------------------------------------------------------------------------
# Modes: --create-labels / --apply-mapping / --add-label (#6)
# ---------------------------------------------------------------------------
class TestModes(unittest.TestCase):

    def _methods(self, mock_api):
        return [(c[0][0], c[0][1]) for c in mock_api.call_args_list]

    @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
    @patch("manage_labels.api_get_all", return_value=[])
    @patch("manage_labels.api")
    def test_create_labels_only_no_mapping(self, mock_api, _get_all, _auth):
        def se(method, path, auth, payload=None):
            if method == "POST" and path == "/labels":
                return {"id": 1, "name": payload["name"]}
            return None
        mock_api.side_effect = se
        manage_labels.main(["--create-labels"])
        methods = self._methods(mock_api)
        self.assertTrue(any(m == ("POST", "/labels") for m in methods))
        self.assertFalse(any(m[0] == "PUT" for m in methods))  # no mapping applied

    @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
    @patch("manage_labels.api_get_all")
    @patch("manage_labels.api")
    def test_apply_mapping_only_no_label_creation(self, mock_api, mock_get_all, _auth):
        existing = [_make_label(l["name"], i + 1)
                    for i, l in enumerate(manage_labels.LABELS)]
        mock_get_all.return_value = existing

        def se(method, path, auth, payload=None):
            if method == "PUT":
                return [{"name": "applied"}]
            return None
        mock_api.side_effect = se
        manage_labels.main(["--apply-mapping"])
        methods = self._methods(mock_api)
        self.assertFalse(any(m == ("POST", "/labels") for m in methods))
        put_calls = [m for m in methods if m[0] == "PUT"]
        self.assertEqual(len(put_calls), len(manage_labels.MAPPING))

    @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
    @patch("manage_labels.api_get_all", return_value=[_make_label("chore", 5)])
    @patch("manage_labels.api")
    def test_add_label_appends_to_issue(self, mock_api, _get_all, _auth):
        def se(method, path, auth, payload=None):
            if method == "POST":
                return [{"name": "chore"}]
            return None
        mock_api.side_effect = se
        manage_labels.main(["--add-label", "42", "chore"])
        posts = [c for c in mock_api.call_args_list
                 if c[0][0] == "POST" and c[0][1] == "/issues/42/labels"]
        self.assertEqual(len(posts), 1)
        self.assertEqual(posts[0][0][3], {"labels": [5]})  # append, id 5
        # POST appends; no PUT (which would replace the whole set).
        self.assertFalse(any(c[0][0] == "PUT" for c in mock_api.call_args_list))

    @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
    @patch("manage_labels.api_get_all", return_value=[])
    @patch("manage_labels.api")
    def test_add_label_unknown_makes_no_write(self, mock_api, _get_all, _auth):
        manage_labels.main(["--add-label", "42", "ghost"])
        # No write via api() for an undefined label.
        self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list)
                        or len(mock_api.call_args_list) == 0)

    @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
    @patch("manage_labels.api_get_all", return_value=[_make_label("chore", 5)])
    @patch("manage_labels.api")
    def test_add_label_dry_makes_no_write(self, mock_api, _get_all, _auth):
        manage_labels.main(["--dry", "--add-label", "42", "chore"])
        self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list)
                        or len(mock_api.call_args_list) == 0)

    @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
    @patch("manage_labels.api")
    def test_add_label_non_numeric_issue_exits(self, mock_api, _auth):
        with self.assertRaises(SystemExit):
            manage_labels.main(["--add-label", "notanum", "chore"])


if __name__ == "__main__":
    unittest.main()
