"""Tracker user accounts: registration, login, API-key management.

Accounts are identified by email address or wallet address (either works as
the login identifier) and authenticated with a password (PBKDF2-SHA256).
The first account ever registered becomes the admin; everyone after is a
regular user.

Mutations are append-only events with unique ids — the same replication
model as ``BillingLedger`` — so accounts and API keys converge across the
tracker hive via gossip, and every dashboard can serve registration/login.
Sessions are local to each tracker and persisted so dashboard cookies survive
tracker restarts.
"""

from __future__ import annotations

import hashlib
import json
import re
import secrets
import sqlite3
import threading
import time
import uuid

DEFAULT_ACCOUNTS_DB_PATH = "accounts.sqlite"
SESSION_TTL = 7 * 86400.0  # seconds
PBKDF2_ITERATIONS = 200_000
MIN_PASSWORD_LENGTH = 8
_MAX_NICKNAME_LENGTH = 64
API_KEY_PREFIX = "sk-mesh-"

_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
_UNSET = object()


def _normalize_nickname(value: object) -> str | None:
    if value is None:
        return None
    if not isinstance(value, str):
        raise ValueError("nickname must be a string")
    nickname = value.strip()
    if not nickname:
        return None
    if len(nickname) > _MAX_NICKNAME_LENGTH:
        raise ValueError(f"nickname must be at most {_MAX_NICKNAME_LENGTH} characters")
    return nickname


def _hash_password(password: str, salt: str) -> str:
    return hashlib.pbkdf2_hmac(
        "sha256", password.encode(), bytes.fromhex(salt), PBKDF2_ITERATIONS
    ).hex()


