import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import patch

import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError

from pygitweb.auth import authenticate_http_key, authenticate_local_user
from pygitweb.auth_config import AuthConfig, LocalUser
from pygitweb.config import Settings, settings
from pygitweb.conftest import set_client_session
from pygitweb.main import app
from pygitweb.password_hash import is_password_hash, verify_password
from pygitweb.sessions import create_session

_SALT = "0123456789abcdef"


def _config() -> AuthConfig:
    return AuthConfig(
        auth_mode="local",
        salt=_SALT,
        local_users=[LocalUser(user="admin", password="secret", email="admin@example.com")],
    )


def test_api_key_settings_default_to_two_keys_without_expiry() -> None:
    cfg = Settings(AUTH=False)
    assert cfg.API_KEY_MAX_COUNT == 2
    assert cfg.API_KEY_EXPIRY_DAYS == 0
    with pytest.raises(ValidationError):
        Settings(AUTH=False, API_KEY_MAX_COUNT=-1)


def test_issue_api_key_returns_plaintext_and_stores_hash(tmp_path: Path) -> None:
    cfg = _config()
    path = tmp_path / "auth.json"
    client = TestClient(app)
    with (
        patch.object(settings, "AUTH", True),
        patch.object(settings, "AUTH_CONFIG", str(path)),
        patch("pygitweb.auth_config.auth_config", cfg),
        patch("pygitweb.auth.auth_config", cfg),
        patch("pygitweb.main.auth_config", cfg),
    ):
        set_client_session(client, "admin")
        page = client.get("/user")
        assert page.status_code == 200
        assert "API keys" in page.text
        assert "Create key" in page.text
        issued = client.post("/user/keys", json={"id": "laptop", "readonly": True})
        assert issued.status_code == 200, issued.text
        body = issued.json()
        assert body["id"] == "laptop"
        assert body["readonly"] is True
        plaintext = body["secret"]
        assert isinstance(plaintext, str)
        assert not plaintext.startswith("scrypt$")
        assert body["time_expiry"] is None
        stored = cfg.local_users[0].keys[0]
        assert stored.secret != plaintext
        assert is_password_hash(stored.secret)
        assert verify_password(plaintext, stored.secret, config_salt=_SALT)
        on_disk = json.loads(path.read_text(encoding="utf-8"))
        assert on_disk["local_users"][0]["keys"][0]["secret"] == stored.secret
        assert plaintext not in path.read_text(encoding="utf-8")
        matched = authenticate_http_key("admin", plaintext)
        assert matched is not None
        assert matched[1] is True
        again = client.get("/user")
        assert "laptop" in again.text
        assert plaintext not in again.text


def test_issue_api_key_enforces_limit_expiry_and_duplicate_id(tmp_path: Path) -> None:
    cfg = _config()
    path = tmp_path / "auth.json"
    client = TestClient(app)
    with (
        patch.object(settings, "AUTH", True),
        patch.object(settings, "AUTH_CONFIG", str(path)),
        patch.object(settings, "API_KEY_MAX_COUNT", 2),
        patch.object(settings, "API_KEY_EXPIRY_DAYS", 7),
        patch("pygitweb.auth_config.auth_config", cfg),
        patch("pygitweb.auth.auth_config", cfg),
        patch("pygitweb.main.auth_config", cfg),
    ):
        set_client_session(client, "admin")
        before = datetime.now(UTC)
        first = client.post("/user/keys", json={"id": "alpha-key"})
        assert first.status_code == 200, first.text
        duplicate = client.post("/user/keys", json={"id": "alpha-key"})
        second = client.post("/user/keys", json={"id": "beta-key"})
        assert second.status_code == 200, second.text
        third = client.post("/user/keys", json={"id": "gamma-key"})
        assert duplicate.status_code == 409
        assert third.status_code == 409
        assert len(cfg.local_users[0].keys) == 2
        expiry = datetime.fromisoformat(first.json()["time_expiry"])
        assert before + timedelta(days=7) <= expiry <= datetime.now(UTC) + timedelta(days=7)
        assert second.json()["time_expiry"] is not None
        revoked = client.delete("/user/keys", params={"id": "alpha-key"})
        assert revoked.status_code == 204
        assert [key.id for key in cfg.local_users[0].keys] == ["beta-key"]
        page = client.get("/user")
        assert "alpha-key" not in page.text
        assert "beta-key" in page.text
        replaced = client.post("/user/keys", json={"id": "alpha-key"})
        assert replaced.status_code == 200
        assert replaced.json()["id"] == "alpha-key"


