"""
Authentication settings loaded from ~/.pygitweb/auth.json (or PYGITWEB_AUTH_CONFIG).

On first run with auth enabled, creates the file with generated local credentials.
"""

from __future__ import annotations

import json
import os
import secrets
import stat
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Literal, Self

from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_validator

from pygitweb.password_hash import hash_password, is_password_hash
from pygitweb.permissions import PermissionsMap

if TYPE_CHECKING:
    from pygitweb.config import Settings

DEFAULT_AUTH_CONFIG_PATH = Path.home() / ".pygitweb" / "auth.json"
_AUTH_CONFIG_MODE = 0o600


def _is_windows() -> bool:
    return sys.platform == "win32"


def _auth_config_mode(path: Path) -> int:
    return stat.S_IMODE(path.stat().st_mode)


def _set_auth_config_mode(path: Path) -> None:
    if _is_windows():
        return
    os.chmod(path, _AUTH_CONFIG_MODE)


class AuthConfigPermissionError(RuntimeError):
    """Auth config file exists but is not mode 0600 (non-Windows only)."""


def _require_auth_config_mode(path: Path) -> None:
    if _is_windows():
        return
    mode = _auth_config_mode(path)
    if mode != _AUTH_CONFIG_MODE:
        raise AuthConfigPermissionError(
            f"PyGitWeb: auth config {path} must have mode 0600 (found {mode:#04o}). Run: chmod 600 {path}"
        )


AuthMode = Literal["oauth", "local", "both"]
OAuthProvider = Literal["google", "github"]
OAuthUsernameFrom = Literal["sub", "email", "preffered_username", "login", "name"]

_AUTH_SALT_HEX_LEN = 16


