"""Tests for presets/bluesky/*.py — pure-function surface only.

Network calls (createSession, xrpc) are not exercised here. Tests
focus on argument parsing and output rendering.
"""
from __future__ import annotations

import importlib.util
import sys
from pathlib import Path

import pytest

PRESET_DIR = Path(__file__).parent.parent / "presets" / "bluesky"


def _load(name: str):
    for k in ("_auth", "_atproto", "_me", "_outbound", "_session", "_rest", "_graphql"):
        sys.modules.pop(k, None)
    sys.path[:] = [p for p in sys.path
                    if "presets/devto" not in p and "presets/hashnode" not in p]
    if str(PRESET_DIR) not in sys.path:
        sys.path.insert(0, str(PRESET_DIR))
    spec = importlib.util.spec_from_file_location(f"bsky_{name}", PRESET_DIR / f"{name}.py")
    assert spec is not None and spec.loader is not None
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


publish = _load("publish")
list_op = _load("list")
search_op = _load("search")
read_op = _load("read")
status_since_op = _load("status_since")


# publish ---------------------------------------------------------------

def test_publish_parse_args_inline_text() -> None:
    body, reply, force = publish.parse_args("Hello, real humans.")
    assert body == "Hello, real humans." and reply is None and force is False


def test_publish_parse_args_with_reply(tmp_path: Path) -> None:
    body, reply, force = publish.parse_args("Reply text|at://did:plc:abc/app.bsky.feed.post/3kxyz")
    assert body == "Reply text"
    assert reply == "at://did:plc:abc/app.bsky.feed.post/3kxyz"
    assert force is False


def test_publish_parse_args_text_file(tmp_path: Path) -> None:
    md = tmp_path / "post.txt"
    md.write_text("From a file")
    body, _, force = publish.parse_args(str(md))
    assert body == "From a file"
    assert force is False


def test_publish_parse_args_too_long(capsys: pytest.CaptureFixture[str]) -> None:
    long_text = "x" * 301
    with pytest.raises(SystemExit):
        publish.parse_args(long_text)
    err = capsys.readouterr().err
    assert "max" in err and "301" in err


def test_publish_parse_args_empty(capsys: pytest.CaptureFixture[str]) -> None:
    with pytest.raises(SystemExit):
        publish.parse_args("")
    assert "ERROR" in capsys.readouterr().err


# extract_link_facets ----------------------------------------------------


def test_facets_no_url() -> None:
    assert publish.extract_link_facets("Hello world") == []


def test_facets_single_url() -> None:
    body = "See https://example.com for more"
    facets = publish.extract_link_facets(body)
    assert len(facets) == 1
    f = facets[0]
    assert f["features"][0]["$type"] == "app.bsky.richtext.facet#link"
    assert f["features"][0]["uri"] == "https://example.com"
    assert f["index"]["byteStart"] == 4
    assert f["index"]["byteEnd"] == 23
    # Cross-check the slice produces the URL.
    assert body.encode("utf-8")[4:23] == b"https://example.com"


def test_facets_strips_trailing_period() -> None:
    body = "Read https://example.com."
    facets = publish.extract_link_facets(body)
    assert len(facets) == 1
    assert facets[0]["features"][0]["uri"] == "https://example.com"
    # Byte range should NOT include the trailing dot.
    assert facets[0]["index"]["byteEnd"] == len(body.encode("utf-8")) - 1


def test_facets_strips_trailing_paren() -> None:
    body = "(see https://example.com)"
    facets = publish.extract_link_facets(body)
    assert len(facets) == 1
    assert facets[0]["features"][0]["uri"] == "https://example.com"


def test_facets_multiple_urls() -> None:
    body = "First https://a.com then https://b.com end"
    facets = publish.extract_link_facets(body)
    assert len(facets) == 2
    assert facets[0]["features"][0]["uri"] == "https://a.com"
    assert facets[1]["features"][0]["uri"] == "https://b.com"
    # Offsets must be increasing and non-overlapping.
    assert facets[0]["index"]["byteEnd"] <= facets[1]["index"]["byteStart"]


