"""Tests for the dependency-upgrade changelog/impact-analysis scripts.

These guard the changelog fetcher used by the weekly `upgrade-deps` workflow.
That job only runs once a week, so without these tests a regression could sit
undetected for days. Two layers:

* **Unit** tests (no network) lock in the tricky parsing logic — monorepo tag
  formats, GitHub-URL parsing, and the "changelog not found" notice.
* **Integration** tests (live network, ``@pytest.mark.integration``) validate
  against the exact packages that previously failed to produce a changelog:
  ``langchain-aws`` (monorepo ``pkg==ver`` tags) and ``google-auth`` (metadata
  pointing at a monorepo subtree). They *skip* (not fail) when the network or
  GitHub API is unavailable, so a transient outage won't break CI, but a real
  parsing/resolution regression still fails the assertions.
"""

import importlib.util
import json
import re
import urllib.error
from pathlib import Path

import pytest
from packaging.version import Version

SCRIPTS_DIR = Path(__file__).resolve().parent.parent / ".github" / "scripts"


def _load(module_name: str):
    spec = importlib.util.spec_from_file_location(module_name, SCRIPTS_DIR / f"{module_name}.py")
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


cl = _load("generate_changelog_summary")

# Connection-level failures we treat as "skip" rather than "fail".
_NETWORK_ERRORS = (urllib.error.URLError, TimeoutError, ConnectionError, OSError)


def _net(fn, *args, **kwargs):
    """Run a network call; skip the test on connectivity/rate-limit errors.

    Assertion errors and genuine logic bugs still propagate — only transient
    infrastructure problems are swallowed into a skip.
    """
    try:
        return fn(*args, **kwargs)
    except urllib.error.HTTPError as exc:  # subclass of URLError
        if exc.code in (403, 429, 500, 502, 503, 504):
            pytest.skip(f"GitHub/PyPI unavailable or rate-limited: {exc}")
        raise
    except _NETWORK_ERRORS as exc:
        pytest.skip(f"network unavailable: {exc}")


class _FakeResp:
    """Minimal stand-in for an ``urlopen`` response (context manager)."""

    def __init__(self, payload: bytes):
        self._payload = payload
        self.headers = {}

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        return False

    def read(self):
        return self._payload


def _fake_releases(monkeypatch, releases):
    """Patch the module's urlopen to return ``releases`` as the GitHub API would."""
    payload = json.dumps(releases).encode()
    monkeypatch.setattr(cl.urllib.request, "urlopen", lambda *a, **k: _FakeResp(payload))


# ---------------------------------------------------------------------------
# Unit: parse_tag_version (monorepo + plain tag formats)
# ---------------------------------------------------------------------------

