from __future__ import annotations

import hmac
import json
import os
import secrets
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from threading import RLock
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field


DEFAULT_SECURITY: dict[str, Any] = {
    # BUILD98: editor access is a fixed local PIN. The older daily PIN and
    # master PIN fields are retained only so existing config files still parse.
    "fixed_pin": "1993",
    "daily_mask": "4827",
    "master_pin": "638194",
    "timezone": "Europe/Moscow",
    "session_minutes": 30,
    "max_attempts": 5,
    "lock_seconds": 30,
    "cookie_secure": False,
}

COOKIE_NAME = "ui_builder_editor_session"


class PinPayload(BaseModel):
    pin: str = Field(min_length=4, max_length=12)


@dataclass
class FailureState:
    attempts: int = 0
    locked_until: float = 0.0


class EditorAuthManager:
    """Local PIN guard for the visual editor.

    BUILD98 uses one fixed PIN (1993 by default). The old daily-PIN helpers are
    kept for backward-compatible diagnostics only and are no longer accepted
    by the login endpoint.
    """

    def __init__(self, settings_dir: Path) -> None:
        self.settings_dir = Path(settings_dir)
        self.security_file = self.settings_dir / "editor_security.json"
        self.settings_dir.mkdir(parents=True, exist_ok=True)
        self._lock = RLock()
        self._sessions: dict[str, float] = {}
        self._failures: dict[str, FailureState] = {}
        self.settings = self._load_settings()

    def _load_settings(self) -> dict[str, Any]:
        if not self.security_file.exists():
            self.security_file.write_text(
                json.dumps(DEFAULT_SECURITY, ensure_ascii=False, indent=2),
                encoding="utf-8",
            )

        try:
            raw = json.loads(self.security_file.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError, TypeError):
            raw = {}

        result = dict(DEFAULT_SECURITY)
        if isinstance(raw, dict):
            result.update(raw)

        overrides = {
            "fixed_pin": os.getenv("EDITOR_FIXED_PIN"),
            "daily_mask": os.getenv("EDITOR_PIN_MASK"),
            "master_pin": os.getenv("EDITOR_MASTER_PIN"),
            "timezone": os.getenv("EDITOR_PIN_TIMEZONE"),
            "session_minutes": os.getenv("EDITOR_SESSION_MINUTES"),
            "max_attempts": os.getenv("EDITOR_MAX_ATTEMPTS"),
            "lock_seconds": os.getenv("EDITOR_LOCK_SECONDS"),
        }
        for key, value in overrides.items():
            if value not in (None, ""):
                result[key] = value

        cookie_override = os.getenv("EDITOR_COOKIE_SECURE")
        if cookie_override not in (None, ""):
            result["cookie_secure"] = cookie_override.strip().lower() in {
                "1", "true", "yes", "on"
            }

        fixed = "".join(character for character in str(result.get("fixed_pin", "1993")) if character.isdigit())
        result["fixed_pin"] = fixed if 4 <= len(fixed) <= 12 else "1993"

        mask = "".join(character for character in str(result["daily_mask"]) if character.isdigit())
        result["daily_mask"] = mask[:4].ljust(4, "0") if mask else "4827"

        master = "".join(character for character in str(result["master_pin"]) if character.isdigit())
        result["master_pin"] = master if 4 <= len(master) <= 12 else "638194"

        try:
            ZoneInfo(str(result["timezone"]))
        except ZoneInfoNotFoundError:
            result["timezone"] = "UTC"

        for key, low, high, fallback in (
            ("session_minutes", 1, 1440, 30),
            ("max_attempts", 1, 20, 5),
            ("lock_seconds", 5, 3600, 30),
        ):
            try:
                value = int(result[key])
            except (TypeError, ValueError):
                value = fallback
            result[key] = max(low, min(high, value))

        return result

    @property
    def timezone(self) -> ZoneInfo:
        return ZoneInfo(str(self.settings["timezone"]))

    @property
    def session_seconds(self) -> int:
        return int(self.settings["session_minutes"]) * 60

    def now(self) -> datetime:
        return datetime.now(self.timezone)

    def daily_pin(self, moment: datetime | None = None) -> str:
        current = moment.astimezone(self.timezone) if moment else self.now()
        date_digits = current.strftime("%d%m")
        mask = str(self.settings["daily_mask"])
        return "".join(
            str((int(date_digit) + int(mask_digit)) % 10)
            for date_digit, mask_digit in zip(date_digits, mask, strict=True)
        )

    def explain_daily_pin(self, moment: datetime | None = None) -> dict[str, Any]:
        current = moment.astimezone(self.timezone) if moment else self.now()
        date_digits = current.strftime("%d%m")
        mask = str(self.settings["daily_mask"])
        return {
            "date": current.strftime("%d.%m.%Y"),
            "date_digits": date_digits,
            "mask": mask,
            "pin": self.daily_pin(current),
            "timezone": str(self.settings["timezone"]),
            "steps": [
                f"{date_digit} + {mask_digit} → {(int(date_digit) + int(mask_digit)) % 10}"
                for date_digit, mask_digit in zip(date_digits, mask, strict=True)
            ],
        }

    @staticmethod
    def _client_key(request: Request) -> str:
        return request.client.host if request.client else "local"

    def _cleanup_sessions(self) -> None:
        now = time.time()
        for token, expires_at in list(self._sessions.items()):
            if expires_at <= now:
                self._sessions.pop(token, None)

    def is_token_valid(self, token: str | None) -> bool:
        if not token:
            return False
        with self._lock:
            self._cleanup_sessions()
            return self._sessions.get(token, 0) > time.time()

    def is_request_authenticated(self, request: Request) -> bool:
        return self.is_token_valid(request.cookies.get(COOKIE_NAME))

    async def require_editor(self, request: Request) -> None:
        if not self.is_request_authenticated(request):
            raise HTTPException(status_code=401, detail="Сессия конструктора не активна")

    def status(self, request: Request) -> dict[str, Any]:
        client_key = self._client_key(request)
        token = request.cookies.get(COOKIE_NAME)
        now = time.time()

        with self._lock:
            self._cleanup_sessions()
            failure = self._failures.get(client_key, FailureState())
            expires_at = self._sessions.get(token or "", 0)
            authenticated = expires_at > now

        return {
            "authenticated": authenticated,
            "expires_in": max(0, int(expires_at - now)) if authenticated else 0,
            "attempts_remaining": max(
                0,
                int(self.settings["max_attempts"]) - failure.attempts,
            ),
            "locked_for": max(0, int(round(failure.locked_until - now))),
            "timezone": str(self.settings["timezone"]),
            "date": self.now().strftime("%d.%m.%Y"),
            "session_minutes": int(self.settings["session_minutes"]),
        }

    def login(self, request: Request, pin: str) -> tuple[str, int]:
        client_key = self._client_key(request)
        now = time.time()
        supplied = str(pin).strip()

        with self._lock:
            failure = self._failures.setdefault(client_key, FailureState())
            if failure.locked_until > now:
                raise HTTPException(
                    status_code=429,
                    detail={
                        "message": "Вход временно заблокирован",
                        "locked_for": max(1, int(round(failure.locked_until - now))),
                    },
                )

            valid = hmac.compare_digest(supplied, str(self.settings["fixed_pin"]))
            if not valid:
                failure.attempts += 1
                remaining = max(
                    0,
                    int(self.settings["max_attempts"]) - failure.attempts,
                )
                if failure.attempts >= int(self.settings["max_attempts"]):
                    failure.attempts = 0
                    failure.locked_until = now + int(self.settings["lock_seconds"])
                    raise HTTPException(
                        status_code=429,
                        detail={
                            "message": "Вход временно заблокирован",
                            "locked_for": int(self.settings["lock_seconds"]),
                        },
                    )
                raise HTTPException(
                    status_code=401,
                    detail={
                        "message": "Неверный PIN",
                        "attempts_remaining": remaining,
                    },
                )

            self._failures.pop(client_key, None)
            token = secrets.token_urlsafe(32)
            self._sessions[token] = now + self.session_seconds
            return token, self.session_seconds

    def logout(self, request: Request) -> None:
        token = request.cookies.get(COOKIE_NAME)
        if token:
            with self._lock:
                self._sessions.pop(token, None)


def create_editor_auth_router(
    auth: EditorAuthManager,
    *,
    prefix: str = "/api/ui-builder/auth",
    editor_url: str = "/editor",
) -> APIRouter:
    router = APIRouter(prefix=prefix, tags=["UI Builder Auth"])

    @router.get("/status")
    async def auth_status(request: Request) -> dict[str, Any]:
        return auth.status(request)

    @router.post("/login")
    async def auth_login(request: Request, payload: PinPayload) -> JSONResponse:
        token, max_age = auth.login(request, payload.pin)
        response = JSONResponse(
            {"ok": True, "editor_url": editor_url, "expires_in": max_age}
        )
        response.set_cookie(
            COOKIE_NAME,
            token,
            max_age=max_age,
            httponly=True,
            samesite="strict",
            secure=bool(auth.settings["cookie_secure"]),
            path="/",
        )
        return response

    @router.post("/logout")
    async def auth_logout(request: Request) -> JSONResponse:
        auth.logout(request)
        response = JSONResponse({"ok": True})
        response.delete_cookie(COOKIE_NAME, path="/")
        return response

    return router