def test_facets_byte_offsets_with_unicode() -> None:
    """Byte offsets, not character offsets — emoji & accents take >1 byte."""
    body = "café — https://example.com"
    facets = publish.extract_link_facets(body)
    assert len(facets) == 1
    start = facets[0]["index"]["byteStart"]
    end = facets[0]["index"]["byteEnd"]
    assert body.encode("utf-8")[start:end] == b"https://example.com"


def test_facets_http_and_https_both_match() -> None:
    body = "old http://example.com and new https://example.com"
    facets = publish.extract_link_facets(body)
    assert len(facets) == 2
    assert facets[0]["features"][0]["uri"] == "http://example.com"
    assert facets[1]["features"][0]["uri"] == "https://example.com"


def test_facets_angle_bracket_url_excludes_brackets() -> None:
    """`<https://x.com>` must not pull `>` into the facet URI — invalid URL."""
    body = "wrap <https://example.com>"
    facets = publish.extract_link_facets(body)
    assert len(facets) == 1
    assert facets[0]["features"][0]["uri"] == "https://example.com"


def test_facets_query_string_with_punct_preserved() -> None:
    """`!`, `)` etc. inside `?query=...` are valid URL chars; don't strip."""
    body = "search https://x.com?q=hi!"
    facets = publish.extract_link_facets(body)
    assert len(facets) == 1
    assert facets[0]["features"][0]["uri"] == "https://x.com?q=hi!"


def test_facets_fragment_with_punct_preserved() -> None:
    body = "anchor https://x.com/page#section!"
    facets = publish.extract_link_facets(body)
    assert len(facets) == 1
    assert facets[0]["features"][0]["uri"] == "https://x.com/page#section!"


def test_facets_markdown_link_syntax_handled() -> None:
    """`[text](URL)` shouldn't pull the closing paren into the facet."""
    body = "see [docs](https://example.com) for more"
    facets = publish.extract_link_facets(body)
    assert len(facets) == 1
    assert facets[0]["features"][0]["uri"] == "https://example.com"


# list ------------------------------------------------------------------

def test_list_parse_args_default() -> None:
    actor, n = list_op.parse_args("")
    assert actor is None and n == 10


def test_list_parse_args_handle_with_limit() -> None:
    actor, n = list_op.parse_args("alice.bsky.social|5")
    assert actor == "alice.bsky.social" and n == 5


def test_list_render_empty() -> None:
    assert list_op.render([]) == "(no posts)"


def test_list_render_formats() -> None:
    out = list_op.render([
        {"post": {"author": {"handle": "alice.bsky.social"},
                   "record": {"text": "Hi", "createdAt": "2026-05-01T00:00:00Z"},
                   "replyCount": 1, "likeCount": 3}},
    ])
    assert "@alice.bsky.social" in out and "Hi" in out and "3 likes" in out


# search ----------------------------------------------------------------

def test_search_parse_args_with_limit() -> None:
    q, n = search_op.parse_args("claude-code|7")
    assert q == "claude-code" and n == 7


def test_search_parse_args_default() -> None:
    q, n = search_op.parse_args("identity")
    assert q == "identity" and n == 10


def test_search_render_empty() -> None:
    assert "no results" in search_op.render("xx", [])


# read ------------------------------------------------------------------

