"""
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 pathlib import Path
from typing import Literal

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

from pygitweb.config import Settings
from pygitweb.password_hash import hash_password
from pygitweb.permissions import PermissionsMap

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)


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

	@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 = "email"
	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="email",
		oauth_allowed_emails=[],
		oauth_permissions=PermissionsMap.empty(),
		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(
			LocalUser(
				user=entry.user,
				pass_hash=hash_password(entry.password, config_salt=config.salt),
				email=entry.email,
			)
		)
		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=[])