# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""Tests for the Social Dashboard tab (API endpoints + helpers)."""

import json
import os
from unittest import mock

import pytest

from familiar.dashboard.app import _strip_html, app


@pytest.fixture()
def client():
    """Flask test client with auth disabled."""
    app.config["TESTING"] = True
    with mock.patch.dict(os.environ, {"FAMILIAR_DASHBOARD_KEY": "testkey"}):
        with app.test_client() as c:
            yield c


def _auth_headers():
    return {"X-API-Key": "testkey"}


def _post_headers():
    return {
        "X-API-Key": "testkey",
        "Content-Type": "application/json",
        "Origin": "http://localhost:5000",
    }


# =============================================================================
# _strip_html tests
# =============================================================================


class TestStripHtml:
    """Tests for the HTML stripping helper."""

    def test_basic_tags(self):
        assert _strip_html("<p>Hello <b>world</b></p>") == "Hello world"

    def test_nested_tags(self):
        assert _strip_html("<div><span>nested</span></div>") == "nested"

    def test_no_tags(self):
        assert _strip_html("plain text") == "plain text"

    def test_empty_string(self):
        assert _strip_html("") == ""

    def test_self_closing_tags(self):
        assert _strip_html("line1<br/>line2") == "line1line2"


# =============================================================================
# /api/social/accounts tests
# =============================================================================


class TestSocialAccounts:
    """Tests for accounts detection endpoint."""

    def test_both_configured(self, client):
        with mock.patch(
            "familiar.dashboard.app.api_social_accounts.__wrapped__",
            side_effect=None,
        ):
            # Patch the skill-level _configured functions
            with mock.patch(
                "familiar.skills.bluesky.skill._configured", return_value=True
            ), mock.patch(
                "familiar.skills.mastodon.skill._configured", return_value=True
            ):
                res = client.get("/api/social/accounts", headers=_auth_headers())
                assert res.status_code == 200
                data = res.get_json()
                assert data["bluesky"] is True
                assert data["mastodon"] is True

    def test_only_bluesky(self, client):
        with mock.patch(
            "familiar.skills.bluesky.skill._configured", return_value=True
        ), mock.patch(
            "familiar.skills.mastodon.skill._configured", return_value=False
        ):
            res = client.get("/api/social/accounts", headers=_auth_headers())
            data = res.get_json()
            assert data["bluesky"] is True
            assert data["mastodon"] is False

    def test_only_mastodon(self, client):
        with mock.patch(
            "familiar.skills.bluesky.skill._configured", return_value=False
        ), mock.patch(
            "familiar.skills.mastodon.skill._configured", return_value=True
        ):
            res = client.get("/api/social/accounts", headers=_auth_headers())
            data = res.get_json()
            assert data["bluesky"] is False
            assert data["mastodon"] is True

    def test_neither_configured(self, client):
        with mock.patch(
            "familiar.skills.bluesky.skill._configured", return_value=False
        ), mock.patch(
            "familiar.skills.mastodon.skill._configured", return_value=False
        ):
            res = client.get("/api/social/accounts", headers=_auth_headers())
            data = res.get_json()
            assert data["bluesky"] is False
            assert data["mastodon"] is False


# =============================================================================
# /api/social/feed tests
# =============================================================================


