"""Cross-bot shared JSON state: account links + blacklist.

Both SREBOT and TSSBOT put ``BOTS/SHARED`` on ``sys.path`` at startup, so this
module is importable by bare name (``from shared_store import ...``) from either
bot.

Two files are managed here:

* ``BLACKLIST.json`` — admin-curated, version-controlled, lives next to this
  module in ``BOTS/SHARED``. Sections: ``discord_users`` (entries may be a plain
  id, ``[id, comment, reason]``, or ``{"id": id, "comment": "...", "reason": "..."}``)
  ``squadrons`` (short tags, SRE-only — TSS teams are intentionally not
  blacklistable), and ``guilds`` (Discord server ids, SRE-only — TSS does not
  yet honour a guild blacklist). Read by both bots.
* ``PLAYERS.json`` — runtime-written Discord-user → WT-account links. Lives in
  ``$STORAGE_VOL_PATH`` (the storage volume both bots share, right next to
  SQUADRONS.json), so SREBOT and TSSBOT read/write the same links.
"""
from __future__ import annotations

import json
import logging
import os
import threading
import time
from pathlib import Path
from typing import Any, Optional

log = logging.getLogger("shared_store")

_LOCK = threading.Lock()

# BLACKLIST.json lives alongside this module in BOTS/SHARED (version-controlled).
BLACKLIST_PATH: Path = Path(__file__).resolve().parent / "BLACKLIST.json"

PLAYERS_FILENAME = "PLAYERS.json"


# ---------------------------------------------------------------------------
# Paths / IO
# ---------------------------------------------------------------------------

def storage_dir() -> Path:
    """Return the shared storage volume root (where PLAYERS.json lives).

    Both bots point ``STORAGE_VOL_PATH`` at the same volume, so PLAYERS.json
    there is read/written by SREBOT and TSSBOT alike, next to SQUADRONS.json.
    """
    raw = os.environ.get("STORAGE_VOL_PATH", "").strip()
    if not raw:
        raise RuntimeError("STORAGE_VOL_PATH must be set")
    p = Path(raw).expanduser()
    p.mkdir(parents=True, exist_ok=True)
    return p


def _players_path() -> Path:
    return storage_dir() / PLAYERS_FILENAME


def _read_json(path: Path, default: Any) -> Any:
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return default
    except (json.JSONDecodeError, OSError) as e:
        log.error("failed reading %s: %s", path, e)
        return default


def _write_json_atomic(path: Path, data: Any) -> bool:
    tmp = path.with_suffix(path.suffix + ".tmp")
    try:
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=4)
        os.replace(tmp, path)
        return True
    except OSError as e:
        log.error("failed writing %s: %s", path, e)
        return False


# ---------------------------------------------------------------------------
# PLAYERS.json — {discord_user_id: {"uid": str, "linked_unix": int}}
# ---------------------------------------------------------------------------

def load_player_links() -> dict[str, dict[str, Any]]:
    """Return all Discord-user → WT-account links."""
    data = _read_json(_players_path(), {})
    return data if isinstance(data, dict) else {}


def get_linked_uid(discord_user_id: int | str) -> Optional[str]:
    """Return the WT UID linked to a Discord user, or None."""
    entry = load_player_links().get(str(discord_user_id))
    if isinstance(entry, dict):
        uid = entry.get("uid")
        return str(uid) if uid not in (None, "") else None
    return None


def save_player_link(discord_user_id: int | str, uid: str) -> bool:
    """Link a Discord user to a WT UID (overwrites any existing link)."""
    with _LOCK:
        links = load_player_links()
        links[str(discord_user_id)] = {"uid": str(uid), "linked_unix": int(time.time())}
        return _write_json_atomic(_players_path(), links)


def remove_player_link(discord_user_id: int | str) -> bool:
    """Remove a Discord user's link. Returns True if a link was removed."""
    with _LOCK:
        links = load_player_links()
        if str(discord_user_id) in links:
            del links[str(discord_user_id)]
            return _write_json_atomic(_players_path(), links)
    return False


