"""Tests: check-updates — address resolution, saved-tag security, feeds.

All network seams injected (fetch, ls-remote, PyPI probe) — hermetic.
The local bare-repo fixture exercises the REAL git ls-remote command
shape without network.
"""

from __future__ import annotations

import json
import os
import shutil
import subprocess
from pathlib import Path

import pytest

from hermes_cli.plugins_provenance import Provenance, ProvenanceClass
from hermes_cli.plugins_updates import (
    CheckResult,
    check_pip_plugins,
    check_provenanced,
    parse_feed_yml,
    run_checks,
)


def _prov(
    klass=ProvenanceClass.GIT,
    row=None,
    path=None,
):
    return Provenance(
        name="plug",
        klass=klass,
        path=path or Path("/x/plug"),
        row=row or {},
    )


def _git_prov(**row):
    row.setdefault("pinned", False)
    row.setdefault("revision", "a" * 40)
    row.setdefault("source", "https://example/o/r")
    return _prov(ProvenanceClass.GIT, row=row)


# ── local git, one resolver for every repo-spawning test ────────────


def _git_exe():
    """Conventional git from PATH — tests run with Git for Windows' cmd
    dir leading PATH, never the MSIX payload (spawn denial, WinError 5)."""
    return shutil.which("git") or "git"


def _git_env():
    return {k: v for k, v in os.environ.items() if k != "GIT_DIR"}


def _local_repo(path: Path, *init_args):
    """A real local git repo; returns (run, head) bound to it."""
    git = _git_exe()
    env = _git_env()

    def run(*args):
        subprocess.run(
            [git, *args], cwd=path, check=True, capture_output=True, env=env
        )

    def head():
        return subprocess.run(
            [git, "rev-parse", "HEAD"], cwd=path, check=True,
            capture_output=True, text=True, env=env,
        ).stdout.strip()

    path.mkdir(parents=True)
    run("init", "-q", *init_args)
    run("-c", "user.email=t@e", "-c", "user.name=t",
        "commit", "--allow-empty", "-qm", "one")
    return run, head


# ── provenance-class short-circuits ────────────────────────────────


def test_manual_reports_not_updatable():
    r = check_provenanced(_prov(ProvenanceClass.MANUAL), fetch=_no, ls_remote=_no)
    assert r.update_available is None
    assert "not auto-updatable" in r.reason


def test_drift_reports_reinstall_remedy():
    r = check_provenanced(
        _prov(ProvenanceClass.DRIFT, row={"source": "https://x/y"}),
        fetch=_no, ls_remote=_no,
    )
    assert "reinstall" in r.reason


def test_self_cloned_reports_adopt():
    r = check_provenanced(_prov(ProvenanceClass.SELF_CLONED), fetch=_no, ls_remote=_no)
    assert "adopt" in r.reason


def _no(*a, **k):
    raise AssertionError("should not be called")


# ── pinned ──────────────────────────────────────────────────────────


def test_pinned_never_auto_moves():
    r = check_provenanced(_git_prov(pinned=True), fetch=_no, ls_remote=_no)
    assert r.update_available is False
    assert "pinned" in r.reason


# ── the saved-tag security heart ────────────────────────────────────


def test_url_appearing_where_none_saved_is_needs_fixing(tmp_path):
    plug = tmp_path / "plug"
    plug.mkdir()
    (plug / "plugin.yaml").write_text(
        "name: plug\nupdate_url: https://evil.example/feed.yml\n",
        encoding="utf-8",
    )
    prov = _git_prov()  # no update_url in the row
    prov.path = plug
    r = check_provenanced(prov, fetch=_no, ls_remote=_no)
    assert r.needs_fixing and "trust-update-url" in r.needs_fixing
    assert r.update_available is None  # refused, not unknown


def test_url_mismatch_is_needs_fixing(tmp_path):
    plug = tmp_path / "plug"
    plug.mkdir()
    (plug / "plugin.yaml").write_text(
        "name: plug\nupdate_url: https://new.example/feed.yml\n",
        encoding="utf-8",
    )
    prov = _git_prov(update_url="https://old.example/feed.yml")
    prov.path = plug
    r = check_provenanced(prov, fetch=_no, ls_remote=_no)
    assert "mismatch" in r.needs_fixing
    assert "trust-update-url" in r.needs_fixing


# ── feed path (matching tag) ────────────────────────────────────────


FEED = """\
version: 1.2.0
released: 2026-09-03T00:00:00Z
min_hermes: 0.27.0
artifacts:
  git: https://example/o/r
  bundle: https://example/o/r/plug-1.2.0.zip
  bundle_sha256: abc123
"""