class TestSocialFeed:
    """Tests for the combined feed endpoint."""

    def _bsky_timeline(self):
        return {
            "feed": [
                {
                    "post": {
                        "author": {
                            "handle": "alice.bsky.social",
                            "displayName": "Alice",
                            "avatar": "https://example.com/a.jpg",
                        },
                        "record": {
                            "text": "Hello from Bluesky",
                            "createdAt": "2026-03-07T12:00:00Z",
                        },
                        "uri": "at://did:plc:abc/app.bsky.feed.post/123",
                        "likeCount": 5,
                        "repostCount": 2,
                        "replyCount": 1,
                    }
                }
            ]
        }

    def _masto_timeline(self):
        return [
            {
                "account": {
                    "acct": "bob@mastodon.social",
                    "display_name": "Bob",
                    "avatar": "https://example.com/b.jpg",
                },
                "content": "<p>Hello from Mastodon</p>",
                "created_at": "2026-03-07T11:00:00Z",
                "url": "https://mastodon.social/@bob/456",
                "favourites_count": 3,
                "reblogs_count": 1,
                "replies_count": 0,
            }
        ]

    def test_combined_feed(self, client):
        with mock.patch(
            "familiar.dashboard.app._social_fetch_bluesky",
            return_value=self._bsky_timeline(),
        ), mock.patch(
            "familiar.dashboard.app._social_fetch_mastodon",
            return_value=self._masto_timeline(),
        ):
            res = client.get("/api/social/feed", headers=_auth_headers())
            assert res.status_code == 200
            data = res.get_json()
            posts = data["posts"]
            assert len(posts) == 2
            # Bluesky is newer (12:00) so should be first
            assert posts[0]["platform"] == "bluesky"
            assert posts[1]["platform"] == "mastodon"

    def test_sort_order(self, client):
        with mock.patch(
            "familiar.dashboard.app._social_fetch_bluesky",
            return_value=self._bsky_timeline(),
        ), mock.patch(
            "familiar.dashboard.app._social_fetch_mastodon",
            return_value=self._masto_timeline(),
        ):
            res = client.get("/api/social/feed", headers=_auth_headers())
            posts = res.get_json()["posts"]
            timestamps = [p["timestamp"] for p in posts]
            assert timestamps == sorted(timestamps, reverse=True)

    def test_single_platform_filter(self, client):
        with mock.patch(
            "familiar.dashboard.app._social_fetch_bluesky",
            return_value=self._bsky_timeline(),
        ), mock.patch(
            "familiar.dashboard.app._social_fetch_mastodon",
            return_value=None,
        ):
            res = client.get(
                "/api/social/feed?platform=bluesky", headers=_auth_headers()
            )
            posts = res.get_json()["posts"]
            assert len(posts) == 1
            assert posts[0]["platform"] == "bluesky"

    def test_empty_feed(self, client):
        with mock.patch(
            "familiar.dashboard.app._social_fetch_bluesky",
            return_value=None,
        ), mock.patch(
            "familiar.dashboard.app._social_fetch_mastodon",
            return_value=None,
        ):
            res = client.get("/api/social/feed", headers=_auth_headers())
            assert res.get_json()["posts"] == []

    def test_error_fallback(self, client):
        """One platform fails, other still returns data."""
        with mock.patch(
            "familiar.dashboard.app._social_fetch_bluesky",
            return_value=None,
        ), mock.patch(
            "familiar.dashboard.app._social_fetch_mastodon",
            return_value=self._masto_timeline(),
        ):
            res = client.get("/api/social/feed", headers=_auth_headers())
            posts = res.get_json()["posts"]
            assert len(posts) == 1
            assert posts[0]["platform"] == "mastodon"

    def test_mastodon_html_stripped(self, client):
        with mock.patch(
            "familiar.dashboard.app._social_fetch_bluesky",
            return_value=None,
        ), mock.patch(
            "familiar.dashboard.app._social_fetch_mastodon",
            return_value=self._masto_timeline(),
        ):
            res = client.get("/api/social/feed", headers=_auth_headers())
            text = res.get_json()["posts"][0]["text"]
            assert "<p>" not in text
            assert text == "Hello from Mastodon"


# =============================================================================
# /api/social/post tests
# =============================================================================


class TestSocialPost:
    """Tests for the post creation endpoint."""

    def test_post_both_platforms(self, client):
        with mock.patch(
            "familiar.skills.bluesky.skill.bluesky_post",
            return_value="Posted to Bluesky: https://bsky.app/...",
        ), mock.patch(
            "familiar.skills.mastodon.skill.mastodon_post",
            return_value="Posted to Mastodon: https://mastodon.social/...",
        ):
            res = client.post(
                "/api/social/post",
                headers=_post_headers(),
                data=json.dumps(
                    {"text": "Hello world", "platforms": ["bluesky", "mastodon"]}
                ),
            )
            assert res.status_code == 200
            results = res.get_json()["results"]
            assert "bluesky" in results
            assert "mastodon" in results

    def test_post_single_platform(self, client):
        with mock.patch(
            "familiar.skills.bluesky.skill.bluesky_post",
            return_value="Posted to Bluesky",
        ):
            res = client.post(
                "/api/social/post",
                headers=_post_headers(),
                data=json.dumps({"text": "Hello", "platforms": ["bluesky"]}),
            )
            assert res.status_code == 200
            results = res.get_json()["results"]
            assert "bluesky" in results
            assert "mastodon" not in results

    def test_post_no_text(self, client):
        res = client.post(
            "/api/social/post",
            headers=_post_headers(),
            data=json.dumps({"text": "", "platforms": ["bluesky"]}),
        )
        assert res.status_code == 400
        assert "required" in res.get_json()["error"].lower()

    def test_post_no_platforms(self, client):
        res = client.post(
            "/api/social/post",
            headers=_post_headers(),
            data=json.dumps({"text": "Hello", "platforms": []}),
        )
        assert res.status_code == 400
        assert "platform" in res.get_json()["error"].lower()

    def test_post_error_handling(self, client):
        with mock.patch(
            "familiar.skills.bluesky.skill.bluesky_post",
            side_effect=Exception("Auth failed"),
        ):
            res = client.post(
                "/api/social/post",
                headers=_post_headers(),
                data=json.dumps({"text": "Hello", "platforms": ["bluesky"]}),
            )
            assert res.status_code == 200
            assert "Error" in res.get_json()["results"]["bluesky"]


