"""Unit tests: OAuth client config resolution, state generation, authorization URL,
token exchange/refresh/revoke/userinfo — HTTP mocked (no real Google calls)."""
import json
import types
from unittest.mock import MagicMock, patch

import pytest

import app.services.gmail_service as gs
from app.services.gmail_service import (
    GmailClient,
    GmailOauthError,
    build_authorization_url,
    exchange_code_for_tokens,
    generate_state,
    get_userinfo,
    parse_client_json,
    refresh_access_token,
    resolve_client_config,
    revoke_token,
)


GOOD_CLIENT_JSON = json.dumps({
    "installed": {
        "client_id": "abc.apps.googleusercontent.com",
        "client_secret": "SECRET-XYZ",
        "auth_uri": "https://accounts.google.com/o/oauth2/auth",
        "token_uri": "https://oauth2.googleapis.com/token",
        "redirect_uris": ["http://localhost:8000/auth/google/callback"],
        "project_id": "my-project",
    }
}).encode()


class TestClientJsonParsing:
    def test_valid_installed(self):
        cfg = parse_client_json(GOOD_CLIENT_JSON)
        assert cfg["client_id"] == "abc.apps.googleusercontent.com"
        assert cfg["client_secret"] == "SECRET-XYZ"
        assert cfg["project_id"] == "my-project"
        assert cfg["redirect_uris"]

    def test_valid_web(self):
        web = json.dumps({"web": {"client_id": "w", "client_secret": "s",
                                  "redirect_uris": ["https://a/cb"]}}).encode()
        cfg = parse_client_json(web)
        assert cfg["client_id"] == "w"

    def test_invalid_json(self):
        with pytest.raises(GmailOauthError, match="not valid JSON"):
            parse_client_json(b"{nope")

    def test_missing_keys(self):
        with pytest.raises(GmailOauthError, match="installed.*web"):
            parse_client_json(json.dumps({"neither": {}}).encode())
        with pytest.raises(GmailOauthError):
            parse_client_json(json.dumps({"installed": {"client_id": "x"}}).encode())


class TestStateAndAuthUrl:
    def test_state_random_and_unique(self):
        states = {generate_state() for _ in range(50)}
        assert len(states) == 50
        assert all(len(s) >= 32 for s in states)

    def test_authorization_url_contents(self):
        url = build_authorization_url(
            {"client_id": "CID", "client_secret": "CS",
             "redirect_uri": "http://app/callback"}, "ST4TE")
        assert url.startswith("https://accounts.google.com/o/oauth2/")
        for frag in ("client_id=CID", "redirect_uri=", "response_type=code",
                     "access_type=offline", "prompt=consent", "state=ST4TE",
                     "scope="):
            assert frag in url, f"missing {frag}"

    def test_scope_includes_gmail_send_and_userinfo(self):
        url = build_authorization_url(
            {"client_id": "c", "client_secret": "s", "redirect_uri": "r"}, "x")
        # gmail.send (URL-encoded) and userinfo.email must be requested
        assert "https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fgmail.send" in url
        assert "userinfo.email" in url
        assert "gmail.compose" in url


class TestResolveClientConfig:
    def test_explicit_config_dict_wins(self):
        cfg = types.SimpleNamespace(client_id="A", client_secret="B", redirect_uri="http://r")
        out = resolve_client_config(cfg)
        assert out == {"client_id": "A", "client_secret": "B", "redirect_uri": "http://r"}

    def test_no_config_raises(self):
        # no DB row, no env path
        with patch.object(gs, "settings") as st:
            st.GOOGLE_OAUTH_CLIENT_JSON_PATH = ""
            with pytest.raises(GmailOauthError, match="No Google OAuth client configuration"):
                resolve_client_config(None)

    def test_env_file_path(self, tmp_path):
        p = tmp_path / "client_secret.json"
        p.write_text(GOOD_CLIENT_JSON.decode())
        with patch.object(gs, "settings") as st:
            st.GOOGLE_OAUTH_CLIENT_JSON_PATH = str(p)
            st.BASE_URL = "http://localhost:8000"
            out = resolve_client_config(None)
        assert out["client_id"] == "abc.apps.googleusercontent.com"
        assert out["redirect_uri"].endswith("/auth/google/callback")

    def test_bad_env_file(self, tmp_path):
        p = tmp_path / "bad.json"
        p.write_text('{"foo": 1}')
        with patch.object(gs, "settings") as st:
            st.GOOGLE_OAUTH_CLIENT_JSON_PATH = str(p)
            with pytest.raises(GmailOauthError, match="Invalid Google OAuth client JSON"):
                resolve_client_config(None)