def test_read_render_with_replies() -> None:
    thread = {
        "post": {
            "uri": "at://did:plc:abc/app.bsky.feed.post/3kxyz",
            "author": {"handle": "alice.bsky.social", "displayName": "Alice"},
            "record": {"text": "Body here", "createdAt": "2026-05-01T00:00:00Z"},
            "likeCount": 5, "replyCount": 1, "repostCount": 0,
        },
        "replies": [
            {"post": {"uri": "at://did:plc:abc/app.bsky.feed.post/3kxyz-r1",
                       "author": {"handle": "bob.bsky.social"},
                       "record": {"text": "Nice"}}},
        ],
    }
    out = read_op.render(thread, inline_n=5)
    assert "@alice.bsky.social" in out and "Body here" in out
    assert "@bob.bsky.social" in out and "Nice" in out
    assert "bluesky_like:" in out
    assert "bluesky_publish:" in out


def test_read_render_no_replies() -> None:
    thread = {
        "post": {
            "uri": "at://x/x/x",
            "author": {"handle": "x.bsky.social"},
            "record": {"text": "x", "createdAt": "2026-05-01T00:00:00Z"},
            "likeCount": 0, "replyCount": 0, "repostCount": 0,
        },
        "replies": [],
    }
    out = read_op.render(thread, inline_n=5)
    assert "0 replies" in out


# status_since ----------------------------------------------------------

def test_status_since_resolve_arg_wins() -> None:
    assert status_since_op.resolve_since("2026-01-01T00:00:00Z") == "2026-01-01T00:00:00Z"


def test_status_since_render_empty() -> None:
    out = status_since_op.render([], since="2026-04-30T00:00:00Z", now="2026-05-01T12:00:00Z")
    assert "(none)" in out


def test_status_since_render_with_kinds() -> None:
    notifs = [
        {"reason": "mention", "indexedAt": "2026-05-01T01:00:00Z",
         "uri": "at://x/y/z",
         "author": {"handle": "alice.bsky.social"},
         "record": {"text": "@max-ai-dev hey"}},
        {"reason": "follow", "indexedAt": "2026-05-01T02:00:00Z",
         "uri": "at://x/y/z2",
         "author": {"handle": "bob.bsky.social"},
         "record": {}},
    ]
    out = status_since_op.render(notifs, since="2026-04-30T00:00:00Z", now="2026-05-01T12:00:00Z")
    assert "MENTIONS (1)" in out
    assert "FOLLOWS (1)" in out
    assert "@alice.bsky.social" in out and "@bob.bsky.social" in out
    assert "bluesky_publish:" in out  # reply NEXT
    assert "bluesky_follow:" in out  # follow back NEXT


# pre-flight: like ----------------------------------------------------------

like_op = _load("like")
follow_op = _load("follow")


def test_like_parse_args_no_force() -> None:
    raw, force = like_op.parse_args("at://did:plc:abc/app.bsky.feed.post/xyz")
    assert raw == "at://did:plc:abc/app.bsky.feed.post/xyz" and force is False


def test_like_parse_args_force() -> None:
    raw, force = like_op.parse_args("at://did:plc:abc/app.bsky.feed.post/xyz|force")
    assert raw == "at://did:plc:abc/app.bsky.feed.post/xyz" and force is True


def test_like_parse_args_empty_exits(capsys: pytest.CaptureFixture[str]) -> None:
    with pytest.raises(SystemExit):
        like_op.parse_args("")
    assert "ERROR" in capsys.readouterr().err


def test_like_preflight_already_liked(monkeypatch: pytest.MonkeyPatch) -> None:
    target = "at://did:plc:abc/app.bsky.feed.post/xyz"
    session = {"did": "did:plc:me", "accessJwt": "tok"}
    monkeypatch.setattr(like_op, "xrpc", lambda nsid, s, **kw: {
        "feed": [{"post": {"uri": target}}, {"post": {"uri": "other"}}]
    })
    assert like_op.preflight_like(target, session) is True


def test_like_preflight_not_liked(monkeypatch: pytest.MonkeyPatch) -> None:
    session = {"did": "did:plc:me", "accessJwt": "tok"}
    monkeypatch.setattr(like_op, "xrpc", lambda nsid, s, **kw: {
        "feed": [{"post": {"uri": "at://other/post/1"}}]
    })
    assert like_op.preflight_like("at://target/post/x", session) is False