def test_matching_tag_fetches_feed(tmp_path):
    plug = tmp_path / "plug"
    plug.mkdir()
    (plug / "plugin.yaml").write_text(
        "name: plug\nversion: 1.0.0\nupdate_url: https://feed.example/f.yml\n",
        encoding="utf-8",
    )
    prov = _git_prov(update_url="https://feed.example/f.yml")
    prov.path = plug

    fetched = []

    def fetch(url):
        fetched.append(url)
        return FEED

    r = check_provenanced(prov, fetch=fetch, ls_remote=_no)
    assert fetched == ["https://feed.example/f.yml"]
    assert r.latest == "1.2.0"
    assert r.current == "1.0.0"
    assert r.min_hermes == "0.27.0"
    assert r.update_available is True  # installed 1.0.0 vs feed 1.2.0


def test_feed_version_with_no_installed_version_is_unknown(tmp_path):
    """A semantic feed vs a versionless manifest cannot be compared — report
    unknown, never a revision-vs-version inequality."""
    plug = tmp_path / "plug"
    plug.mkdir()
    (plug / "plugin.yaml").write_text(
        "name: plug\nupdate_url: https://feed.example/f.yml\n", encoding="utf-8"
    )
    prov = _git_prov(update_url="https://feed.example/f.yml")
    prov.path = plug
    r = check_provenanced(prov, fetch=lambda u: FEED, ls_remote=_no)
    assert r.update_available is None
    assert r.latest == "1.2.0"


def test_feed_git_sha_artifact_compares_sha_to_sha(tmp_path):
    """C17 like-for-like: a full git SHA in the feed's artifacts.git is
    compared against the recorded revision, never to a semantic version —
    and both reported fields are the shas actually compared."""
    plug = tmp_path / "plug"
    plug.mkdir()
    (plug / "plugin.yaml").write_text(
        "name: plug\nversion: 9.9.9\nupdate_url: https://feed.example/f.yml\n",
        encoding="utf-8",
    )
    feed = "version: 1.2.0\nartifacts:\n  git: %s\n" % ("c" * 40)
    prov = _git_prov(update_url="https://feed.example/f.yml")
    prov.path = plug

    r = check_provenanced(prov, fetch=lambda u: feed, ls_remote=_no)
    assert r.update_available is True  # c*40 != a*40
    assert r.current == "a" * 40
    assert r.latest == "c" * 40

    prov2 = _git_prov(update_url="https://feed.example/f.yml", revision="c" * 40)
    prov2.path = plug
    r2 = check_provenanced(prov2, fetch=lambda u: feed, ls_remote=_no)
    assert r2.update_available is False  # SHA vs SHA: converged
    assert r2.current == "c" * 40
    assert r2.latest == "c" * 40


def test_feed_git_sha_case_equivalent_is_not_an_update(tmp_path):
    """Hex identity is case-insensitive — but only after format validation."""
    plug = tmp_path / "plug"
    plug.mkdir()
    (plug / "plugin.yaml").write_text(
        "name: plug\nupdate_url: https://feed.example/f.yml\n", encoding="utf-8"
    )
    feed = "version: 1.2.0\nartifacts:\n  git: %s\n" % ("C" * 40)
    prov = _git_prov(update_url="https://feed.example/f.yml", revision="c" * 40)
    prov.path = plug
    r = check_provenanced(prov, fetch=lambda u: feed, ls_remote=_no)
    assert r.update_available is False


def test_feed_git_sha_with_no_recorded_revision_is_unknown(tmp_path):
    plug = tmp_path / "plug"
    plug.mkdir()
    (plug / "plugin.yaml").write_text(
        "name: plug\nversion: 9.9.9\nupdate_url: https://feed.example/f.yml\n",
        encoding="utf-8",
    )
    feed = "version: 1.2.0\nartifacts:\n  git: %s\n" % ("c" * 40)
    prov = _git_prov(update_url="https://feed.example/f.yml", revision="")
    prov.path = plug
    r = check_provenanced(prov, fetch=lambda u: feed, ls_remote=_no)
    assert r.update_available is None
    assert "no full revision sha" in r.reason


def test_feed_git_sha_against_tagged_revision_is_unknown(tmp_path):
    """A recorded tag is not a full sha — never compared by inequality."""
    plug = tmp_path / "plug"
    plug.mkdir()
    (plug / "plugin.yaml").write_text(
        "name: plug\nversion: 9.9.9\nupdate_url: https://feed.example/f.yml\n",
        encoding="utf-8",
    )
    feed = "version: 1.2.0\nartifacts:\n  git: %s\n" % ("c" * 40)
    prov = _git_prov(
        update_url="https://feed.example/f.yml", revision="v1.2.0"
    )
    prov.path = plug
    r = check_provenanced(prov, fetch=lambda u: feed, ls_remote=_no)
    assert r.update_available is None