def test_issue_api_key_requires_a_local_account_and_a_session(tmp_path: Path) -> None:
    cfg = _config()
    path = tmp_path / "auth.json"
    client = TestClient(app)
    with (
        patch.object(settings, "AUTH", True),
        patch.object(settings, "AUTH_CONFIG", str(path)),
        patch("pygitweb.auth_config.auth_config", cfg),
        patch("pygitweb.auth.auth_config", cfg),
        patch("pygitweb.main.auth_config", cfg),
    ):
        missing = client.post("/user/keys", json={"id": "laptop"})
        assert missing.status_code == 401
        token = create_session("nobody", auth_method="local")
        unknown = client.post(
            "/user/keys",
            json={"id": "laptop"},
            headers={"Authorization": f"Bearer {token}"},
        )
        assert unknown.status_code == 404
        page = client.get("/user", headers={"Authorization": f"Bearer {token}"})
        assert "cannot be stored" in page.text
        email_token = create_session("other-name", email="Admin@Example.com", auth_method="oauth")
        by_email = client.post(
            "/user/keys",
            json={"id": "mail-key"},
            headers={"Authorization": f"Bearer {email_token}"},
        )
        assert by_email.status_code == 200, by_email.text
        assert len(cfg.local_users) == 1
        assert cfg.local_users[0].keys[0].id == "mail-key"
        missing_key = client.delete(
            "/user/keys", params={"id": "missing"}, headers={"Authorization": f"Bearer {email_token}"}
        )
        assert missing_key.status_code == 404
        revoke_unknown = client.delete(
            "/user/keys", params={"id": "mail-key"}, headers={"Authorization": f"Bearer {token}"}
        )
        assert revoke_unknown.status_code == 404
    with patch.object(settings, "AUTH", False):
        disabled = client.post("/user/keys", json={"id": "laptop"})
        assert disabled.status_code == 400
        disabled_revoke = client.delete("/user/keys", params={"id": "laptop"})
        assert disabled_revoke.status_code == 400


def test_issue_api_key_keeps_memory_unchanged_when_save_fails(tmp_path: Path) -> None:
    cfg = _config()
    path = tmp_path / "auth.json"
    client = TestClient(app, raise_server_exceptions=False)
    with (
        patch.object(settings, "AUTH", True),
        patch.object(settings, "AUTH_CONFIG", str(path)),
        patch("pygitweb.auth_config.auth_config", cfg),
        patch("pygitweb.auth.auth_config", cfg),
        patch("pygitweb.main.auth_config", cfg),
        patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")),
    ):
        set_client_session(client, "admin")
        response = client.post("/user/keys", json={"id": "laptop"})
        assert response.status_code == 500
        assert cfg.local_users[0].keys == []


def test_revoke_api_key_keeps_memory_unchanged_when_save_fails(tmp_path: Path) -> None:
    cfg = _config()
    path = tmp_path / "auth.json"
    client = TestClient(app, raise_server_exceptions=False)
    with (
        patch.object(settings, "AUTH", True),
        patch.object(settings, "AUTH_CONFIG", str(path)),
        patch("pygitweb.auth_config.auth_config", cfg),
        patch("pygitweb.auth.auth_config", cfg),
        patch("pygitweb.main.auth_config", cfg),
    ):
        set_client_session(client, "admin")
        issued = client.post("/user/keys", json={"id": "laptop"})
        assert issued.status_code == 200, issued.text
        with patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")):
            response = client.delete("/user/keys", params={"id": "laptop"})
        assert response.status_code == 500
        assert [key.id for key in cfg.local_users[0].keys] == ["laptop"]