class AccountStore:
    """Thread-safe account/API-key store with SQLite persistence and event replication."""

    def __init__(self, db_path: str | None = None) -> None:
        self._db_path = db_path
        self._lock = threading.Lock()
        self._accounts: dict[str, dict] = {}  # account_id -> record
        self._by_identifier: dict[str, str] = {}  # lowercased email/wallet -> account_id
        self._active_keys: dict[str, str] = {}  # api_key -> account_id
        self._revoked_keys: set[str] = set()
        self._sessions: dict[str, dict] = {}  # token -> {account_id, expires}
        self._seen_event_ids: set[str] = set()
        self._event_log: list[dict] = []
        self._dirty = False
        if self._db_path:
            self._init_db()
            self._load_from_db()

    # ---- registration & login ----

    def register(
        self,
        *,
        email: str | None = None,
        wallet: str | None = None,
        password: str,
        nickname: str | None = None,
    ) -> dict:
        """Create an account. The first account becomes the admin.

        Raises ValueError with a user-facing message on invalid input.
        """
        email = (email or "").strip().lower() or None
        wallet = (wallet or "").strip() or None
        if not email and not wallet:
            raise ValueError("provide an email or a wallet address")
        if email and not _EMAIL_RE.match(email):
            raise ValueError("invalid email address")
        if len(password or "") < MIN_PASSWORD_LENGTH:
            raise ValueError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
        nickname = _normalize_nickname(nickname)
        with self._lock:
            for identifier in filter(None, (email, wallet)):
                if identifier.lower() in self._by_identifier:
                    raise ValueError(f"an account for {identifier!r} already exists")
            salt = secrets.token_hex(16)
            event = {
                "id": f"register-{uuid.uuid4().hex}",
                "type": "register",
                "account_id": f"acct-{uuid.uuid4().hex}",
                "email": email,
                "wallet": wallet,
                "role": "admin" if not self._accounts else "user",
                "password_hash": _hash_password(password, salt),
                "salt": salt,
                "nickname": nickname,
                "ts": time.time(),
            }
            self._apply_locked(event)
            return self._public_view(self._accounts[event["account_id"]])

    def update_profile(self, account_id: str, *, nickname: str | None = _UNSET) -> dict:
        """Update display fields for an account. Pass nickname=None to clear."""
        if nickname is not _UNSET:
            nickname = _normalize_nickname(nickname)
        with self._lock:
            if account_id not in self._accounts:
                raise ValueError("unknown account")
            event = {
                "id": f"profile-{uuid.uuid4().hex}",
                "type": "update_profile",
                "account_id": account_id,
                "ts": time.time(),
            }
            if nickname is not _UNSET:
                event["nickname"] = nickname
            self._apply_locked(event)
            return self._public_view(self._accounts[account_id])

    def verify_login(self, identifier: str, password: str) -> dict | None:
        """Return the public account view when credentials match, else None."""
        with self._lock:
            account_id = self._by_identifier.get((identifier or "").strip().lower())
            if account_id is None:
                return None
            record = self._accounts[account_id]
            if _hash_password(password or "", record["salt"]) != record["password_hash"]:
                return None
            return self._public_view(record)

    # ---- sessions (local to this tracker) ----

    def create_session(self, account_id: str) -> str:
        token = secrets.token_urlsafe(32)
        with self._lock:
            self._sessions[token] = {
                "account_id": account_id,
                "expires": time.time() + SESSION_TTL,
            }
            self._dirty = True
        self.save_to_db()
        return token

    def session_account(self, token: str | None) -> dict | None:
        if not token:
            return None
        with self._lock:
            session = self._sessions.get(token)
            if session is None:
                return None
            if session["expires"] < time.time():
                del self._sessions[token]
                return None
            record = self._accounts.get(session["account_id"])
            return self._public_view(record) if record else None

    def destroy_session(self, token: str | None) -> None:
        if not token:
            return
        with self._lock:
            if self._sessions.pop(token, None) is not None:
                self._dirty = True
        self.save_to_db()

    # ---- API keys ----

    def create_api_key(self, account_id: str) -> str:
        api_key = API_KEY_PREFIX + secrets.token_hex(24)
        with self._lock:
            if account_id not in self._accounts:
                raise ValueError("unknown account")
            self._apply_locked({
                "id": f"createkey-{uuid.uuid4().hex}",
                "type": "create_key",
                "account_id": account_id,
                "api_key": api_key,
                "ts": time.time(),
            })
        return api_key

    def revoke_api_key(self, account_id: str, api_key: str) -> bool:
        """Revoke a key owned by ``account_id``. Returns False if not owned."""
        with self._lock:
            if self._active_keys.get(api_key) != account_id:
                return False
            self._apply_locked({
                "id": f"revokekey-{uuid.uuid4().hex}",
                "type": "revoke_key",
                "account_id": account_id,
                "api_key": api_key,
                "ts": time.time(),
            })
            return True

    def keys_for(self, account_id: str) -> list[str]:
        with self._lock:
            return sorted(
                key for key, owner in self._active_keys.items() if owner == account_id
            )

    def is_key_revoked(self, api_key: str) -> bool:
        with self._lock:
            return api_key in self._revoked_keys

    def is_active_key(self, api_key: str) -> bool:
        with self._lock:
            return api_key in self._active_keys

    def owner_of_key(self, api_key: str) -> str | None:
        with self._lock:
            return self._active_keys.get(api_key)

    # ---- views ----

    def _public_view(self, record: dict) -> dict:
        return {
            "account_id": record["account_id"],
            "email": record.get("email"),
            "wallet": record.get("wallet"),
            "nickname": record.get("nickname"),
            "role": record["role"],
            "created_ts": record.get("ts", 0.0),
        }

    def get_account(self, account_id: str) -> dict | None:
        with self._lock:
            record = self._accounts.get(account_id)
            return self._public_view(record) if record else None

    def list_accounts(self) -> list[dict]:
        with self._lock:
            views = []
            for record in self._accounts.values():
                view = self._public_view(record)
                view["api_keys"] = sorted(
                    key for key, owner in self._active_keys.items()
                    if owner == record["account_id"]
                )
                views.append(view)
            return sorted(views, key=lambda v: v["created_ts"])

    def account_count(self) -> int:
        with self._lock:
            return len(self._accounts)

    # ---- replication (same model as BillingLedger) ----

    def events_since(self, index: int) -> tuple[list[dict], int]:
        with self._lock:
            return list(self._event_log[index:]), len(self._event_log)

    def apply_events(self, events: list[dict]) -> int:
        applied = 0
        with self._lock:
            for event in events:
                event_id = event.get("id")
                if not event_id or event_id in self._seen_event_ids:
                    continue
                self._apply_locked(event)
                applied += 1
        return applied

    def _apply_locked(self, event: dict) -> None:
        etype = event.get("type")
        if etype == "register":
            account_id = event["account_id"]
            record = {
                "account_id": account_id,
                "email": event.get("email"),
                "wallet": event.get("wallet"),
                "role": event.get("role", "user"),
                "password_hash": event["password_hash"],
                "salt": event["salt"],
                "nickname": event.get("nickname"),
                "ts": float(event.get("ts", 0.0)),
            }
            self._accounts[account_id] = record
            for identifier in filter(None, (record["email"], record["wallet"])):
                self._by_identifier.setdefault(identifier.lower(), account_id)
        elif etype == "update_profile":
            account_id = event["account_id"]
            record = self._accounts.get(account_id)
            if record is None:
                return
            if "nickname" in event:
                record["nickname"] = event.get("nickname")
        elif etype == "create_key":
            api_key = event["api_key"]
            if api_key not in self._revoked_keys:
                self._active_keys[api_key] = event["account_id"]
        elif etype == "revoke_key":
            api_key = event["api_key"]
            self._active_keys.pop(api_key, None)
            self._revoked_keys.add(api_key)
        else:
            return
        self._seen_event_ids.add(event["id"])
        self._event_log.append(event)
        self._dirty = True

    # ---- persistence ----

    def _init_db(self) -> None:
        con = sqlite3.connect(self._db_path)  # type: ignore[arg-type]
        con.execute(
            "CREATE TABLE IF NOT EXISTS account_events "
            "(event_id TEXT PRIMARY KEY, payload TEXT NOT NULL, ts REAL NOT NULL)"
        )
        con.execute(
            "CREATE TABLE IF NOT EXISTS account_sessions "
            "(token TEXT PRIMARY KEY, account_id TEXT NOT NULL, expires REAL NOT NULL)"
        )
        con.commit()
        con.close()

    def _load_from_db(self) -> None:
        con = sqlite3.connect(self._db_path)  # type: ignore[arg-type]
        rows = con.execute(
            "SELECT payload FROM account_events ORDER BY ts, event_id"
        ).fetchall()
        session_rows = con.execute(
            "SELECT token, account_id, expires FROM account_sessions WHERE expires >= ?",
            (time.time(),),
        ).fetchall()
        con.close()
        with self._lock:
            for (payload,) in rows:
                try:
                    event = json.loads(payload)
                except json.JSONDecodeError:
                    continue
                if event.get("id") not in self._seen_event_ids:
                    self._apply_locked(event)
            self._sessions = {
                token: {"account_id": account_id, "expires": float(expires)}
                for token, account_id, expires in session_rows
                if account_id in self._accounts
            }
            self._dirty = False

    def save_to_db(self) -> None:
        if not self._db_path:
            return
        with self._lock:
            if not self._dirty:
                return
            events = list(self._event_log)
            sessions = [
                (token, session["account_id"], float(session["expires"]))
                for token, session in self._sessions.items()
                if session["expires"] >= time.time()
            ]
            self._dirty = False
        con = sqlite3.connect(self._db_path)  # type: ignore[arg-type]
        con.executemany(
            "INSERT OR IGNORE INTO account_events (event_id, payload, ts) VALUES (?, ?, ?)",
            [(e["id"], json.dumps(e), float(e.get("ts", 0.0))) for e in events],
        )
        con.execute("DELETE FROM account_sessions")
        con.executemany(
            "INSERT INTO account_sessions (token, account_id, expires) VALUES (?, ?, ?)",
            sessions,
        )
        con.commit()
        con.close()
