# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# Licensed under the MIT License

"""Tests for credential health — EnsureCredentials pattern (Sprint 1)."""

import json
import time
from unittest.mock import MagicMock, patch

import pytest

from familiar.core.credential_health import (
    _STATUS_FILE,
    CredentialStatus,
    _reset_for_testing,
    clear_attention,
    ensure_credentials,
    get_all_statuses,
    get_attention_needed,
    get_credential_status,
    invalidate_credential,
    validate_all,
)


@pytest.fixture(autouse=True)
def clean_state(tmp_path, monkeypatch):
    """Reset module state and redirect status file to tmp_path."""
    _reset_for_testing()
    status_file = tmp_path / "credential_status.json"
    monkeypatch.setattr("familiar.core.credential_health._STATUS_FILE", status_file)
    yield
    _reset_for_testing()


# ── CredentialStatus Dataclass ───────────────────────────────────────────────


class TestCredentialStatus:
    def test_defaults(self):
        s = CredentialStatus(service_key="test")
        assert s.configured is False
        assert s.valid is None
        assert s.attention_needed is False
        assert s.last_validated is None
        assert s.last_error is None
        assert s.expires_at is None
        assert s.metadata == {}

    def test_to_dict(self):
        s = CredentialStatus(
            service_key="github",
            configured=True,
            valid=True,
            last_validated="2026-03-15T00:00:00+00:00",
        )
        d = s.to_dict()
        assert d["service_key"] == "github"
        assert d["configured"] is True
        assert d["valid"] is True

    def test_full_construction(self):
        s = CredentialStatus(
            service_key="x",
            configured=True,
            valid=False,
            attention_needed=True,
            last_validated="2026-01-01T00:00:00+00:00",
            last_error="expired",
            expires_at="2026-01-02T00:00:00+00:00",
            metadata={"user": "test"},
        )
        assert s.attention_needed is True
        assert s.last_error == "expired"


# ── ensure_credentials ───────────────────────────────────────────────────────


class TestEnsureCredentials:
    def test_unknown_service(self):
        status = ensure_credentials("nonexistent")
        assert status.configured is False
        assert status.valid is None

    def test_unconfigured_service(self, monkeypatch):
        monkeypatch.delenv("GITHUB_TOKEN", raising=False)
        status = ensure_credentials("github")
        assert status.configured is False
        assert status.valid is False
        assert "Not configured" in status.last_error

    def test_configured_and_valid(self, monkeypatch):
        monkeypatch.setenv("GITHUB_TOKEN", "ghp_test")
        with patch(
            "familiar.core.credential_registry.validate_github",
            return_value=(True, "OK (HTTP 200)"),
        ):
            status = ensure_credentials("github")
        assert status.configured is True
        assert status.valid is True
        assert status.attention_needed is False
        assert status.last_validated is not None

    def test_configured_but_invalid(self, monkeypatch):
        monkeypatch.setenv("GITHUB_TOKEN", "ghp_expired")
        with patch(
            "familiar.core.credential_registry.validate_github",
            return_value=(False, "HTTP 401: Bad credentials"),
        ):
            status = ensure_credentials("github")
        assert status.configured is True
        assert status.valid is False
        assert status.attention_needed is True
        assert "401" in status.last_error

    def test_cache_hit_skips_revalidation(self, monkeypatch):
        monkeypatch.setenv("GITHUB_TOKEN", "ghp_test")
        with patch(
            "familiar.core.credential_registry.validate_github",
            return_value=(True, "OK"),
        ) as mock_validate:
            ensure_credentials("github")
            # Second call should use cache (github check_interval=14400)
            ensure_credentials("github")
        assert mock_validate.call_count == 1

    def test_cache_miss_after_invalidation(self, monkeypatch):
        monkeypatch.setenv("GITHUB_TOKEN", "ghp_test")
        with patch(
            "familiar.core.credential_registry.validate_github",
            return_value=(True, "OK"),
        ) as mock_validate:
            ensure_credentials("github")
            invalidate_credential("github")
            ensure_credentials("github")
        assert mock_validate.call_count == 2

    def test_none_type_service(self):
        """Services with NONE credential type should validate."""
        status = ensure_credentials("websearch")
        assert status.configured is True
        assert status.valid is True

    def test_on_demand_interval_always_revalidates(self, monkeypatch):
        """Services with check_interval=0 should always revalidate."""
        monkeypatch.setenv("FAMILIAR_DASHBOARD_KEY", "a-long-secret-key-1234")
        status1 = ensure_credentials("dashboard_key")
        assert status1.valid is True
        # Second call should still validate (check_interval=0)
        status2 = ensure_credentials("dashboard_key")
        assert status2.valid is True

    def test_refresh_fn_success(self, monkeypatch):
        """When refresh_fn succeeds, should return valid without running validate."""
        with (
            patch(
                "familiar.core.credential_registry.is_configured",
                return_value=True,
            ),
            patch(
                "familiar.core.credential_health._refresh_google_oauth",
                return_value=(True, "Refreshed: google_token"),
            ),
        ):
            status = ensure_credentials("google_workspace")
        assert status.valid is True
        assert status.attention_needed is False
        assert "refresh_result" in status.metadata

    def test_refresh_fn_failure_falls_through(self, monkeypatch):
        """When refresh_fn fails, should fall through to validate_fn."""
        with (
            patch(
                "familiar.core.credential_registry.is_configured",
                return_value=True,
            ),
            patch(
                "familiar.core.credential_health._refresh_google_oauth",
                return_value=(False, "No refresh token"),
            ),
            patch(
                "familiar.core.credential_registry.validate_google_workspace",
                return_value=(True, "Tokens: Calendar"),
            ),
        ):
            status = ensure_credentials("google_workspace")
        assert status.valid is True