# =============================================================================
# /api/social/notifications tests
# =============================================================================


class TestSocialNotifications:
    """Tests for the notifications endpoint."""

    def test_combined_notifications(self, client):
        bsky_data = {
            "notifications": [
                {
                    "reason": "like",
                    "author": {"handle": "carol.bsky.social", "avatar": ""},
                    "record": {"text": ""},
                    "indexedAt": "2026-03-07T10:00:00Z",
                    "isRead": False,
                }
            ]
        }
        masto_data = [
            {
                "type": "favourite",
                "account": {"acct": "dave@masto.social", "avatar": ""},
                "status": {"content": "<p>Liked your post</p>"},
                "created_at": "2026-03-07T09:00:00Z",
            }
        ]
        with mock.patch(
            "familiar.dashboard.app._social_fetch_bluesky",
            return_value=bsky_data,
        ), mock.patch(
            "familiar.dashboard.app._social_fetch_mastodon",
            return_value=masto_data,
        ):
            res = client.get("/api/social/notifications", headers=_auth_headers())
            assert res.status_code == 200
            notifs = res.get_json()["notifications"]
            assert len(notifs) == 2
            # Bluesky is newer so first
            assert notifs[0]["platform"] == "bluesky"

    def test_empty_notifications(self, client):
        with mock.patch(
            "familiar.dashboard.app._social_fetch_bluesky",
            return_value=None,
        ), mock.patch(
            "familiar.dashboard.app._social_fetch_mastodon",
            return_value=None,
        ):
            res = client.get("/api/social/notifications", headers=_auth_headers())
            assert res.get_json()["notifications"] == []

    def test_single_platform_notifications(self, client):
        bsky_data = {
            "notifications": [
                {
                    "reason": "follow",
                    "author": {"handle": "eve.bsky.social", "avatar": ""},
                    "record": {},
                    "indexedAt": "2026-03-07T08:00:00Z",
                    "isRead": True,
                }
            ]
        }
        with mock.patch(
            "familiar.dashboard.app._social_fetch_bluesky",
            return_value=bsky_data,
        ), mock.patch(
            "familiar.dashboard.app._social_fetch_mastodon",
            return_value=None,
        ):
            res = client.get("/api/social/notifications", headers=_auth_headers())
            notifs = res.get_json()["notifications"]
            assert len(notifs) == 1
            assert notifs[0]["type"] == "follow"
            assert notifs[0]["is_read"] is True


# =============================================================================
# /api/social/poll tests
# =============================================================================


class TestSocialPoll:
    """Tests for the lightweight notification poll endpoint."""

    def test_poll_count(self, client):
        with mock.patch(
            "familiar.dashboard.app._social_fetch_bluesky",
            return_value={"count": 5},
        ), mock.patch(
            "familiar.dashboard.app._social_fetch_mastodon",
            return_value=[{"id": "1"}],
        ):
            res = client.get("/api/social/poll", headers=_auth_headers())
            assert res.status_code == 200
            assert res.get_json()["unread_count"] == 6

    def test_poll_no_platforms(self, client):
        with mock.patch(
            "familiar.dashboard.app._social_fetch_bluesky",
            return_value=None,
        ), mock.patch(
            "familiar.dashboard.app._social_fetch_mastodon",
            return_value=None,
        ):
            res = client.get("/api/social/poll", headers=_auth_headers())
            assert res.get_json()["unread_count"] == 0


# =============================================================================
# /api/social/config tests
# =============================================================================


class TestSocialConfig:
    """Tests for the config endpoint."""

    def test_both_accounts(self, client):
        with mock.patch(
            "familiar.skills.bluesky.skill._configured", return_value=True
        ), mock.patch(
            "familiar.skills.bluesky.skill._get_session",
            return_value={"handle": "alice.bsky.social", "accessJwt": "tok", "did": "did:plc:abc"},
        ), mock.patch(
            "familiar.dashboard.app._social_fetch_bluesky",
            return_value={"displayName": "Alice", "avatar": "https://example.com/a.jpg"},
        ), mock.patch(
            "familiar.skills.mastodon.skill._configured", return_value=True
        ), mock.patch(
            "familiar.dashboard.app._social_fetch_mastodon",
            return_value={
                "acct": "bob@mastodon.social",
                "display_name": "Bob",
                "avatar": "https://example.com/b.jpg",
            },
        ):
            res = client.get("/api/social/config", headers=_auth_headers())
            assert res.status_code == 200
            accounts = res.get_json()["accounts"]
            assert "bluesky" in accounts
            assert "mastodon" in accounts
            assert accounts["bluesky"]["handle"] == "alice.bsky.social"

    def test_no_accounts(self, client):
        with mock.patch(
            "familiar.skills.bluesky.skill._configured", return_value=False
        ), mock.patch(
            "familiar.skills.mastodon.skill._configured", return_value=False
        ):
            res = client.get("/api/social/config", headers=_auth_headers())
            assert res.get_json()["accounts"] == {}