def test_like_preflight_api_error_degrades(monkeypatch: pytest.MonkeyPatch) -> None:
    session = {"did": "did:plc:me", "accessJwt": "tok"}
    monkeypatch.setattr(like_op, "xrpc", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("down")))
    assert like_op.preflight_like("at://x/y/z", session) is False


def test_like_main_aborts_on_dupe(monkeypatch: pytest.MonkeyPatch,
                                   capsys: pytest.CaptureFixture[str]) -> None:
    target_uri = "at://did:plc:abc/app.bsky.feed.post/xyz"
    session = {"did": "did:plc:me", "accessJwt": "tok"}
    monkeypatch.setattr(like_op, "get_handle", lambda: "me.bsky.social")
    monkeypatch.setattr(like_op, "get_app_password", lambda: "pw")
    monkeypatch.setattr(like_op, "get_session", lambda h, p: session)
    monkeypatch.setattr(like_op, "to_at_uri", lambda arg, s: target_uri)
    monkeypatch.setattr(like_op, "xrpc", lambda nsid, s, **kw: {
        "feed": [{"post": {"uri": target_uri}}]
    })
    with pytest.raises(SystemExit) as exc:
        like_op.main(target_uri)
    assert exc.value.code == 1
    assert "ABORT" in capsys.readouterr().err


def test_like_main_force_bypasses_preflight(monkeypatch: pytest.MonkeyPatch,
                                             capsys: pytest.CaptureFixture[str]) -> None:
    target_uri = "at://did:plc:abc/app.bsky.feed.post/xyz"
    session = {"did": "did:plc:me", "accessJwt": "tok"}
    calls: list[str] = []

    def fake_xrpc(nsid, s, method="GET", params=None, body=None, **kw):
        calls.append(nsid)
        if nsid == "app.bsky.feed.getPostThread":
            return {"thread": {"post": {"uri": target_uri, "cid": "cid123"}}}
        if nsid == "com.atproto.repo.createRecord":
            return {"uri": "at://me/like/1", "cid": "cid-like"}
        return {}

    monkeypatch.setattr(like_op, "get_handle", lambda: "me.bsky.social")
    monkeypatch.setattr(like_op, "get_app_password", lambda: "pw")
    monkeypatch.setattr(like_op, "get_session", lambda h, p: session)
    monkeypatch.setattr(like_op, "to_at_uri", lambda arg, s: target_uri)
    monkeypatch.setattr(like_op, "xrpc", fake_xrpc)
    like_op.main(f"{target_uri}|force")
    assert "liked" in capsys.readouterr().out
    assert "getActorLikes" not in calls  # preflight skipped


# pre-flight: follow --------------------------------------------------------

def test_follow_parse_args_no_force() -> None:
    target, force = follow_op.parse_args("alice.bsky.social")
    assert target == "alice.bsky.social" and force is False


def test_follow_parse_args_force() -> None:
    target, force = follow_op.parse_args("alice.bsky.social|force")
    assert target == "alice.bsky.social" and force is True


def test_follow_parse_args_strips_at() -> None:
    target, force = follow_op.parse_args("@alice.bsky.social")
    assert target == "alice.bsky.social"


def test_follow_parse_args_empty_exits(capsys: pytest.CaptureFixture[str]) -> None:
    with pytest.raises(SystemExit):
        follow_op.parse_args("")
    assert "ERROR" in capsys.readouterr().err


def test_follow_preflight_already_follows(monkeypatch: pytest.MonkeyPatch) -> None:
    session = {"did": "did:plc:me", "accessJwt": "tok"}
    target_did = "did:plc:alice"
    monkeypatch.setattr(follow_op, "xrpc", lambda nsid, s, **kw: {
        "follows": [{"did": target_did}, {"did": "did:plc:other"}]
    })
    assert follow_op.preflight_follow(target_did, session) is True