def test_malformed_yaml_feed_reports_unknown_row(tmp_path):
    plug = tmp_path / "plug"
    plug.mkdir()
    (plug / "plugin.yaml").write_text("update_url: https://feed.example/f.yml\n", encoding="utf-8")
    prov = _git_prov(update_url="https://feed.example/f.yml")
    prov.path = plug
    result = check_provenanced(prov, fetch=lambda _: "version: [", ls_remote=_no)
    assert result.update_available is None
    assert "unparseable" in result.reason


def test_parse_feed_rejects_non_string_git_artifact():
    feed = "version: 1.2.0\nartifacts:\n  git:\n    url: x\n"
    with pytest.raises(ValueError):
        parse_feed_yml(feed)


def test_ls_remote_lifecycle_current_available_applied(tmp_path, monkeypatch):
    """The git-path gate, end to end against a real local repo: current ->
    ls-remote sees a new HEAD -> revision recorded -> current. No network,
    no manual manifest rewriting — every comparison is the real command."""
    repo = tmp_path / "repo"
    run, head = _local_repo(repo, "-b", "main")
    sha1 = head()
    from hermes_cli.plugins_updates import default_ls_remote
    monkeypatch.setattr('hermes_cli.plugins_cmd._resolve_git_executable', _git_exe)
    ls_remote = default_ls_remote

    plug = tmp_path / "plug"
    plug.mkdir()
    (plug / ".git").mkdir()
    prov = _git_prov(revision=sha1, source=str(repo))
    prov.path = plug

    def check():
        return check_provenanced(prov, fetch=_no, ls_remote=ls_remote)

    assert check().update_available is False  # current

    run("-c", "user.email=t@e", "-c", "user.name=t",
        "commit", "--allow-empty", "-qm", "two")
    sha2 = head()
    assert sha2 != sha1
    r = check()
    assert r.update_available is True  # available
    assert r.latest == sha2

    # once the update pipeline has recorded the new revision, converged
    prov3 = _git_prov(revision=sha2, source=str(repo))
    prov3.path = plug
    assert check_provenanced(prov3, fetch=_no, ls_remote=ls_remote).update_available is False


def test_feed_fetch_failure_is_row_level_reason(tmp_path):
    plug = tmp_path / "plug"
    plug.mkdir()
    (plug / "plugin.yaml").write_text(
        "name: plug\nupdate_url: https://feed.example/f.yml\n", encoding="utf-8"
    )
    prov = _git_prov(update_url="https://feed.example/f.yml")
    prov.path = plug

    def boom(url):
        raise OSError("timeout")

    r = check_provenanced(prov, fetch=boom, ls_remote=_no)
    assert r.reason.startswith("feed fetch failed")
    assert r.update_available is None


# ── ls-remote fallback (no update_url anywhere) ─────────────────────


def test_ls_remote_fallback(tmp_path):
    plug = tmp_path / "plug"
    plug.mkdir()
    prov = _git_prov()  # no update_url in row or manifest
    prov.path = plug
    calls = []

    def ls(source):
        calls.append(source)
        return "b" * 40

    r = check_provenanced(prov, fetch=_no, ls_remote=ls)
    assert calls == ["https://example/o/r"]
    assert r.update_available is True  # b != a


# ── the real git ls-remote, against a local bare repo ───────────────


def test_real_ls_remote_against_bare_repo(tmp_path, monkeypatch):
    source = tmp_path / "bare.git"
    git = _git_exe()
    env = _git_env()
    subprocess.run(
        [git, "init", "--bare", "-q", str(source)],
        check=True, capture_output=True, env=env,
    )
    from hermes_cli.plugins_updates import default_ls_remote
    monkeypatch.setattr('hermes_cli.plugins_cmd._resolve_git_executable', _git_exe)
    assert default_ls_remote(str(source)) == ""


# ── feed parsing ────────────────────────────────────────────────────


def test_parse_feed_requires_version():
    with pytest.raises(ValueError):
        parse_feed_yml("released: 2026-01-01\n")
    assert parse_feed_yml("version: 2.0.0\n")["version"] == "2.0.0"


# ── pip world ───────────────────────────────────────────────────────


class _EP:
    def __init__(self, name, value, dist_name):
        self.name = name
        self.value = value
        self.dist_name = dist_name


def test_pip_not_on_pypi_reports_unknown():
    eps = [_EP("local-only", "x:y", "x")]
    rs = check_pip_plugins(
        installed_version=lambda d: "1.0",
        pypi_latest=lambda d: None,
        entry_points=eps,
    )
    assert rs[0].update_available is None
    assert "not on PyPI" in rs[0].reason


