"""Accounts for the server (AV-054 E1, docs/ARCHITEKTURA_SERWER.md section 3).

By invitation only: the owner (role OWNER) creates an invitation link for an
e-mail address; whoever opens it sets a password and accepts the beta's
documents (a consent row). At most BETA_MAX_USERS accounts (owner included),
counting open invitations too.

Nothing secret is stored: a session, an invitation and a password reset keep
the SHA-256 of their random token; a password, its argon2id hash
(argon2-cffi, RFC 9106 low-memory parameters). Sessions last SESSION_DAYS
from their last use and are cut short by sign-out, a password reset or
deleting the account.
"""

from __future__ import annotations

import hashlib
import os
import re
import secrets
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any

from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError

from mountain_twin.journey.persistence import JourneySqliteDatabase, _transaction

SESSION_DAYS = 30
SESSION_TOUCH_SECONDS = 3600  # last_seen / expiry moved at most once an hour
INVITATION_DAYS = 7
RESET_HOURS = 1
API_TOKEN_DAYS = 180
API_TOKEN_PREFIX = "ltk_"  # recognisable in a leaked log or paste
TOKEN_TOUCH_SECONDS = 60
MIN_PASSWORD_LENGTH = 10
DEFAULT_BETA_MAX_USERS = 6
CONSENT_VERSION = "beta-2026-10"
EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")

_hasher = PasswordHasher()


class AccountError(ValueError):
    """A refusal the page shows: ``code`` for the program, ``str()`` in Polish."""

    def __init__(self, status: int, code: str, message: str):
        super().__init__(message)
        self.status = status
        self.code = code


def _now() -> datetime:
    return datetime.now(timezone.utc)


def _iso(moment: datetime) -> str:
    return moment.isoformat()


def _parse(text: str) -> datetime:
    return datetime.fromisoformat(text)


def token_hash(token: str) -> str:
    return hashlib.sha256(token.encode("utf-8")).hexdigest()


def normalize_email(raw: Any) -> str:
    email = str(raw or "").strip().lower()
    if not EMAIL.match(email) or len(email) > 254:
        raise AccountError(400, "BAD_EMAIL", "Podaj poprawny adres e-mail.")
    return email


def check_password(password: Any) -> str:
    if not isinstance(password, str) or len(password) < MIN_PASSWORD_LENGTH:
        raise AccountError(
            400, "WEAK_PASSWORD", f"Hasło musi mieć co najmniej {MIN_PASSWORD_LENGTH} znaków."
        )
    if len(password) > 1024:
        raise AccountError(400, "WEAK_PASSWORD", "Hasło jest za długie.")
    return password


def beta_max_users(environ=os.environ) -> int:
    try:
        return max(1, int(environ.get("BETA_MAX_USERS") or DEFAULT_BETA_MAX_USERS))
    except ValueError:
        return DEFAULT_BETA_MAX_USERS


@dataclass(frozen=True)
class User:
    user_id: str
    email: str
    role: str
    created_at: str

    def to_dict(self) -> dict[str, Any]:
        return {
            "user_id": self.user_id,
            "email": self.email,
            "role": self.role,
            "created_at": self.created_at,
        }


@dataclass(frozen=True)
class Session:
    user: User
    csrf_token: str
    expires_at: str
    # AV-054 E2: signed in with an API token (Authorization: Bearer): no
    # cookie, so no CSRF token to check.
    via_token: bool = False
    token_id: str | None = None