def test_follow_preflight_not_following(monkeypatch: pytest.MonkeyPatch) -> None:
    session = {"did": "did:plc:me", "accessJwt": "tok"}
    monkeypatch.setattr(follow_op, "xrpc", lambda nsid, s, **kw: {
        "follows": [{"did": "did:plc:other"}]
    })
    assert follow_op.preflight_follow("did:plc:alice", session) is False


def test_follow_preflight_api_error_degrades(monkeypatch: pytest.MonkeyPatch) -> None:
    session = {"did": "did:plc:me", "accessJwt": "tok"}
    monkeypatch.setattr(follow_op, "xrpc", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("down")))
    assert follow_op.preflight_follow("did:plc:alice", session) is False


def test_follow_main_aborts_on_dupe(monkeypatch: pytest.MonkeyPatch,
                                     capsys: pytest.CaptureFixture[str]) -> None:
    target_did = "did:plc:alice"
    session = {"did": "did:plc:me", "accessJwt": "tok"}

    def fake_xrpc(nsid, s, method="GET", params=None, body=None, **kw):
        if nsid == "app.bsky.actor.getProfile":
            return {"did": target_did}
        return {"follows": [{"did": target_did}]}

    monkeypatch.setattr(follow_op, "get_handle", lambda: "me.bsky.social")
    monkeypatch.setattr(follow_op, "get_app_password", lambda: "pw")
    monkeypatch.setattr(follow_op, "get_session", lambda h, p: session)
    monkeypatch.setattr(follow_op, "xrpc", fake_xrpc)
    with pytest.raises(SystemExit) as exc:
        follow_op.main("alice.bsky.social")
    assert exc.value.code == 1
    assert "ABORT" in capsys.readouterr().err


def test_follow_main_force_bypasses_preflight(monkeypatch: pytest.MonkeyPatch,
                                               capsys: pytest.CaptureFixture[str]) -> None:
    target_did = "did:plc:alice"
    session = {"did": "did:plc:me", "accessJwt": "tok"}
    calls: list[str] = []

    def fake_xrpc(nsid, s, method="GET", params=None, body=None, **kw):
        calls.append(nsid)
        if nsid == "app.bsky.actor.getProfile":
            return {"did": target_did}
        return {"uri": "at://me/follow/1"}

    monkeypatch.setattr(follow_op, "get_handle", lambda: "me.bsky.social")
    monkeypatch.setattr(follow_op, "get_app_password", lambda: "pw")
    monkeypatch.setattr(follow_op, "get_session", lambda h, p: session)
    monkeypatch.setattr(follow_op, "xrpc", fake_xrpc)
    follow_op.main("alice.bsky.social|force")
    assert "followed" in capsys.readouterr().out
    assert "getFollows" not in calls


# pre-flight: publish --------------------------------------------------------

def test_publish_parse_args_with_force() -> None:
    body, reply, force = publish.parse_args("Hello world|at://x/y/z|force")
    assert body == "Hello world" and reply == "at://x/y/z" and force is True


def test_publish_parse_args_no_force() -> None:
    body, reply, force = publish.parse_args("Hello world|at://x/y/z")
    assert force is False


def test_publish_parse_args_no_reply_no_force() -> None:
    body, reply, force = publish.parse_args("Hello world")
    assert reply is None and force is False


def test_publish_preflight_already_replied(monkeypatch: pytest.MonkeyPatch) -> None:
    session = {"did": "did:plc:me", "accessJwt": "tok"}
    root_uri = "at://root/post/1"
    reply_uri = "at://parent/post/2"

    def fake_xrpc(nsid, s, **kw):
        if nsid == "app.bsky.feed.getPostThread":
            return {"thread": {"post": {"uri": reply_uri, "cid": "c",
                                         "record": {"reply": {"root": {"uri": root_uri}}}}}}
        return {"feed": [
            {"post": {"uri": "at://me/post/9",
                       "record": {"reply": {"root": {"uri": root_uri}}}}},
        ]}

    monkeypatch.setattr(publish, "xrpc", fake_xrpc)
    assert publish.preflight_publish(reply_uri, session) is True