# =============================================================================
# Auth gating tests
# =============================================================================


class TestSocialAuthGating:
    """All social endpoints require authentication."""

    def test_accounts_requires_auth(self, client):
        res = client.get("/api/social/accounts")
        assert res.status_code == 401

    def test_feed_requires_auth(self, client):
        res = client.get("/api/social/feed")
        assert res.status_code == 401

    def test_post_requires_auth(self, client):
        res = client.post(
            "/api/social/post",
            headers={
                "Content-Type": "application/json",
                "Origin": "http://localhost:5000",
            },
            data=json.dumps({"text": "hi", "platforms": ["bluesky"]}),
        )
        assert res.status_code == 401


# =============================================================================
# Platform helper tests
# =============================================================================


class TestPlatformHelpers:
    """Tests for _social_fetch_bluesky and _social_fetch_mastodon."""

    def test_bluesky_fetch_success(self):
        mock_resp = mock.MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"feed": []}

        with mock.patch(
            "familiar.skills.bluesky.skill._configured", return_value=True
        ), mock.patch(
            "familiar.skills.bluesky.skill._headers",
            return_value={"Authorization": "Bearer tok"},
        ), mock.patch(
            "familiar.skills.bluesky.skill._base_url",
            return_value="https://bsky.social",
        ), mock.patch("httpx.get", return_value=mock_resp):
            from familiar.dashboard.app import _social_fetch_bluesky

            result = _social_fetch_bluesky("app.bsky.feed.getTimeline")
            assert result == {"feed": []}

    def test_bluesky_fetch_not_configured(self):
        with mock.patch(
            "familiar.skills.bluesky.skill._configured", return_value=False
        ):
            from familiar.dashboard.app import _social_fetch_bluesky

            assert _social_fetch_bluesky("app.bsky.feed.getTimeline") is None

    def test_bluesky_fetch_failure(self):
        mock_resp = mock.MagicMock()
        mock_resp.status_code = 500

        with mock.patch(
            "familiar.skills.bluesky.skill._configured", return_value=True
        ), mock.patch(
            "familiar.skills.bluesky.skill._headers",
            return_value={"Authorization": "Bearer tok"},
        ), mock.patch(
            "familiar.skills.bluesky.skill._base_url",
            return_value="https://bsky.social",
        ), mock.patch("httpx.get", return_value=mock_resp):
            from familiar.dashboard.app import _social_fetch_bluesky

            assert _social_fetch_bluesky("app.bsky.feed.getTimeline") is None

    def test_mastodon_fetch_success(self):
        mock_resp = mock.MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = [{"id": "1"}]

        with mock.patch(
            "familiar.skills.mastodon.skill._configured", return_value=True
        ), mock.patch(
            "familiar.skills.mastodon.skill._headers",
            return_value={"Authorization": "Bearer tok"},
        ), mock.patch(
            "familiar.skills.mastodon.skill._base_url",
            return_value="https://mastodon.social",
        ), mock.patch("httpx.get", return_value=mock_resp):
            from familiar.dashboard.app import _social_fetch_mastodon

            result = _social_fetch_mastodon("/api/v1/timelines/home")
            assert result == [{"id": "1"}]

    def test_mastodon_fetch_not_configured(self):
        with mock.patch(
            "familiar.skills.mastodon.skill._configured", return_value=False
        ):
            from familiar.dashboard.app import _social_fetch_mastodon

            assert _social_fetch_mastodon("/api/v1/timelines/home") is None

    def test_mastodon_fetch_failure(self):
        mock_resp = mock.MagicMock()
        mock_resp.status_code = 401

        with mock.patch(
            "familiar.skills.mastodon.skill._configured", return_value=True
        ), mock.patch(
            "familiar.skills.mastodon.skill._headers",
            return_value={"Authorization": "Bearer tok"},
        ), mock.patch(
            "familiar.skills.mastodon.skill._base_url",
            return_value="https://mastodon.social",
        ), mock.patch("httpx.get", return_value=mock_resp):
            from familiar.dashboard.app import _social_fetch_mastodon

            assert _social_fetch_mastodon("/api/v1/timelines/home") is None