def generate_auth_salt() -> str:
    return secrets.token_hex(_AUTH_SALT_HEX_LEN // 2)


def _as_utc(value: datetime) -> datetime:
    if value.tzinfo is None:
        return value.replace(tzinfo=UTC)
    return value.astimezone(UTC)


class HttpKey(BaseModel):
    id: str = Field(min_length=1)
    secret: str = Field(min_length=1)
    readonly: bool = False
    time_issued: datetime
    time_expiry: datetime | None = None

    @field_validator("id", "secret", mode="before")
    @classmethod
    def _strip_required_text(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        stripped = value.strip()
        if not stripped:
            raise ValueError("must be a non-empty string")
        return stripped

    @field_validator("secret")
    @classmethod
    def _require_secret_hash(cls, value: str) -> str:
        if not is_password_hash(value):
            raise ValueError("secret must be a scrypt password hash")
        return value

    @model_validator(mode="after")
    def _expiry_not_before_issue(self) -> Self:
        if self.time_expiry is not None and _as_utc(self.time_expiry) < _as_utc(self.time_issued):
            raise ValueError("time_expiry is before time_issued")
        return self

    def is_expired(self, now: datetime) -> bool:
        if self.time_expiry is None:
            return False
        return _as_utc(now) >= _as_utc(self.time_expiry)


class LocalUser(BaseModel):
    model_config = ConfigDict(populate_by_name=True)

    user: str = Field(min_length=2)
    password: str | None = Field(default=None, alias="pass", serialization_alias="pass")
    pass_hash: str = ""
    email: str | None = None
    assume_anonymous: bool = False
    keys: list[HttpKey] = Field(default_factory=list)

    @field_validator("keys")
    @classmethod
    def _unique_key_ids(cls, keys: list[HttpKey]) -> list[HttpKey]:
        seen: set[str] = set()
        for key in keys:
            if key.id in seen:
                raise ValueError(f"duplicate http key id: {key.id}")
            seen.add(key.id)
        return keys

    @field_validator("email", mode="before")
    @classmethod
    def _normalize_email(cls, value: object) -> str | None:
        if value is None or value == "":
            return None
        if isinstance(value, str):
            stripped = value.strip()
            return stripped or None
        return str(value).strip() or None


class AuthConfig(BaseModel):
    model_config = ConfigDict(populate_by_name=True)

    salt: str = ""
    auth_mode: AuthMode = "local"
    oauth_provider: OAuthProvider | str = ""
    oauth_client_id: str = ""
    oauth_client_secret: str = ""
    oauth_redirect_uri: str = ""
    oauth_username_from: OAuthUsernameFrom = "preffered_username"
    oauth_allowed_emails: list[str] = Field(default_factory=list)
    oauth_permissions: PermissionsMap = Field(default_factory=PermissionsMap.empty)
    local_users: list[LocalUser] = Field(default_factory=list)

    @field_validator("salt", mode="before")
    @classmethod
    def _validate_salt(cls, value: object) -> str:
        if value is None or value == "":
            return ""
        salt = str(value).strip().lower()
        if len(salt) != _AUTH_SALT_HEX_LEN or not all(c in "0123456789abcdef" for c in salt):
            raise ValueError(f"salt must be a {_AUTH_SALT_HEX_LEN}-character hex string")
        return salt

    @field_validator("oauth_permissions", mode="before")
    @classmethod
    def _validate_oauth_permissions(cls, v: object) -> PermissionsMap | dict[str, list[str]]:
        if isinstance(v, PermissionsMap):
            return v
        return PermissionsMap.model_validate(v)

    @field_serializer("oauth_permissions")
    def _serialize_oauth_permissions(self, value: PermissionsMap) -> dict[str, list[str]]:
        return value.root


def auth_config_path(settings: Settings) -> Path:
    if settings.AUTH_CONFIG:
        return Path(settings.AUTH_CONFIG).expanduser()
    return DEFAULT_AUTH_CONFIG_PATH


def default_auth_config(*, admin_password: str, salt: str) -> AuthConfig:
    return AuthConfig(
        salt=salt,
        auth_mode="local",
        oauth_provider="",
        oauth_client_id="",
        oauth_client_secret="",
        oauth_redirect_uri="",
        oauth_username_from="preffered_username",
        oauth_allowed_emails=[],
        oauth_permissions=PermissionsMap.default_access(),
        local_users=[LocalUser(user="admin", pass_hash=hash_password(admin_password, config_salt=salt))],
    )


def write_auth_config(path: Path, config: AuthConfig) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(
        json.dumps(config.model_dump(by_alias=True, mode="json", exclude_none=True), indent=2) + "\n",
        encoding="utf-8",
    )
    _set_auth_config_mode(path)


def _ensure_auth_salt(path: Path, config: AuthConfig) -> AuthConfig:
    if config.salt:
        return config
    salt = generate_auth_salt()
    updated = config.model_copy(update={"salt": salt})
    write_auth_config(path, updated)
    return updated


def _migrate_plaintext_passwords(path: Path, config: AuthConfig) -> AuthConfig:
    updated_users: list[LocalUser] = []
    changed = False
    for entry in config.local_users:
        if not entry.password:
            updated_users.append(entry)
            continue
        updated_users.append(
            entry.model_copy(
                update={
                    "password": None,
                    "pass_hash": hash_password(entry.password, config_salt=config.salt),
                }
            )
        )
        changed = True
    if not changed:
        return config
    migrated = config.model_copy(update={"local_users": updated_users})
    write_auth_config(path, migrated)
    print(
        f"WARNING: hashed plaintext password(s) in {path}.\n",
        file=sys.stderr,
    )
    return migrated


def load_auth_config_file(path: Path) -> AuthConfig:
    return AuthConfig.model_validate_json(path.read_text(encoding="utf-8"))


def is_local_auth_available(config: AuthConfig) -> bool:
    if config.auth_mode not in ("local", "both"):
        return False
    return any(u.pass_hash or u.password for u in config.local_users)


def is_oauth_auth_available(config: AuthConfig) -> bool:
    if config.auth_mode not in ("oauth", "both"):
        return False
    provider = str(config.oauth_provider).strip()
    return bool(
        provider in ("google", "github")
        and config.oauth_client_id.strip()
        and config.oauth_client_secret.strip()
        and config.oauth_redirect_uri.strip()
    )


def is_auth_configured(config: AuthConfig) -> bool:
    if config.auth_mode == "local":
        return is_local_auth_available(config)
    if config.auth_mode == "oauth":
        return is_oauth_auth_available(config)
    return is_local_auth_available(config) or is_oauth_auth_available(config)


def init_auth_config(settings: Settings) -> AuthConfig:
    global auth_config
    path = auth_config_path(settings)
    if path.is_file():
        if settings.AUTH:
            _require_auth_config_mode(path)
        auth_config = _migrate_plaintext_passwords(
            path,
            _ensure_auth_salt(path, load_auth_config_file(path)),
        )
        return auth_config
    if not settings.AUTH:
        auth_config = AuthConfig(auth_mode="local", local_users=[])
        return auth_config
    password = secrets.token_urlsafe(16)
    salt = generate_auth_salt()
    auth_config = default_auth_config(admin_password=password, salt=salt)
    write_auth_config(path, auth_config)
    print(
        f"WARNING: PyGitWeb: created auth config at {path}\n"
        "         Set PYGITWEB_AUTH=0 to disable auth, or edit this file for fixed credentials.\n",
        file=sys.stderr,
    )
    print(f"  Username: admin\n  Password: {password}\n", file=sys.stderr)
    return auth_config


auth_config: AuthConfig = AuthConfig(auth_mode="local", local_users=[])