class _Resp:
    def __init__(self, status, body):
        self.status_code = status
        self.text = body if isinstance(body, str) else json.dumps(body)
        self._body = body

    def json(self):
        return self._body


class TestTokenExchange:
    def test_success(self):
        with patch.object(gs.requests, "post",
                          return_value=_Resp(200, {"access_token": "AT", "refresh_token": "RT"})):
            tok = exchange_code_for_tokens({"client_id": "c", "client_secret": "s",
                                            "redirect_uri": "r"}, "code123")
        assert tok["access_token"] == "AT"

    def test_http_failure_raises(self):
        with patch.object(gs.requests, "post", return_value=_Resp(400, "invalid_grant")):
            with pytest.raises(GmailOauthError, match="400"):
                exchange_code_for_tokens({"client_id": "c", "client_secret": "s",
                                          "redirect_uri": "r"}, "bad")

    def test_missing_access_token(self):
        with patch.object(gs.requests, "post", return_value=_Resp(200, {"foo": 1})):
            with pytest.raises(GmailOauthError, match="missing access_token"):
                exchange_code_for_tokens({"client_id": "c", "client_secret": "s",
                                          "redirect_uri": "r"}, "x")


class TestTokenRefresh:
    def test_success(self):
        with patch.object(gs.requests, "post",
                          return_value=_Resp(200, {"access_token": "AT2", "expires_in": 3600})):
            tok = refresh_access_token({"client_id": "c", "client_secret": "s"}, "RT")
        assert tok["access_token"] == "AT2"

    def test_failure(self):
        with patch.object(gs.requests, "post", return_value=_Resp(401, "unauthorized")):
            with pytest.raises(GmailOauthError, match="refresh failed"):
                refresh_access_token({"client_id": "c", "client_secret": "s"}, "bad")


class TestRevoke:
    def test_success(self):
        with patch.object(gs.requests, "post", return_value=_Resp(200, "")):
            assert revoke_token("tok") is True

    def test_failure(self):
        with patch.object(gs.requests, "post", return_value=_Resp(400, "")):
            assert revoke_token("tok") is False

    def test_empty_token(self):
        assert revoke_token("") is False

    def test_network_error_false(self):
        with patch.object(gs.requests, "post", side_effect=RuntimeError("net")):
            assert revoke_token("tok") is False


class TestUserinfo:
    def test_success(self):
        with patch.object(gs.requests, "get",
                          return_value=_Resp(200, {"email": "me@gmail.com"})):
            assert get_userinfo("AT")["email"] == "me@gmail.com"

    def test_failure(self):
        with patch.object(gs.requests, "get", return_value=_Resp(401, "")):
            with pytest.raises(GmailOauthError):
                get_userinfo("bad")