class TestParseTagVersion:

    @pytest.mark.parametrize(
        "tag,package,expected",
        [
            # plain tags carry no package name and are always accepted
            ("1.6.0", None, "1.6.0"),
            ("v1.6.0", None, "1.6.0"),
            ("V1.6.0", None, "1.6.0"),
            ("release-2.3.0", None, "2.3.0"),
            ("release/2.3.0", None, "2.3.0"),
            ("1.6.0rc1", None, "1.6.0rc1"),
            ("1.6.0-rc1", None, "1.6.0rc1"),
            ("1.2.3.post1", None, "1.2.3.post1"),
            # monorepo "pkg==ver" tags (langchain style — the #190 failure)
            ("langchain-aws==1.6.0", "langchain-aws", "1.6.0"),
            ("langchain-aws==1.5.1", "langchain-aws", "1.5.1"),
            # other monorepo separators
            ("langchain-aws@1.6.0", "langchain-aws", "1.6.0"),
            ("langchain-aws/1.6.0", "langchain-aws", "1.6.0"),
            ("langchain-aws-1.6.0", "langchain-aws", "1.6.0"),
            ("google-auth-v2.55.0", "google-auth", "2.55.0"),  # the #189 failure
            # normalization: underscores/case differences still match
            ("langchain_aws==1.6.0", "langchain-aws", "1.6.0"),
            # sibling packages sharing a monorepo must be rejected
            ("langgraph-checkpoint-aws==1.1.1", "langchain-aws", None),
            ("langchain-agentcore-codeinterpreter==0.0.4", "langchain-aws", None),
            ("google-cloud-storage-v2.1.0", "google-auth", None),
            ("langchain-core==1.4.0", "langchain", None),  # core != langchain
            ("langchain==1.3.1", "langchain", "1.3.1"),
            # garbage
            ("not-a-version", None, None),
            ("", None, None),
        ],
    )
    def test_parse(self, tag, package, expected):
        got = cl.parse_tag_version(tag, package)
        assert got == (None if expected is None else Version(expected))

    def test_package_name_starting_with_v_not_mangled(self):
        # A "v" prefix is only stripped when it precedes a digit.
        assert cl.parse_tag_version("vcrpy-7.0.0", "vcrpy") == Version("7.0.0")

    @pytest.mark.parametrize(
        "tag,package,subpath,expected",
        [
            # libs-path tags (langchain-community/-cerebras/-google-genai) are
            # accepted when the label matches the repo subpath.
            ("libs/genai/v4.2.5", "langchain-google-genai", "libs/genai", "4.2.5"),
            ("libs/community/v0.4.2", "langchain-community", "libs/community", "0.4.2"),
            ("libs/cerebras/v0.8.2", "langchain-cerebras", "libs/cerebras", "0.8.2"),
            # siblings sharing the langchain-google monorepo must still be rejected
            ("libs/vertexai/v3.2.4", "langchain-google-genai", "libs/genai", None),
            ("libs/community/v5.0.0", "langchain-google-genai", "libs/genai", None),
            # without the subpath hint, a libs-path label can't match the package
            ("libs/genai/v4.2.5", "langchain-google-genai", None, None),
            # name-labeled monorepo tags keep working regardless of subpath
            ("langchain-aws==1.6.0", "langchain-aws", "libs/aws", "1.6.0"),
        ],
    )
    def test_parse_with_subpath(self, tag, package, subpath, expected):
        got = cl.parse_tag_version(tag, package, subpath)
        assert got == (None if expected is None else Version(expected))


# ---------------------------------------------------------------------------
# Unit: parse_github_location / find_subtree
# ---------------------------------------------------------------------------

class TestGithubLocation:

    def test_monorepo_subtree(self):
        loc = cl.parse_github_location(
            "https://github.com/googleapis/google-cloud-python/tree/main/packages/google-auth"
        )
        assert loc == {
            "owner": "googleapis",
            "repo": "google-cloud-python",
            "branch": "main",
            "subpath": "packages/google-auth",
        }

    def test_plain_repo(self):
        loc = cl.parse_github_location("https://github.com/psf/requests")
        assert loc == {"owner": "psf", "repo": "requests", "branch": None, "subpath": None}

    def test_git_suffix_stripped(self):
        loc = cl.parse_github_location("https://github.com/psf/requests.git")
        assert loc["repo"] == "requests"

    def test_non_github(self):
        assert cl.parse_github_location("https://gitlab.com/foo/bar") is None
        assert cl.parse_github_location("") is None

    def test_find_subtree(self):
        urls = {
            "Homepage": "https://github.com/googleapis/google-cloud-python/tree/main/packages/google-auth",
        }
        assert cl.find_subtree(urls, "googleapis", "google-cloud-python") == (
            "main",
            "packages/google-auth",
        )

    def test_find_subtree_none_for_other_repo(self):
        urls = {"Homepage": "https://github.com/psf/requests"}
        assert cl.find_subtree(urls, "psf", "requests") == (None, None)


# ---------------------------------------------------------------------------
# Unit: build_not_found_notice
# ---------------------------------------------------------------------------