# ---------------------------------------------------------------------------
# BLACKLIST.json — {"discord_users": [...], "squadrons": [...]}
# ---------------------------------------------------------------------------

_BLACKLIST_CACHE: Optional[dict[str, Any]] = None
_BLACKLIST_CACHE_TS: float = 0.0
_BLACKLIST_TTL = 60.0


def _default_blacklist() -> dict[str, list]:
    return {"discord_users": [], "squadrons": [], "guilds": []}


def load_blacklist(force: bool = False) -> dict[str, Any]:
    """Return the blacklist dict, cached for a short TTL (reloadable with force)."""
    global _BLACKLIST_CACHE, _BLACKLIST_CACHE_TS
    now = time.time()
    if not force and _BLACKLIST_CACHE is not None and (now - _BLACKLIST_CACHE_TS) < _BLACKLIST_TTL:
        return _BLACKLIST_CACHE
    data = _read_json(BLACKLIST_PATH, _default_blacklist())
    if not isinstance(data, dict):
        data = _default_blacklist()
    data.setdefault("discord_users", [])
    data.setdefault("squadrons", [])
    data.setdefault("guilds", [])
    _BLACKLIST_CACHE = data
    _BLACKLIST_CACHE_TS = now
    return data


def blacklisted_squadrons() -> list[str]:
    """Return blacklisted squadron short tags (SRE autolog matching)."""
    return [str(s) for s in load_blacklist().get("squadrons", [])]


def blacklisted_guilds() -> list[int]:
    """Return blacklisted Discord guild (server) IDs.

    Entries may be a plain id, ``[id, comment, reason]``, or a dict with
    ``id``/``guild_id`` plus optional ``comment``/``reason`` (mirrors the
    ``discord_users`` format). Comment/reason are for readability only and are
    ignored here. Unparseable entries are skipped.
    """
    out: list[int] = []
    for entry in load_blacklist().get("guilds", []):
        if isinstance(entry, dict):
            raw = entry.get("id", entry.get("guild_id"))
        elif isinstance(entry, (list, tuple)):
            raw = entry[0] if entry else None
        else:
            raw = entry
        if raw is None:
            continue
        try:
            out.append(int(raw))
        except (TypeError, ValueError):
            continue
    return out


def check_guild_blacklist(guild_id: int | str) -> tuple[bool, Optional[str]]:
    """Return (is_blacklisted, reason) for a Discord guild (server).

    Entries follow the same flexible format as ``discord_users`` (plain id,
    ``[id, comment, reason]``, or a dict with ``id``/``guild_id`` plus optional
    ``reason``). SRE-only — see the module docstring. An empty/missing reason
    comes back as ``None``.
    """
    target = str(guild_id)
    for entry in load_blacklist().get("guilds", []):
        if isinstance(entry, dict):
            blocked_id = entry.get("id", entry.get("guild_id"))
            reason = entry.get("reason")
        elif isinstance(entry, (list, tuple)):
            blocked_id = entry[0] if entry else None
            reason = entry[2] if len(entry) > 2 else None
        else:
            blocked_id, reason = entry, None
        if str(blocked_id) == target:
            return True, (reason or None)
    return False, None


def check_user_blacklist(discord_user_id: int | str) -> tuple[bool, Optional[str]]:
    """Return (is_blacklisted, reason) for a Discord user.

    Entries may be a plain id, ``[id, comment, reason]``, or a dict with
    ``id``/``discord_id`` plus optional ``reason``. The comment/name is ignored
    by runtime checks and exists only to make BLACKLIST.json readable.
    """
    target = str(discord_user_id)
    for entry in load_blacklist().get("discord_users", []):
        if isinstance(entry, dict):
            blocked_id = entry.get("id", entry.get("discord_id"))
            reason = entry.get("reason")
        elif isinstance(entry, (list, tuple)):
            blocked_id = entry[0]
            reason = entry[2] if len(entry) > 2 else None
        else:
            blocked_id, reason = entry, None
        if str(blocked_id) == target:
            return True, reason
    return False, None