# ── get_credential_status ────────────────────────────────────────────────────


class TestGetCredentialStatus:
    def test_uncached(self, monkeypatch):
        monkeypatch.delenv("GITHUB_TOKEN", raising=False)
        status = get_credential_status("github")
        assert status.service_key == "github"
        assert status.valid is None  # untested

    def test_after_ensure(self, monkeypatch):
        monkeypatch.setenv("GITHUB_TOKEN", "ghp_test")
        with patch(
            "familiar.core.credential_registry.validate_github",
            return_value=(True, "OK"),
        ):
            ensure_credentials("github")
        status = get_credential_status("github")
        assert status.valid is True


# ── get_all_statuses ─────────────────────────────────────────────────────────


class TestGetAllStatuses:
    def test_returns_all(self):
        statuses = get_all_statuses()
        assert len(statuses) > 0
        keys = [s.service_key for s in statuses]
        assert "anthropic" in keys
        assert "github" in keys

    def test_filter_by_category(self):
        statuses = get_all_statuses(category="llm")
        assert all(True for s in statuses)  # all returned
        keys = [s.service_key for s in statuses]
        assert "anthropic" in keys
        assert "github" not in keys

    def test_includes_cached(self, monkeypatch):
        monkeypatch.setenv("GITHUB_TOKEN", "ghp_test")
        with patch(
            "familiar.core.credential_registry.validate_github",
            return_value=(True, "OK"),
        ):
            ensure_credentials("github")
        statuses = get_all_statuses()
        github = [s for s in statuses if s.service_key == "github"][0]
        assert github.valid is True


# ── invalidate_credential ────────────────────────────────────────────────────


class TestInvalidateCredential:
    def test_invalidate_clears_valid(self, monkeypatch):
        monkeypatch.setenv("GITHUB_TOKEN", "ghp_test")
        with patch(
            "familiar.core.credential_registry.validate_github",
            return_value=(True, "OK"),
        ):
            ensure_credentials("github")
        invalidate_credential("github")
        status = get_credential_status("github")
        assert status.valid is None
        assert status.last_validated is None

    def test_invalidate_nonexistent(self):
        """Should not raise."""
        invalidate_credential("nonexistent")


# ── clear_attention ──────────────────────────────────────────────────────────