class TestNotFoundNotice:

    def test_lists_locations_and_marker(self):
        attempted = [
            "https://raw.githubusercontent.com/acme/widget/main/CHANGELOG.md",
            "https://raw.githubusercontent.com/acme/widget/main/HISTORY.md",
        ]
        notice = cl.build_not_found_notice(
            "widget", "1.0.0", "1.1.0",
            {"owner": "acme", "repo": "widget", "branch": "main", "subpath": None},
            attempted,
        )
        assert notice.startswith(cl.NOT_FOUND_MARKER)
        assert "Changelog not found" in notice
        assert "acme/widget" in notice
        for url in attempted:
            assert url in notice
        assert "https://pypi.org/project/widget/#history" in notice

    def test_no_repo_resolved(self):
        notice = cl.build_not_found_notice("widget", "1.0.0", "1.1.0", None, [])
        assert notice.startswith(cl.NOT_FOUND_MARKER)
        assert "No GitHub repository" in notice

    def test_reflects_releases_outcome_without_fabricating(self):
        # BugBot #3: when the Releases API call failed, the notice must report
        # what actually happened (from `attempted`) and must NOT fabricate a
        # "no tags in range" claim that was never established.
        attempted = [
            "GitHub Releases API of `acme/widget` "
            "(https://github.com/acme/widget/releases) — request failed: HTTP Error 503",
        ]
        notice = cl.build_not_found_notice(
            "widget", "1.0.0", "1.1.0",
            {"owner": "acme", "repo": "widget", "branch": None, "subpath": None},
            attempted,
        )
        assert "request failed: HTTP Error 503" in notice
        assert "no tags for" not in notice


# ---------------------------------------------------------------------------
# Unit: fetch_github_releases body handling (BugBot #2, via faked urlopen)
# ---------------------------------------------------------------------------

class TestFetchGithubReleasesBodies:

    def test_header_only_releases_return_empty(self, monkeypatch):
        # Matching releases exist but carry no notes (empty/whitespace bodies).
        # fetch must return "" so main() falls through to the CHANGELOG.md path.
        _fake_releases(monkeypatch, [
            {"tag_name": "widget-v1.1.0", "name": "widget-v1.1.0", "body": ""},
            {"tag_name": "widget-v1.0.5", "name": "widget-v1.0.5", "body": "   "},
        ])
        out = cl.fetch_github_releases(
            "acme", "widget", "widget", Version("1.0.0"), Version("1.1.0")
        )
        assert out == ""

    def test_bodyless_releases_skipped_but_real_ones_kept(self, monkeypatch):
        _fake_releases(monkeypatch, [
            {"tag_name": "widget-v1.1.0", "name": "widget-v1.1.0", "body": "Added a feature"},
            {"tag_name": "widget-v1.0.5", "name": "widget-v1.0.5", "body": ""},
        ])
        out = cl.fetch_github_releases(
            "acme", "widget", "widget", Version("1.0.0"), Version("1.1.0")
        )
        assert "Added a feature" in out
        assert "widget-v1.1.0" in out
        assert "widget-v1.0.5" not in out  # bodyless release dropped


class TestSummaryFailedNotice:
    """When the changelog was fetched but Claude summarization failed, the
    notice must say so (not 'not found') yet still carry the marker so impact
    analysis skips."""

    def test_says_found_but_not_summarized(self):
        notice = cl.build_summary_failed_notice(
            "widget", "1.0.0", "1.1.0",
            "https://raw.githubusercontent.com/acme/widget/main/CHANGELOG.md",
            {"owner": "acme", "repo": "widget", "branch": "main", "subpath": None},
        )
        assert notice.startswith(cl.NOT_FOUND_MARKER)
        assert "found but not summarized" in notice.lower()
        assert "not found" not in notice.lower()  # must not claim it wasn't found
        assert "https://raw.githubusercontent.com/acme/widget/main/CHANGELOG.md" in notice


# ---------------------------------------------------------------------------
# Unit: the not-found marker is kept in sync across both scripts
# ---------------------------------------------------------------------------

def test_not_found_marker_in_sync():
    impact = _load("generate_impact_analysis")
    assert impact.NOT_FOUND_MARKER == cl.NOT_FOUND_MARKER