def test_oauth_session_issues_a_passwordless_local_user(tmp_path: Path) -> None:
    cfg = _config()
    path = tmp_path / "auth.json"
    client = TestClient(app)
    with (
        patch.object(settings, "AUTH", True),
        patch.object(settings, "AUTH_CONFIG", str(path)),
        patch("pygitweb.auth_config.auth_config", cfg),
        patch("pygitweb.auth.auth_config", cfg),
        patch("pygitweb.main.auth_config", cfg),
    ):
        token = create_session("admin", email="bob@example.com", auth_method="oauth")
        headers = {"Authorization": f"Bearer {token}"}
        page = client.get("/user", headers=headers)
        assert page.status_code == 200
        assert "Create key" in page.text
        assert "Smart HTTP username for these keys: <code>bob@example.com</code>" in page.text
        assert "cannot be stored" not in page.text
        assert cfg.local_users[0].keys == []
        issued = client.post("/user/keys", json={"id": "laptop", "readonly": True}, headers=headers)
        assert issued.status_code == 200, issued.text
        plaintext = issued.json()["secret"]
        assert len(cfg.local_users) == 2
        created = cfg.local_users[1]
        assert created.user == "bob@example.com"
        assert created.email == "bob@example.com"
        assert created.password is None
        assert created.pass_hash == ""
        assert created.assume_anonymous is False
        assert [key.id for key in cfg.local_users[0].keys] == []
        assert created.keys[0].readonly is True
        assert verify_password(plaintext, created.keys[0].secret, config_salt=_SALT)
        on_disk = json.loads(path.read_text(encoding="utf-8"))
        stored = on_disk["local_users"][1]
        assert "pass" not in stored
        assert stored["pass_hash"] == ""
        assert plaintext not in path.read_text(encoding="utf-8")
        assert authenticate_local_user("bob@example.com", plaintext) is None
        matched = authenticate_http_key("bob@example.com", plaintext)
        assert matched is not None
        assert matched[0].email == "bob@example.com"
        assert matched[1] is True
        assert authenticate_http_key("BOB@example.com", plaintext) == matched
        assert authenticate_http_key("admin", plaintext) is None
        again = client.post("/user/keys", json={"id": "phone"}, headers=headers)
        assert again.status_code == 200, again.text
        assert len(cfg.local_users) == 2
        assert [key.id for key in created.keys] == ["laptop", "phone"]
        revoked = client.delete("/user/keys", params={"id": "laptop"}, headers=headers)
        assert revoked.status_code == 204
        assert [key.id for key in created.keys] == ["phone"]
        no_email = create_session("admin", auth_method="oauth")
        missing = client.post(
            "/user/keys",
            json={"id": "laptop"},
            headers={"Authorization": f"Bearer {no_email}"},
        )
        assert missing.status_code == 404
        assert missing.json()["detail"] == "An email is required to store API keys"
        blocked = client.get("/user", headers={"Authorization": f"Bearer {no_email}"})
        assert "cannot be stored" in blocked.text


def test_oauth_key_name_collision_does_not_attach_to_the_other_account(tmp_path: Path) -> None:
    cfg = AuthConfig(
        auth_mode="local",
        salt=_SALT,
        local_users=[LocalUser(user="taken@example.com", password="secret", email="other@example.com")],
    )
    path = tmp_path / "auth.json"
    client = TestClient(app)
    with (
        patch.object(settings, "AUTH", True),
        patch.object(settings, "AUTH_CONFIG", str(path)),
        patch("pygitweb.auth_config.auth_config", cfg),
        patch("pygitweb.auth.auth_config", cfg),
        patch("pygitweb.main.auth_config", cfg),
    ):
        token = create_session("taken@example.com", email="taken@example.com", auth_method="oauth")
        response = client.post(
            "/user/keys",
            json={"id": "laptop"},
            headers={"Authorization": f"Bearer {token}"},
        )
        assert response.status_code == 409
        assert response.json()["detail"] == "A local account already uses this name"
        assert len(cfg.local_users) == 1
        assert cfg.local_users[0].keys == []


def test_oauth_key_user_is_removed_when_save_fails(tmp_path: Path) -> None:
    cfg = _config()
    path = tmp_path / "auth.json"
    client = TestClient(app, raise_server_exceptions=False)
    with (
        patch.object(settings, "AUTH", True),
        patch.object(settings, "AUTH_CONFIG", str(path)),
        patch("pygitweb.auth_config.auth_config", cfg),
        patch("pygitweb.auth.auth_config", cfg),
        patch("pygitweb.main.auth_config", cfg),
        patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")),
    ):
        token = create_session("admin", email="bob@example.com", auth_method="oauth")
        response = client.post(
            "/user/keys",
            json={"id": "laptop"},
            headers={"Authorization": f"Bearer {token}"},
        )
        assert response.status_code == 500
        assert [user.user for user in cfg.local_users] == ["admin"]
        assert cfg.local_users[0].keys == []