class TestGmailClientSendApi:
    def _client(self):
        cfg = {"client_id": "c", "client_secret": "s"}
        with patch.object(GmailClient, "_ensure_valid"):
            c = GmailClient("acct@gmail.com", "AT", "RT", cfg, sending_method="api")
        return c

    def test_send_mime_api_returns_provider_response(self):
        c = self._client()
        from email.mime.text import MIMEText

        msg = MIMEText("body", "html")
        msg["From"] = "acct@gmail.com"
        msg["To"] = "to@x.com"
        sent = {"id": "MSGID123", "threadId": "TH123", "labelIds": ["SENT"]}
        fake_service = MagicMock()
        fake_service.users().messages().send.return_value.execute.return_value = sent
        c._service = fake_service
        out = c.send_mime_api(msg)
        assert out["ok"] is True
        assert out["message_id"] == "MSGID123"
        assert out["thread_id"] == "TH123"
        # the raw MIME was base64url-encoded in the API body
        body = fake_service.users().messages().send.call_args[1]["body"]
        assert set(body) == {"raw"}

    def test_send_mime_smtp_xoauth2(self):
        c = self._client()
        c._creds = MagicMock()
        c._creds.token = "ACCESSTOKEN"
        c._creds.valid = True
        from email.mime.text import MIMEText

        msg = MIMEText("x")
        msg["To"] = "to@x.com"

        recorded = {}

        class FakeSMTP:
            def __init__(self, host, port, timeout=30):
                recorded["host"], recorded["port"] = host, port
            def __enter__(self):
                return self
            def __exit__(self, *a):
                return False
            def ehlo(self, *a):
                return (250, b"ok")
            def starttls(self, context=None):
                recorded["starttls"] = True
            def docmd(self, cmd, arg=""):
                recorded["auth"] = (cmd, arg)
                return (235, b"accepted")
            def sendmail(self, frm, to, data):
                recorded["sent"] = (frm, to, data)
                return {}  # none refused

        import smtplib
        with patch.object(gs, "smtplib", types.SimpleNamespace(SMTP=FakeSMTP)) if False else patch("smtplib.SMTP", FakeSMTP):
            out = c.send_mime_smtp(msg)
        assert out["ok"] is True
        assert recorded["host"] == "smtp.gmail.com" and recorded["port"] == 587
        assert recorded["starttls"] is True
        cmd, arg = recorded["auth"]
        assert cmd == "AUTH" and arg.startswith("XOAUTH2 ")
        import base64
        decoded = base64.b64decode(arg.split(" ", 1)[1]).decode()
        assert decoded == "user=acct@gmail.com\x01auth=Bearer ACCESSTOKEN\x01\x01"
        frm, to, data = recorded["sent"]
        assert frm == "acct@gmail.com" and to == ["to@x.com"]

    def test_send_mime_smtp_refused_recipient(self):
        c = self._client()
        c._creds = MagicMock()
        c._creds.token = "T"
        c._creds.valid = True
        from email.mime.text import MIMEText

        msg = MIMEText("x")
        msg["To"] = "refused@x.com"

        class FakeSMTPRefused:
            def __init__(self, *a, **k):
                pass
            def __enter__(self):
                return self
            def __exit__(self, *a):
                return False
            def ehlo(self, *a):
                return (250, b"ok")
            def starttls(self, context=None):
                pass
            def docmd(self, cmd, arg=""):
                return (235, b"ok")
            def sendmail(self, frm, to, data):
                return {"refused@x.com": (550, b"no such user")}  # refused!

        with patch("smtplib.SMTP", FakeSMTPRefused):
            out = c.send_mime_smtp(msg)
        # refused recipients = permanent failure, NO fake success
        assert out["ok"] is False
        assert out["error_class"] == "permanent"

    def test_verify_connection_ok(self):
        c = self._client()
        fake_service = MagicMock()
        fake_service.users().getProfile.return_value.execute.return_value = {
            "emailAddress": "acct@gmail.com", "historyId": "1",
            "messagesTotal": 10, "threadsTotal": 2}
        c._service = fake_service
        v = c.verify_connection()
        assert v["ok"] is True and v["email_address"] == "acct@gmail.com"

    def test_verify_connection_failure_reports_honestly(self):
        c = self._client()
        fake_service = MagicMock()
        fake_service.users().getProfile.return_value.execute.side_effect = RuntimeError("401 invalid grant")
        c._service = fake_service
        v = c.verify_connection()
        assert v["ok"] is False
        assert "401" in v["detail"]

    def test_invalid_method_falls_back_to_api(self):
        with patch.object(GmailClient, "_ensure_valid"):
            c = GmailClient("a@x.com", "T", "R", {"client_id": "c", "client_secret": "s"},
                            sending_method="carrier-pigeon")
        assert c.method == "api"


