"""Unit tests for project catalog-config id derivation and url canonicalization."""
from __future__ import annotations

from pathlib import Path

import pytest

from specify_cli.bundler import BundlerError
from specify_cli.bundler.commands_impl import catalog_config as cc


def test_derive_id_incorporates_path_stem_for_same_host():
    # Two catalogs on the same host must not collide on the derived id.
    a = cc._derive_id("https://example.com/team-a.json")
    b = cc._derive_id("https://example.com/team-b.json")
    assert a == "example-com-team-a"
    assert b == "example-com-team-b"
    assert a != b


def test_derive_id_distinguishes_tlds():
    # Different TLDs sharing a second-level label must not collide.
    com = cc._derive_id("https://example.com/team-a.json")
    net = cc._derive_id("https://example.net/team-a.json")
    assert com == "example-com-team-a"
    assert net == "example-net-team-a"
    assert com != net


def test_derive_id_falls_back_to_host_when_no_path():
    assert cc._derive_id("https://example.com/") == "example-com"


def test_derive_id_for_local_path_uses_stem():
    assert cc._derive_id("./catalogs/my-catalog.json") == "my-catalog"


def test_canonicalize_makes_relative_local_path_absolute(tmp_path: Path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    (tmp_path / "local.json").write_text("{}", encoding="utf-8")

    result = cc._canonicalize_url("local.json")

    assert Path(result).is_absolute()
    assert Path(result) == (tmp_path / "local.json").resolve()


def test_canonicalize_leaves_remote_urls_untouched():
    for url in (
        "https://example.com/c.json",
        "http://localhost:8080/c.json",
        "file:///tmp/c.json",
        "builtin://default",
    ):
        assert cc._canonicalize_url(url) == url


def test_add_source_persists_absolute_local_path(tmp_path: Path, monkeypatch):
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    catalog = project / "sub" / "cat.json"
    catalog.parent.mkdir()
    catalog.write_text("{}", encoding="utf-8")

    monkeypatch.chdir(project)
    source = cc.add_source(project, "sub/cat.json", policy="install-allowed", priority=50)

    assert Path(source.url).is_absolute()
    assert Path(source.url) == catalog.resolve()


def test_remove_source_accepts_relative_local_path(tmp_path: Path, monkeypatch):
    """add_source stores a local path as an absolute url, so remove_source must
    accept the same relative path the caller added; otherwise `remove ./cat.json`
    cannot undo `add ./cat.json`."""
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    catalog = project / "sub" / "cat.json"
    catalog.parent.mkdir()
    catalog.write_text("{}", encoding="utf-8")
    monkeypatch.chdir(project)

    cc.add_source(project, "sub/cat.json", policy="install-allowed", priority=50)
    # Removing with the same relative path must succeed (stored absolute).
    removed = cc.remove_source(project, "sub/cat.json")
    assert removed == "sub/cat.json"
    # And it is actually gone now.
    with pytest.raises(BundlerError, match="No project-scoped catalog source"):
        cc.remove_source(project, "sub/cat.json")


def test_remove_by_id_does_not_also_delete_canonical_url_match(tmp_path: Path, monkeypatch):
    """`remove <id>` must remove only the exact-id source, not also a different
    source whose url happens to equal the id's canonicalized path. (_canonicalize_url
    treats a bare id as a local path, so the canonical match is only a fallback when
    there is no exact id/url match.)"""
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    monkeypatch.chdir(project)
    # Source A: id "local", a remote url.
    cc.add_source(
        project, "https://example.com/a.json", source_id="local",
        policy="install-allowed", priority=10,
    )
    # Source B: a local path that canonicalizes to <cwd>/local, with a distinct id.
    cc.add_source(project, "local", source_id="bsource", policy="install-allowed", priority=20)

    removed = cc.remove_source(project, "local")
    assert removed == "local"
    ids = {c["id"] for c in cc._read(project)}
    assert "local" not in ids   # the exact-id source was removed
    assert "bsource" in ids     # the canonical-url source survives (not collateral)


def test_add_source_refuses_symlinked_specify_escape(tmp_path: Path):
    project = tmp_path / "proj"
    project.mkdir()
    outside = tmp_path / "outside"
    outside.mkdir()
    (project / ".specify").symlink_to(outside, target_is_directory=True)

    with pytest.raises(BundlerError, match="escapes the allowed root"):
        cc.add_source(project, "https://example.com/c.json", policy="install-allowed", priority=50)


def test_read_rejects_non_list_catalogs(tmp_path: Path):
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    cc._config_path(project).write_text(
        "schema_version: '1.0'\ncatalogs: not-a-list\n", encoding="utf-8"
    )

    with pytest.raises(BundlerError, match="'catalogs' must be a list"):
        cc._read(project)


def test_read_rejects_non_mapping_catalog_entry(tmp_path: Path):
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    cc._config_path(project).write_text(
        "schema_version: '1.0'\ncatalogs:\n  - just-a-string\n", encoding="utf-8"
    )

    with pytest.raises(BundlerError, match="each catalog entry must be a mapping"):
        cc._read(project)


def test_read_rejects_non_mapping_top_level(tmp_path: Path):
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    cc._config_path(project).write_text("- a\n- b\n", encoding="utf-8")

    with pytest.raises(BundlerError, match="expected a mapping at the top level"):
        cc._read(project)


def test_read_rejects_unknown_schema_version(tmp_path: Path):
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    cc._config_path(project).write_text(
        "schema_version: '2.0'\ncatalogs: []\n", encoding="utf-8"
    )

    with pytest.raises(BundlerError, match="Unsupported catalog config schema version"):
        cc._read(project)


def test_read_accepts_forward_compatible_minor_schema(tmp_path: Path):
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    cc._config_path(project).write_text(
        "schema_version: '1.5'\ncatalogs: []\n", encoding="utf-8"
    )
    assert cc._read(project) == []


def test_read_tolerates_missing_schema_version(tmp_path: Path):
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    cc._config_path(project).write_text("catalogs: []\n", encoding="utf-8")
    assert cc._read(project) == []


def test_read_returns_empty_for_missing_or_empty_config(tmp_path: Path):
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    assert cc._read(project) == []

    cc._config_path(project).write_text("schema_version: '1.0'\n", encoding="utf-8")
    assert cc._read(project) == []


def test_slug_lowercases_for_deterministic_ids():
    # Mixed-case local filenames must derive the same id regardless of case so
    # the case-sensitive duplicate check cannot admit logical duplicates.
    assert cc._slug("Team-A") == "team-a"
    assert cc._derive_id("./catalogs/Team-A.json") == "team-a"
    assert cc._derive_id("https://Example.com/Team-A.json") == "example-com-team-a"


def test_derive_id_handles_ipv6_literal():
    # An IPv6 host must not be truncated at the first colon.
    derived = cc._derive_id("https://[2001:db8::1]/catalog.json")
    assert derived == "2001-db8--1-catalog"


def test_derive_id_ignores_credentials_and_port():
    assert cc._derive_id("https://user:pw@example.com:8443/c.json") == "example-com-c"


def test_add_source_rejects_unsupported_scheme(tmp_path: Path):
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    with pytest.raises(BundlerError, match="Unsupported catalog url scheme"):
        cc.add_source(project, "ssh://host/catalog.json", policy="install-allowed", priority=50)


def test_add_source_allows_local_path_with_colon(tmp_path: Path, monkeypatch):
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    monkeypatch.chdir(project)
    # A relative path containing ':' but no '://' is still a local path.
    source = cc.add_source(project, "weird:name.json", policy="install-allowed", priority=50)
    assert source.url.endswith("weird:name.json") or "weird" in source.url


def test_add_source_rejects_plain_http_for_non_localhost(tmp_path: Path):
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    with pytest.raises(BundlerError, match="HTTPS"):
        cc.add_source(project, "http://example.com/catalog.json", policy="install-allowed", priority=50)


def test_add_source_allows_http_for_localhost(tmp_path: Path):
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    source = cc.add_source(project, "http://localhost:8080/c.json", policy="install-allowed", priority=50)
    assert source.url == "http://localhost:8080/c.json"


def test_add_source_rejects_host_less_remote_urls(tmp_path: Path):
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    for url in ("https://:8080", "https://user@"):
        with pytest.raises(BundlerError, match="host"):
            cc.add_source(project, url, policy="install-allowed", priority=50)


def test_add_source_wraps_invalid_ipv6_as_bundler_error(tmp_path: Path):
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    with pytest.raises(BundlerError, match="Invalid catalog url"):
        cc.add_source(project, "https://[::1/c.json", policy="install-allowed", priority=50)


def test_remove_source_does_not_crash_on_invalid_ipv6(tmp_path: Path):
    project = tmp_path / "proj"
    (project / ".specify").mkdir(parents=True)
    with pytest.raises(BundlerError, match="No project-scoped catalog source"):
        cc.remove_source(project, "https://[::1/c.json")