class AccountStore:
    """One connection per instance (the same per-request philosophy as the
    Journey repositories)."""

    def __init__(self, database: JourneySqliteDatabase):
        self._connection = database.connect()

    def close(self) -> None:
        self._connection.close()

    # --- users -------------------------------------------------------------------
    def _user(self, row) -> User:
        return User(row["user_id"], row["email"], row["role"], row["created_at"])

    def user_by_id(self, user_id: str) -> User | None:
        row = self._connection.execute(
            "SELECT * FROM users WHERE user_id = ?", (user_id,)
        ).fetchone()
        return None if row is None else self._user(row)

    def users(self) -> list[User]:
        rows = self._connection.execute("SELECT * FROM users ORDER BY created_at").fetchall()
        return [self._user(row) for row in rows]

    def create_user(self, email: str, password: str, role: str = "USER") -> User:
        email = normalize_email(email)
        check_password(password)
        if role not in ("OWNER", "USER"):
            raise ValueError("role must be OWNER or USER")
        user = User(f"user-v0_1:{uuid.uuid4()}", email, role, _iso(_now()))
        try:
            with _transaction(self._connection):
                self._connection.execute(
                    "INSERT INTO users(user_id, email, password_hash, role, created_at)"
                    " VALUES (?, ?, ?, ?, ?)",
                    (user.user_id, email, _hasher.hash(password), role, user.created_at),
                )
        except Exception as error:
            if "UNIQUE" in str(error):
                raise AccountError(
                    409, "EMAIL_TAKEN", "Konto z tym adresem już istnieje."
                ) from error
            raise
        return user

    def verify_password(self, email: str, password: str) -> User | None:
        row = self._connection.execute(
            "SELECT * FROM users WHERE email = ?", (str(email or "").strip().lower(),)
        ).fetchone()
        if row is None:
            # The same work as a real check: no timing hint that the address is unknown.
            try:
                _hasher.verify(_DUMMY_HASH, password or "")
            except (VerificationError, InvalidHashError):
                pass
            return None
        try:
            _hasher.verify(row["password_hash"], password or "")
        except (VerifyMismatchError, VerificationError, InvalidHashError):
            return None
        if _hasher.check_needs_rehash(row["password_hash"]):
            with _transaction(self._connection):
                self._connection.execute(
                    "UPDATE users SET password_hash = ? WHERE user_id = ?",
                    (_hasher.hash(password), row["user_id"]),
                )
        return self._user(row)

    def set_password(self, user_id: str, password: str) -> None:
        check_password(password)
        with _transaction(self._connection):
            self._connection.execute(
                "UPDATE users SET password_hash = ? WHERE user_id = ?",
                (_hasher.hash(password), user_id),
            )
            # A new password ends every session and every API token (a reset
            # after a stolen one, a change after a doubt).
            self._connection.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
            self._connection.execute("DELETE FROM api_tokens WHERE user_id = ?", (user_id,))

    def delete_user(self, user_id: str) -> None:
        """The user row and everything hanging on it (sessions, consents,
        resets, invitations they made, memberships: ON DELETE CASCADE)."""
        with _transaction(self._connection):
            self._connection.execute("DELETE FROM users WHERE user_id = ?", (user_id,))

    # --- invitations --------------------------------------------------------------
    def open_invitations(self) -> int:
        return self._connection.execute(
            "SELECT COUNT(*) FROM invitations WHERE used_at IS NULL AND expires_at > ?",
            (_iso(_now()),),
        ).fetchone()[0]

    def invite(self, invited_by: str, email: str, *, max_users: int) -> tuple[str, str]:
        """(token, expires_at): a single-use link, valid INVITATION_DAYS."""
        email = normalize_email(email)
        if self._connection.execute("SELECT 1 FROM users WHERE email = ?", (email,)).fetchone():
            raise AccountError(409, "EMAIL_TAKEN", "Konto z tym adresem już istnieje.")
        taken = len(self.users()) + self.open_invitations()
        if taken >= max_users:
            raise AccountError(
                409,
                "BETA_FULL",
                f"Beta ma limit {max_users} kont (z otwartymi zaproszeniami). "
                "Więcej osób dopiero po zmianie planu Open-Meteo (decyzja 2B).",
            )
        token = secrets.token_urlsafe(32)
        now = _now()
        expires = _iso(now + timedelta(days=INVITATION_DAYS))
        with _transaction(self._connection):
            # A new invitation for the same address replaces an open one.
            self._connection.execute(
                "DELETE FROM invitations WHERE email = ? AND used_at IS NULL", (email,)
            )
            self._connection.execute(
                "INSERT INTO invitations(token_hash, email, invited_by, created_at, expires_at)"
                " VALUES (?, ?, ?, ?, ?)",
                (
                    token_hash(token),
                    email,
                    invited_by,
                    _iso(now),
                    expires,
                ),
            )
        return token, expires

    def invitation_email(self, token: str) -> str:
        row = self._connection.execute(
            "SELECT email, expires_at, used_at FROM invitations WHERE token_hash = ?",
            (token_hash(token or ""),),
        ).fetchone()
        if row is None or row["used_at"] is not None or _parse(row["expires_at"]) <= _now():
            raise AccountError(
                404, "INVITATION_INVALID", "To zaproszenie jest nieważne albo już wykorzystane."
            )
        return row["email"]

    def accept_invitation(self, token: str, password: str, consent_version: str) -> User:
        if consent_version != CONSENT_VERSION:
            raise AccountError(
                400, "CONSENT_REQUIRED", "Zaakceptuj politykę prywatności i regulamin bety."
            )
        email = self.invitation_email(token)
        user = self.create_user(email, password, "USER")
        with _transaction(self._connection):
            self._connection.execute(
                "UPDATE invitations SET used_at = ? WHERE token_hash = ?",
                (_iso(_now()), token_hash(token)),
            )
            self._connection.execute(
                "INSERT INTO consents(user_id, document_version, accepted_at) VALUES (?, ?, ?)",
                (user.user_id, consent_version, _iso(_now())),
            )
        return user

    def record_consent(self, user_id: str, version: str = CONSENT_VERSION) -> None:
        with _transaction(self._connection):
            self._connection.execute(
                "INSERT OR IGNORE INTO consents(user_id, document_version, accepted_at)"
                " VALUES (?, ?, ?)",
                (user_id, version, _iso(_now())),
            )

    # --- password resets ------------------------------------------------------------
    def reset_link_token(self, email: str) -> str:
        email = normalize_email(email)
        row = self._connection.execute(
            "SELECT user_id FROM users WHERE email = ?", (email,)
        ).fetchone()
        if row is None:
            raise AccountError(404, "NO_SUCH_USER", "Nie ma konta z tym adresem.")
        token = secrets.token_urlsafe(32)
        now = _now()
        with _transaction(self._connection):
            self._connection.execute(
                "DELETE FROM password_resets WHERE user_id = ? AND used_at IS NULL",
                (row["user_id"],),
            )
            self._connection.execute(
                "INSERT INTO password_resets(token_hash, user_id, created_at, expires_at)"
                " VALUES (?, ?, ?, ?)",
                (
                    token_hash(token),
                    row["user_id"],
                    _iso(now),
                    _iso(now + timedelta(hours=RESET_HOURS)),
                ),
            )
        return token

    def reset_password(self, token: str, password: str) -> User:
        row = self._connection.execute(
            "SELECT user_id, expires_at, used_at FROM password_resets WHERE token_hash = ?",
            (token_hash(token or ""),),
        ).fetchone()
        if row is None or row["used_at"] is not None or _parse(row["expires_at"]) <= _now():
            raise AccountError(
                404, "RESET_INVALID", "Ten link jest nieważny albo już wykorzystany."
            )
        self.set_password(row["user_id"], password)
        with _transaction(self._connection):
            self._connection.execute(
                "UPDATE password_resets SET used_at = ? WHERE token_hash = ?",
                (_iso(_now()), token_hash(token)),
            )
        return self.user_by_id(row["user_id"])

    # --- sessions ---------------------------------------------------------------------
    def start_session(self, user_id: str) -> tuple[str, Session]:
        token, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(24)
        now = _now()
        expires = now + timedelta(days=SESSION_DAYS)
        with _transaction(self._connection):
            self._connection.execute(
                "INSERT INTO sessions(token_hash, user_id, csrf_token, created_at, expires_at,"
                " last_seen_at) VALUES (?, ?, ?, ?, ?, ?)",
                (token_hash(token), user_id, csrf, _iso(now), _iso(expires), _iso(now)),
            )
        return token, Session(self.user_by_id(user_id), csrf, _iso(expires))

    def session(self, token: str | None) -> Session | None:
        """The live session of this token (sliding expiry), or None."""
        if not token:
            return None
        row = self._connection.execute(
            "SELECT s.*, u.email, u.role, u.created_at AS user_created_at FROM sessions s"
            " JOIN users u ON u.user_id = s.user_id WHERE s.token_hash = ?",
            (token_hash(token),),
        ).fetchone()
        if row is None:
            return None
        now = _now()
        if _parse(row["expires_at"]) <= now:
            with _transaction(self._connection):
                self._connection.execute(
                    "DELETE FROM sessions WHERE token_hash = ?", (row["token_hash"],)
                )
            return None
        expires_at = row["expires_at"]
        if (now - _parse(row["last_seen_at"])).total_seconds() > SESSION_TOUCH_SECONDS:
            expires_at = _iso(now + timedelta(days=SESSION_DAYS))
            with _transaction(self._connection):
                self._connection.execute(
                    "UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE token_hash = ?",
                    (_iso(now), expires_at, row["token_hash"]),
                )
        user = User(row["user_id"], row["email"], row["role"], row["user_created_at"])
        return Session(user, row["csrf_token"], expires_at)

    # --- API tokens (E2) -------------------------------------------------------------
    def create_api_token(self, user_id: str, device_name: Any) -> tuple[str, dict[str, Any]]:
        """(the token -- shown this once, never stored --, its public record)."""
        name = str(device_name or "").strip()[:80]
        if not name:
            raise AccountError(400, "DEVICE_NAME_REQUIRED", "Podaj nazwę urządzenia.")
        token = API_TOKEN_PREFIX + secrets.token_urlsafe(32)
        now = _now()
        record = {
            "token_id": f"token-v0_1:{uuid.uuid4()}",
            "device_name": name,
            "created_at": _iso(now),
            "last_used_at": None,
            "expires_at": _iso(now + timedelta(days=API_TOKEN_DAYS)),
        }
        with _transaction(self._connection):
            self._connection.execute(
                "INSERT INTO api_tokens(token_hash, user_id, device_name, created_at, token_id,"
                " expires_at) VALUES (?, ?, ?, ?, ?, ?)",
                (
                    token_hash(token),
                    user_id,
                    name,
                    record["created_at"],
                    record["token_id"],
                    record["expires_at"],
                ),
            )
        return token, record

    def api_tokens(self, user_id: str) -> list[dict[str, Any]]:
        rows = self._connection.execute(
            "SELECT token_id, device_name, created_at, last_used_at, expires_at FROM api_tokens"
            " WHERE user_id = ? AND token_id IS NOT NULL ORDER BY created_at",
            (user_id,),
        ).fetchall()
        return [dict(row) for row in rows]

    def revoke_api_token(self, user_id: str, token_id: str) -> bool:
        with _transaction(self._connection):
            return (
                self._connection.execute(
                    "DELETE FROM api_tokens WHERE user_id = ? AND token_id = ?", (user_id, token_id)
                ).rowcount
                == 1
            )

    def token_session(self, token: str | None) -> Session | None:
        """The user behind an API token (Authorization: Bearer), or None for
        an unknown, revoked or expired one (an expired one is removed)."""
        if not token or not token.startswith(API_TOKEN_PREFIX):
            return None
        row = self._connection.execute(
            "SELECT t.*, u.email, u.role, u.created_at AS user_created_at FROM api_tokens t"
            " JOIN users u ON u.user_id = t.user_id WHERE t.token_hash = ?",
            (token_hash(token),),
        ).fetchone()
        if row is None or row["expires_at"] is None:
            return None
        now = _now()
        if _parse(row["expires_at"]) <= now:
            with _transaction(self._connection):
                self._connection.execute(
                    "DELETE FROM api_tokens WHERE token_hash = ?", (row["token_hash"],)
                )
            return None
        last = row["last_used_at"]
        if last is None or (now - _parse(last)).total_seconds() > TOKEN_TOUCH_SECONDS:
            with _transaction(self._connection):
                self._connection.execute(
                    "UPDATE api_tokens SET last_used_at = ? WHERE token_hash = ?",
                    (_iso(now), row["token_hash"]),
                )
        user = User(row["user_id"], row["email"], row["role"], row["user_created_at"])
        return Session(user, "", row["expires_at"], via_token=True, token_id=row["token_id"])

    def end_session(self, token: str | None) -> None:
        if token:
            with _transaction(self._connection):
                self._connection.execute(
                    "DELETE FROM sessions WHERE token_hash = ?", (token_hash(token),)
                )


_DUMMY_HASH = _hasher.hash("not-a-password-of-anyone")