class TestGmailClientAppPasswordSmtp:
    """The 'Bulk Add via SMTP' auth mode: plain Gmail address + 16-char app
    password, AUTH LOGIN over STARTTLS - no OAuth client/token involved at all."""

    def test_app_password_forces_smtp_method_no_oauth_creds(self):
        c = GmailClient("acct@gmail.com", smtp_password="abcdabcdabcdabcd")
        assert c.method == "smtp"
        assert c._creds is None  # no OAuth credentials object constructed

    def test_api_raises_honest_error_for_app_password_account(self):
        c = GmailClient("acct@gmail.com", smtp_password="abcdabcdabcdabcd")
        with pytest.raises(gs.GmailOauthError):
            c.send_mime_api(object())

    def test_send_mime_smtp_uses_auth_login_not_xoauth2(self):
        c = GmailClient("acct@gmail.com", smtp_password="abcdabcdabcdabcd")
        from email.mime.text import MIMEText
        msg = MIMEText("x"); msg["To"] = "to@x.com"

        recorded = {}

        class FakeSMTP:
            def __init__(self, host, port, timeout=30):
                recorded["host"], recorded["port"] = host, port
            def __enter__(self): return self
            def __exit__(self, *a): return False
            def ehlo(self, *a): return (250, b"ok")
            def starttls(self, context=None): recorded["starttls"] = True
            def login(self, user, password):
                recorded["login"] = (user, password)
            def sendmail(self, frm, to, data):
                recorded["sent"] = (frm, to, data)
                return {}

        with patch("smtplib.SMTP", FakeSMTP):
            out = c.send_mime_smtp(msg)
        assert out["ok"] is True
        assert "AUTH LOGIN" in out["raw_response"]
        assert recorded["login"] == ("acct@gmail.com", "abcdabcdabcdabcd")
        assert recorded["starttls"] is True
        frm, to, _ = recorded["sent"]
        assert frm == "acct@gmail.com" and to == ["to@x.com"]

    def test_send_mime_smtp_bad_app_password_reports_auth_failure(self):
        c = GmailClient("acct@gmail.com", smtp_password="wrongwrongwrong1")
        from email.mime.text import MIMEText
        msg = MIMEText("x"); msg["To"] = "to@x.com"

        class FakeSMTPBadAuth:
            def __init__(self, *a, **k): pass
            def __enter__(self): return self
            def __exit__(self, *a): return False
            def ehlo(self, *a): return (250, b"ok")
            def starttls(self, context=None): pass
            def login(self, user, password):
                import smtplib
                raise smtplib.SMTPAuthenticationError(535, b"5.7.8 Username and Password not accepted")

        with patch("smtplib.SMTP", FakeSMTPBadAuth):
            out = c.send_mime_smtp(msg)
        assert out["ok"] is False
        assert out["error_class"] == "permanent"
        assert "authentication" in out["error"].lower()

    def test_verify_connection_routes_to_smtp_login_for_app_password(self):
        c = GmailClient("acct@gmail.com", smtp_password="abcdabcdabcdabcd")

        class FakeSMTPOk:
            def __init__(self, *a, **k): pass
            def __enter__(self): return self
            def __exit__(self, *a): return False
            def ehlo(self, *a): return (250, b"ok")
            def starttls(self, context=None): pass
            def login(self, user, password): pass

        with patch("smtplib.SMTP", FakeSMTPOk):
            v = c.verify_connection()
        assert v["ok"] is True
        assert v["email_address"] == "acct@gmail.com"


class TestBulkSmtpHelpers:
    def test_normalize_app_password_strips_spaces(self):
        assert gs.normalize_app_password("abcd efgh ijkl mnop") == "abcdefghijklmnop"
        assert gs.normalize_app_password("  ABCDefghIJKLmnop ") == "ABCDefghIJKLmnop"

    def test_validate_smtp_credentials_syntax_ok(self):
        assert gs.validate_smtp_credentials_syntax(
            "user@gmail.com", "abcd efgh ijkl mnop") is None

    def test_validate_smtp_credentials_syntax_bad_email(self):
        err = gs.validate_smtp_credentials_syntax("not-an-email", "abcdefghijklmnop")
        assert err and "email" in err.lower()

    def test_validate_smtp_credentials_syntax_bad_password_length(self):
        err = gs.validate_smtp_credentials_syntax("user@gmail.com", "short")
        assert err and "16" in err

    def test_validate_smtp_credentials_syntax_bad_password_chars(self):
        err = gs.validate_smtp_credentials_syntax("user@gmail.com", "1234567890123456")
        assert err and "16" in err

    def test_verify_smtp_login_module_function_builds_client_and_calls_it(self):
        with patch.object(GmailClient, "verify_smtp_login",
                          return_value={"ok": True, "email_address": "a@gmail.com"}) as m:
            out = gs.verify_smtp_login("a@gmail.com", "abcd efgh ijkl mnop")
        assert out["ok"] is True
        m.assert_called_once()