# ---------------------------------------------------------------------------
# Integration: the two packages that previously produced no changelog
# ---------------------------------------------------------------------------

@pytest.mark.integration
class TestLangchainAwsLive:
    """#190: langchain-aws monorepo tags `langchain-aws==1.6.0`."""

    def test_releases_resolve_and_scope(self):
        urls = _net(cl.fetch_pypi_urls, "langchain-aws")
        owner, repo = cl.extract_github_repo(urls)
        assert (owner, repo) == ("langchain-ai", "langchain-aws")

        text = _net(
            cl.fetch_github_releases,
            owner, repo, "langchain-aws", Version("1.5.0"), Version("1.6.0"),
        )
        assert text, "expected non-empty changelog from GitHub releases"
        assert "1.6.0" in text
        # Sibling packages in the same repo must not leak into release headers.
        headers = [ln for ln in text.splitlines() if ln.startswith("## ")]
        assert headers, "expected per-release headers"
        assert not any("langgraph-checkpoint-aws" in h for h in headers)


@pytest.mark.integration
class TestGoogleAuthLive:
    """#189: google-auth metadata points at a google-cloud-python subtree."""

    def test_repo_and_subtree_resolution(self):
        urls = _net(cl.fetch_pypi_urls, "google-auth")
        owner, repo = cl.extract_github_repo(urls)
        assert (owner, repo) == ("googleapis", "google-cloud-python")
        branch, subpath = cl.find_subtree(urls, owner, repo)
        assert subpath == "packages/google-auth"
        assert branch  # whatever the default branch is

    def test_changelog_reachable(self):
        """Either the releases path or the per-subpath CHANGELOG.md yields notes."""
        urls = _net(cl.fetch_pypi_urls, "google-auth")
        owner, repo = cl.extract_github_repo(urls)
        branch, subpath = cl.find_subtree(urls, owner, repo)

        attempted: list[str] = []
        text, source = _net(
            cl.fetch_raw_changelog_files, owner, repo, branch, subpath, attempted
        )
        assert text, "expected a per-package CHANGELOG.md in the monorepo subtree"
        assert "2.55.0" in text
        assert source and "packages/google-auth" in source


@pytest.mark.integration
class TestLibsPathMonorepoLive:
    """langchain-community / -cerebras / -google-genai tag by libs-path
    (`libs/<name>/vX`), matched via the repo subpath."""

    def test_resolves_via_subpath(self):
        urls = _net(cl.fetch_pypi_urls, "langchain-cerebras")
        owner, repo = cl.extract_github_repo(urls)
        branch, subpath = cl.find_subtree(urls, owner, repo)
        assert subpath == "libs/cerebras"
        text = _net(
            cl.fetch_github_releases,
            owner, repo, "langchain-cerebras", Version("0.8.0"), Version("0.8.2"), subpath,
        )
        assert text, "expected libs-path releases to resolve once subpath is supplied"
        assert "0.8" in text

    def test_shared_monorepo_excludes_siblings(self):
        # langchain-google hosts genai, vertexai and community side by side.
        urls = _net(cl.fetch_pypi_urls, "langchain-google-genai")
        owner, repo = cl.extract_github_repo(urls)
        assert (owner, repo) == ("langchain-ai", "langchain-google")
        branch, subpath = cl.find_subtree(urls, owner, repo)
        assert subpath == "libs/genai"
        text = _net(
            cl.fetch_github_releases,
            owner, repo, "langchain-google-genai", Version("0"), Version("99"), subpath,
        )
        assert text, "expected genai releases"
        # Check release-entry headers (## {name}), not raw substrings: a genai
        # release body legitimately links to a sibling tag in its "Full
        # Changelog" compare URL, so substring checks false-positive.
        headers = re.findall(r"^## (.+)$", text, re.M)
        assert any(h.startswith("libs/genai") for h in headers), "expected genai entries"
        assert not any(
            h.startswith(("libs/vertexai", "libs/community")) for h in headers
        ), "sibling packages must not appear as release entries"
