from __future__ import annotations import os from dataclasses import dataclass from datetime import timedelta from functools import lru_cache from typing import Optional from services.pricing import PricingMetadata from services.security import JWTSettings @dataclass(frozen=True, slots=True) class AdminBootstrapSettings: """Default administrator bootstrap configuration.""" email: str username: str password: str roles: tuple[str, ...] force_reset: bool @dataclass(frozen=True, slots=True) class SessionSettings: """Cookie and header configuration for session token transport.""" access_cookie_name: str refresh_cookie_name: str cookie_secure: bool cookie_domain: Optional[str] cookie_path: str header_name: str header_prefix: str allow_header_fallback: bool @dataclass(frozen=True, slots=True) class Settings: """Application configuration sourced from environment variables.""" jwt_secret_key: str = "change-me" jwt_algorithm: str = "HS256" jwt_access_token_minutes: int = 15 jwt_refresh_token_days: int = 7 session_access_cookie_name: str = "calminer_access_token" session_refresh_cookie_name: str = "calminer_refresh_token" session_cookie_secure: bool = False session_cookie_domain: Optional[str] = None session_cookie_path: str = "/" session_header_name: str = "Authorization" session_header_prefix: str = "Bearer" session_allow_header_fallback: bool = True admin_email: str = "admin@calminer.local" admin_username: str = "admin" admin_password: str = "ChangeMe123!" admin_roles: tuple[str, ...] = ("admin",) admin_force_reset: bool = False pricing_default_payable_pct: float = 100.0 pricing_default_currency: str | None = "USD" pricing_moisture_threshold_pct: float = 8.0 pricing_moisture_penalty_per_pct: float = 0.0 @classmethod def from_environment(cls) -> "Settings": """Construct settings from environment variables.""" return cls( jwt_secret_key=os.getenv("CALMINER_JWT_SECRET", "change-me"), jwt_algorithm=os.getenv("CALMINER_JWT_ALGORITHM", "HS256"), jwt_access_token_minutes=cls._int_from_env( "CALMINER_JWT_ACCESS_MINUTES", 15 ), jwt_refresh_token_days=cls._int_from_env( "CALMINER_JWT_REFRESH_DAYS", 7 ), session_access_cookie_name=os.getenv( "CALMINER_SESSION_ACCESS_COOKIE", "calminer_access_token" ), session_refresh_cookie_name=os.getenv( "CALMINER_SESSION_REFRESH_COOKIE", "calminer_refresh_token" ), session_cookie_secure=cls._bool_from_env( "CALMINER_SESSION_COOKIE_SECURE", False ), session_cookie_domain=os.getenv("CALMINER_SESSION_COOKIE_DOMAIN"), session_cookie_path=os.getenv("CALMINER_SESSION_COOKIE_PATH", "/"), session_header_name=os.getenv( "CALMINER_SESSION_HEADER_NAME", "Authorization" ), session_header_prefix=os.getenv( "CALMINER_SESSION_HEADER_PREFIX", "Bearer" ), session_allow_header_fallback=cls._bool_from_env( "CALMINER_SESSION_ALLOW_HEADER_FALLBACK", True ), admin_email=os.getenv( "CALMINER_SEED_ADMIN_EMAIL", "admin@calminer.local" ), admin_username=os.getenv( "CALMINER_SEED_ADMIN_USERNAME", "admin" ), admin_password=os.getenv( "CALMINER_SEED_ADMIN_PASSWORD", "ChangeMe123!" ), admin_roles=cls._parse_admin_roles( os.getenv("CALMINER_SEED_ADMIN_ROLES") ), admin_force_reset=cls._bool_from_env( "CALMINER_SEED_FORCE", False ), pricing_default_payable_pct=cls._float_from_env( "CALMINER_PRICING_DEFAULT_PAYABLE_PCT", 100.0 ), pricing_default_currency=cls._optional_str( "CALMINER_PRICING_DEFAULT_CURRENCY", "USD" ), pricing_moisture_threshold_pct=cls._float_from_env( "CALMINER_PRICING_MOISTURE_THRESHOLD_PCT", 8.0 ), pricing_moisture_penalty_per_pct=cls._float_from_env( "CALMINER_PRICING_MOISTURE_PENALTY_PER_PCT", 0.0 ), ) @staticmethod def _int_from_env(name: str, default: int) -> int: raw_value = os.getenv(name) if raw_value is None: return default try: return int(raw_value) except ValueError: return default @staticmethod def _bool_from_env(name: str, default: bool) -> bool: raw_value = os.getenv(name) if raw_value is None: return default lowered = raw_value.strip().lower() if lowered in {"1", "true", "yes", "on"}: return True if lowered in {"0", "false", "no", "off"}: return False return default @staticmethod def _parse_admin_roles(raw_value: str | None) -> tuple[str, ...]: if not raw_value: return ("admin",) parts = [segment.strip() for segment in raw_value.split(",") if segment.strip()] if "admin" not in parts: parts.insert(0, "admin") seen: set[str] = set() ordered: list[str] = [] for role_name in parts: if role_name not in seen: ordered.append(role_name) seen.add(role_name) return tuple(ordered) @staticmethod def _float_from_env(name: str, default: float) -> float: raw_value = os.getenv(name) if raw_value is None: return default try: return float(raw_value) except ValueError: return default @staticmethod def _optional_str(name: str, default: str | None = None) -> str | None: raw_value = os.getenv(name) if raw_value is None or raw_value.strip() == "": return default return raw_value.strip() def jwt_settings(self) -> JWTSettings: """Build runtime JWT settings compatible with token helpers.""" return JWTSettings( secret_key=self.jwt_secret_key, algorithm=self.jwt_algorithm, access_token_ttl=timedelta(minutes=self.jwt_access_token_minutes), refresh_token_ttl=timedelta(days=self.jwt_refresh_token_days), ) def session_settings(self) -> SessionSettings: """Provide transport configuration for session tokens.""" return SessionSettings( access_cookie_name=self.session_access_cookie_name, refresh_cookie_name=self.session_refresh_cookie_name, cookie_secure=self.session_cookie_secure, cookie_domain=self.session_cookie_domain, cookie_path=self.session_cookie_path, header_name=self.session_header_name, header_prefix=self.session_header_prefix, allow_header_fallback=self.session_allow_header_fallback, ) def admin_bootstrap_settings(self) -> AdminBootstrapSettings: """Return configured admin bootstrap settings.""" return AdminBootstrapSettings( email=self.admin_email, username=self.admin_username, password=self.admin_password, roles=self.admin_roles, force_reset=self.admin_force_reset, ) def pricing_metadata(self) -> PricingMetadata: """Build pricing metadata defaults.""" return PricingMetadata( default_payable_pct=self.pricing_default_payable_pct, default_currency=self.pricing_default_currency, moisture_threshold_pct=self.pricing_moisture_threshold_pct, moisture_penalty_per_pct=self.pricing_moisture_penalty_per_pct, ) @lru_cache(maxsize=1) def get_settings() -> Settings: """Return cached application settings.""" return Settings.from_environment()