def test_publish_preflight_not_replied(monkeypatch: pytest.MonkeyPatch) -> None:
    session = {"did": "did:plc:me", "accessJwt": "tok"}
    root_uri = "at://root/post/1"
    reply_uri = "at://parent/post/2"

    def fake_xrpc(nsid, s, **kw):
        if nsid == "app.bsky.feed.getPostThread":
            return {"thread": {"post": {"uri": reply_uri, "cid": "c",
                                         "record": {"reply": {"root": {"uri": root_uri}}}}}}
        return {"feed": [
            {"post": {"uri": "at://me/post/9",
                       "record": {"reply": {"root": {"uri": "at://other/root/1"}}}}},
        ]}

    monkeypatch.setattr(publish, "xrpc", fake_xrpc)
    assert publish.preflight_publish(reply_uri, session) is False


def test_publish_preflight_api_error_degrades(monkeypatch: pytest.MonkeyPatch) -> None:
    session = {"did": "did:plc:me", "accessJwt": "tok"}
    monkeypatch.setattr(publish, "xrpc", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("down")))
    assert publish.preflight_publish("at://x/y/z", session) is False


def test_publish_main_aborts_on_dupe_reply(monkeypatch: pytest.MonkeyPatch,
                                            capsys: pytest.CaptureFixture[str]) -> None:
    session = {"did": "did:plc:me", "accessJwt": "tok"}
    root_uri = "at://root/post/1"
    reply_uri = "at://parent/post/2"

    def fake_xrpc(nsid, s, method="GET", params=None, body=None, **kw):
        if nsid == "app.bsky.feed.getPostThread":
            return {"thread": {"post": {"uri": reply_uri, "cid": "c",
                                         "record": {"reply": {"root": {"uri": root_uri}}}}}}
        return {"feed": [
            {"post": {"uri": "at://me/post/9",
                       "record": {"reply": {"root": {"uri": root_uri}}}}},
        ]}

    monkeypatch.setattr(publish, "get_handle", lambda: "me.bsky.social")
    monkeypatch.setattr(publish, "get_app_password", lambda: "pw")
    monkeypatch.setattr(publish, "get_session", lambda h, p: session)
    monkeypatch.setattr(publish, "xrpc", fake_xrpc)
    with pytest.raises(SystemExit) as exc:
        publish.main(f"Hello|{reply_uri}")
    assert exc.value.code == 1
    assert "ABORT" in capsys.readouterr().err


def test_publish_main_force_bypasses_preflight(monkeypatch: pytest.MonkeyPatch,
                                                capsys: pytest.CaptureFixture[str]) -> None:
    session = {"did": "did:plc:me", "accessJwt": "tok"}
    reply_uri = "at://parent/post/2"
    calls: list[str] = []

    def fake_xrpc(nsid, s, method="GET", params=None, body=None, **kw):
        calls.append(nsid)
        if nsid == "app.bsky.feed.getPostThread":
            return {"thread": {"post": {"uri": reply_uri, "cid": "c", "record": {}}}}
        return {"uri": "at://me/post/new", "cid": "cid-new"}

    monkeypatch.setattr(publish, "get_handle", lambda: "me.bsky.social")
    monkeypatch.setattr(publish, "get_app_password", lambda: "pw")
    monkeypatch.setattr(publish, "get_session", lambda h, p: session)
    monkeypatch.setattr(publish, "xrpc", fake_xrpc)
    publish.main(f"Hello|{reply_uri}|force")
    assert "published" in capsys.readouterr().out
    assert "getAuthorFeed" not in calls