class _Dist:
    def __init__(self, name):
        self._name = name

    @property
    def metadata(self):
        return {"Name": self._name}


def test_pip_uses_owning_distribution_not_import_root():
    """C17: the dist name comes from the entry point's owning distribution,
    never guessed from the value's first import module."""
    ep = _EP("mnemosyne", "some.module:register", None)
    ep.dist = _Dist("mnemosyne-hermes")
    seen = []

    rs = check_pip_plugins(
        installed_version=lambda d: (seen.append(d), "0.5.0")[1],
        pypi_latest=lambda d: None,
        entry_points=[ep],
    )
    assert seen == ["mnemosyne-hermes"]
    assert rs[0].current == "0.5.0"


def test_pip_entry_point_without_distribution_is_unknown_not_guessed():
    ep = _EP("orphan", "some.module:register", None)
    rs = check_pip_plugins(
        installed_version=lambda d: pytest.fail(f"guessed dist {d!r}"),
        pypi_latest=lambda d: pytest.fail("no fetch without a dist"),
        entry_points=[ep],
    )
    assert rs[0].update_available is None
    assert rs[0].latest is None
    assert "distribution" in rs[0].reason


# ── run_checks composition ───────────────────────────────────────────


def test_run_checks_never_mutates(tmp_path):
    plugins = tmp_path / "plugins"
    plug = plugins / "plug"
    (plug / ".git").mkdir(parents=True)  # git-class, not drift
    (plugins / ".install-metadata.json").write_text(
        json.dumps({"plug": {"pinned": False, "revision": "a" * 40,
                             "source": "https://example/o/r"}}),
        encoding="utf-8",
    )
    before = (plugins / ".install-metadata.json").read_text(encoding="utf-8")

    results = run_checks(
        plugins,
        fetch=_no,
        ls_remote=lambda s: "b" * 40,
        include_pip=False,
    )
    assert results[0].update_available is True
    assert (plugins / ".install-metadata.json").read_text(encoding="utf-8") == before


@pytest.mark.parametrize("url", ["http://feed.example/f.yml", "file:///etc/passwd", "ftp://x/f.yml", ""])
def test_default_fetch_refuses_non_https_feeds_before_any_request(monkeypatch, url):
    """Rows saved before the https rule (or hand-edited) still reach the real fetcher from the
    gateway tick; the sink refuses them instead of opening the URL."""
    import urllib.request
    from hermes_cli.plugins_updates import default_fetch

    def never(*a, **k):
        raise AssertionError("urlopen must not be reached")

    monkeypatch.setattr(urllib.request, "urlopen", never)
    with pytest.raises(ValueError, match="https://"):
        default_fetch(url)


@pytest.fixture
def feed_redirect_server(monkeypatch):
    """Exercise urllib's redirect machinery without requiring a TLS certificate."""
    import http.client
    import threading
    import urllib.request
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

    visited = []

    class FeedHandler(BaseHTTPRequestHandler):
        def do_GET(self):
            visited.append(self.path)
            if self.path == "/start":
                target = f"http://127.0.0.1:{self.server.server_port}/middle"
            elif self.path == "/middle":
                target = f"https://127.0.0.1:{self.server.server_port}/feed"
            elif self.path == "/secure":
                target = f"https://127.0.0.1:{self.server.server_port}/feed"
            else:
                self.send_response(200)
                self.end_headers()
                self.wfile.write(b"version: 1.2.0\n")
                return
            self.send_response(302)
            self.send_header("Location", target)
            self.end_headers()

        def log_message(self, *_args):
            pass

    # Only the HTTPS transport is replaced; the redirect handler and HTTP
    # transport stay real, so the server can observe a forbidden HTTP hop.
    monkeypatch.setattr(
        urllib.request.HTTPSHandler, "https_open",
        lambda self, req: self.do_open(http.client.HTTPConnection, req),
    )
    server = ThreadingHTTPServer(("127.0.0.1", 0), FeedHandler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        yield f"https://127.0.0.1:{server.server_port}", visited
    finally:
        server.shutdown()
        thread.join()
        server.server_close()


def test_default_fetch_refuses_intermediate_plaintext_redirect(feed_redirect_server):
    from hermes_cli.plugins_updates import default_fetch

    base, visited = feed_redirect_server
    with pytest.raises(ValueError, match="https://"):
        default_fetch(base + "/start")
    assert visited == ["/start"]


def test_default_fetch_follows_https_redirect(feed_redirect_server):
    from hermes_cli.plugins_updates import default_fetch

    base, visited = feed_redirect_server
    assert default_fetch(base + "/secure") == "version: 1.2.0\n"
    assert visited == ["/secure", "/feed"]