class TestClearAttention:
    def test_clear_after_failure(self, monkeypatch):
        monkeypatch.setenv("GITHUB_TOKEN", "ghp_bad")
        with patch(
            "familiar.core.credential_registry.validate_github",
            return_value=(False, "401 Unauthorized"),
        ):
            ensure_credentials("github")
        status = get_credential_status("github")
        assert status.attention_needed is True

        clear_attention("github")
        status = get_credential_status("github")
        assert status.attention_needed is False

    def test_clear_nonexistent(self):
        """Should not raise."""
        clear_attention("nonexistent")


# ── get_attention_needed ─────────────────────────────────────────────────────


class TestGetAttentionNeeded:
    def test_empty_initially(self):
        assert get_attention_needed() == []

    def test_returns_broken_credentials(self, monkeypatch):
        monkeypatch.setenv("GITHUB_TOKEN", "ghp_bad")
        with patch(
            "familiar.core.credential_registry.validate_github",
            return_value=(False, "401"),
        ):
            ensure_credentials("github")
        needs = get_attention_needed()
        assert len(needs) == 1
        assert needs[0].service_key == "github"

    def test_cleared_not_returned(self, monkeypatch):
        monkeypatch.setenv("GITHUB_TOKEN", "ghp_bad")
        with patch(
            "familiar.core.credential_registry.validate_github",
            return_value=(False, "401"),
        ):
            ensure_credentials("github")
        clear_attention("github")
        assert get_attention_needed() == []


# ── validate_all ─────────────────────────────────────────────────────────────


class TestValidateAll:
    def test_summary_format(self, monkeypatch):
        # Just validate unconfigured services (fast, no network)
        # Clear all env vars to avoid accidentally hitting real APIs
        for key in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GITHUB_TOKEN"]:
            monkeypatch.delenv(key, raising=False)
        result = validate_all(category="security")
        assert "total" in result
        assert "configured" in result
        assert "valid" in result
        assert "attention" in result
        assert "errors" in result
        assert isinstance(result["errors"], list)

    def test_category_filter(self, monkeypatch):
        result = validate_all(category="llm")
        assert result["total"] >= 4


# ── Persistence ──────────────────────────────────────────────────────────────


class TestPersistence:
    def test_save_and_reload(self, tmp_path, monkeypatch):
        status_file = tmp_path / "credential_status.json"
        monkeypatch.setattr("familiar.core.credential_health._STATUS_FILE", status_file)

        monkeypatch.setenv("GITHUB_TOKEN", "ghp_test")
        with patch(
            "familiar.core.credential_registry.validate_github",
            return_value=(True, "OK"),
        ):
            ensure_credentials("github")

        assert status_file.exists()
        data = json.loads(status_file.read_text())
        assert "github" in data
        assert data["github"]["valid"] is True

        # Reset and reload
        _reset_for_testing()
        status = get_credential_status("github")
        assert status.valid is True

    def test_corrupt_file_handled(self, tmp_path, monkeypatch):
        status_file = tmp_path / "credential_status.json"
        status_file.write_text("not json!!!")
        monkeypatch.setattr("familiar.core.credential_health._STATUS_FILE", status_file)

        _reset_for_testing()
        # Should not raise
        status = get_credential_status("github")
        assert status.valid is None


# ── Google OAuth Refresh ─────────────────────────────────────────────────────


class TestGoogleRefresh:
    def test_refresh_no_token_files(self, tmp_path, monkeypatch):
        monkeypatch.setattr("familiar.core.credential_health.Path.home", lambda: tmp_path)
        from familiar.core.credential_health import _refresh_google_oauth

        ok, msg = _refresh_google_oauth()
        assert ok is False
        assert "No Google token" in msg

    def test_refresh_import_error(self, monkeypatch):
        monkeypatch.setattr(
            "familiar.core.credential_health.Path.home",
            lambda: Path("/nonexistent"),
        )
        import builtins

        real_import = builtins.__import__

        def mock_import(name, *args, **kwargs):
            if "google.oauth2" in name or "google.auth" in name:
                raise ImportError("not installed")
            return real_import(name, *args, **kwargs)

        monkeypatch.setattr(builtins, "__import__", mock_import)
        from familiar.core.credential_health import _refresh_google_oauth

        ok, msg = _refresh_google_oauth()
        assert ok is False
        assert "not installed" in msg


# Need Path import for test
from pathlib import Path
