"""Obfuscation helpers."""

# Standard Library
import base64
import hashlib
import hmac
import itertools
import logging
import secrets
import string
import time
import json
from dataclasses import dataclass, field
from collections.abc import Mapping
from typing import Dict, Iterable, List, Optional

# Django
from django.contrib.auth.models import Group, User

# Alliance Auth
from allianceauth.services.modules.discord.discord_client.helpers import RolesSet

# Discord Obfuscate App
from discord_obfuscate.app_settings import DISCORD_OBFUSCATE_SECRET
from discord_obfuscate.config import require_existing_role
from discord_obfuscate.constants import (
    ALLOWED_DIVIDERS,
    DEFAULT_OBFUSCATE_ENABLED,
    DEFAULT_OBFUSCATE_FORMAT,
    DEFAULT_OBFUSCATE_METHOD,
    DEFAULT_OBFUSCATE_PREFIX,
    OBFUSCATION_METHODS,
    ROLE_NAME_MAX_LEN,
)
from discord_obfuscate.models import DiscordRoleObfuscation

logger = logging.getLogger(__name__)

RANDOM_KEY_CHARS = string.ascii_letters + string.digits


def generate_random_key(length: int = 16) -> str:
    return "".join(secrets.choice(RANDOM_KEY_CHARS) for _ in range(length))



@dataclass(frozen=True)
class RoleNameResolution:
    """Resolved role name for a group."""

    group: Optional[Group]
    desired_name: str
    used_name: Optional[str]
    matched_role_id: Optional[int]
    used_original: bool


@dataclass
class RawRole:
    """Fallback role model when the client returns stripped objects."""

    id: int
    name: str
    position: int = 0
    color: int = 0
    managed: bool = False
    raw: dict = field(default_factory=dict, repr=False)

    def __getattr__(self, item):
        if item in self.raw:
            return self.raw[item]
        raise AttributeError(f"{type(self).__name__} has no attribute '{item}'")


class SimpleRolesSet:
    """Minimal RolesSet replacement for raw role payloads."""

    def __init__(self, roles: list):
        self._roles = list(roles or [])
        self._by_name = {
            getattr(role, "name", None): role
            for role in self._roles
            if getattr(role, "name", None)
        }
        self._by_id = {
            getattr(role, "id", None): role
            for role in self._roles
            if getattr(role, "id", None) is not None
        }

    def __iter__(self):
        return iter(self._roles)

    def __len__(self):
        return len(self._roles)

    def role_by_name(self, name: str):
        return self._by_name.get(name)

    def role_by_id(self, role_id: int):
        return self._by_id.get(role_id)


def _method_info(method: str) -> tuple:
    method = method or DEFAULT_OBFUSCATE_METHOD
    if method not in OBFUSCATION_METHODS:
        method = DEFAULT_OBFUSCATE_METHOD
    _, algo, encoding = OBFUSCATION_METHODS[method]
    return algo, encoding


def _hash_bytes(name: str, secret: str, algo: str) -> bytes:
    secret_bytes = str(secret or "").encode("utf-8")
    name_bytes = str(name).encode("utf-8")
    digestmod = hashlib.sha256
    if algo == "blake2s":
        digestmod = hashlib.blake2s
    return hmac.new(secret_bytes, name_bytes, digestmod).digest()


def _encode_hash(hash_bytes: bytes, encoding: str) -> str:
    if encoding == "base32":
        return base64.b32encode(hash_bytes).decode("ascii").rstrip("=")
    return hash_bytes.hex()


def _apply_format(format_str: str, tokens: dict) -> str:
    result = format_str or DEFAULT_OBFUSCATE_FORMAT
    for key, value in tokens.items():
        result = result.replace(f"{{{key}}}", value)
    return result


def _sanitize_output(value: str, allowed_dividers: list) -> str:
    cleaned = []
    divider_set = set(allowed_dividers or [])
    for char in value:
        if char.isalnum() or char in divider_set:
            cleaned.append(char)
    return "".join(cleaned)


def _insert_dividers(value: str, dividers: list, min_chars: int) -> str:
    if not dividers or min_chars <= 0:
        return value
    chunks = [value[i : i + min_chars] for i in range(0, len(value), min_chars)]
    if len(chunks) <= 1:
        return value
    out = chunks[0]
    for divider, chunk in zip(itertools.cycle(dividers), chunks[1:]):
        out += divider + chunk
    return out


def obfuscate_name(
    name: str,
    method: str,
    secret: str,
    prefix: str = "",
    format_str: str = "",
    dividers: Optional[list] = None,
    min_chars_before_divider: int = 0,
) -> str:
    """Create a deterministic obfuscated name."""
    algo, encoding = _method_info(method)
    hash_str = _encode_hash(_hash_bytes(name, secret, algo), encoding)

    format_str = format_str or DEFAULT_OBFUSCATE_FORMAT
    prefix = prefix or ""
    if prefix and "{prefix}" not in format_str:
        format_str = "{prefix}" + format_str

    tokens = {
        "prefix": _sanitize_output(prefix, ALLOWED_DIVIDERS),
        "hash8": hash_str[:8],
        "hash12": hash_str[:12],
        "hash16": hash_str[:16],
    }
    value = _apply_format(format_str, tokens)
    value = _sanitize_output(value, dividers or [])
    value = _insert_dividers(value, dividers or [], min_chars_before_divider)
    value = _sanitize_output(value, dividers or [])
    return value[:ROLE_NAME_MAX_LEN]


def role_name_for_group(
    group: Group,
    config: Optional[DiscordRoleObfuscation],
) -> str:
    """Determine the desired role name for a group based on config."""
    if config is None:
        return group.name
    if config and config.opt_out:
        return group.name
    if config and config.custom_name:
        dividers = config.get_dividers()
        return _sanitize_output(str(config.custom_name), dividers)[:ROLE_NAME_MAX_LEN]
    method = config.obfuscation_type if config else DEFAULT_OBFUSCATE_METHOD
    format_str = config.obfuscation_format if config else DEFAULT_OBFUSCATE_FORMAT
    dividers = config.get_dividers() if config else []
    min_chars = config.min_chars_before_divider if config else 0
    input_name = group.name
    if config and config.use_random_key and config.random_key:
        input_name = config.random_key
    return obfuscate_name(
        input_name,
        method,
        DISCORD_OBFUSCATE_SECRET,
        DEFAULT_OBFUSCATE_PREFIX,
        format_str,
        dividers,
        min_chars,
    )


def _rate_limit_delay(exc) -> float:
    resets_in = getattr(exc, "resets_in", None)
    if resets_in is None and exc.args:
        try:
            resets_in = float(exc.args[0])
        except (TypeError, ValueError):
            resets_in = None
    if resets_in is None:
        return 5.0
    try:
        delay = float(resets_in)
    except (TypeError, ValueError):
        return 5.0
    if delay > 60:
        delay = delay / 1000.0
    return max(delay + 0.25, 0.5)


def _roles_have_position(roles: list) -> bool:
    for role in roles:
        if hasattr(role, "position"):
            return True
    return False


def _safe_int(value, default=0) -> int:
    try:
        if value is None:
            return default
        return int(value)
    except (TypeError, ValueError):
        return default


def _normalize_role_id(value) -> int:
    return _safe_int(value, default=0)


def _fetch_raw_roles(client, guild_id):
    try:
        return client._api_request(method="get", route=f"guilds/{guild_id}/roles")
    except Exception:
        logger.exception("Failed to fetch raw roles from Discord API")
        return None


def _raw_role_from_payload(payload: dict) -> RawRole:
    role_id = _normalize_role_id(payload.get("id"))
    name = payload.get("name") or ""
    position = _safe_int(payload.get("position"), default=0)
    color = _safe_int(payload.get("color"), default=0)
    managed = bool(payload.get("managed", False))
    return RawRole(
        id=role_id,
        name=name,
        position=position,
        color=color,
        managed=managed,
        raw=payload,
    )


def _normalize_raw_roles(raw_roles):
    if raw_roles is None:
        return None
    if isinstance(raw_roles, list):
        return raw_roles
    if isinstance(raw_roles, (bytes, bytearray)):
        try:
            raw_roles = raw_roles.decode("utf-8")
        except Exception:
            return None
    if isinstance(raw_roles, str):
        try:
            parsed = json.loads(raw_roles)
            if isinstance(parsed, list):
                return parsed
            raw_roles = parsed
        except Exception:
            return None
    if isinstance(raw_roles, Mapping):
        data = raw_roles.get("data")
        if isinstance(data, list):
            return data
    if hasattr(raw_roles, "json"):
        try:
            parsed = raw_roles.json()
            if isinstance(parsed, list):
                return parsed
        except Exception:
            return None
    return None


def fetch_roleset(use_cache: bool = True, max_attempts: int = 3) -> RolesSet:
    """Fetch roles for the configured guild as RolesSet."""
    try:
        from allianceauth.services.modules.discord.core import (
            default_bot_client,
            DISCORD_GUILD_ID,
        )
        from allianceauth.services.modules.discord.discord_client.exceptions import (
            DiscordRateLimitExhausted,
        )

        for attempt in range(1, max_attempts + 1):
            try:
                roles = default_bot_client.guild_roles(
                    guild_id=DISCORD_GUILD_ID, use_cache=use_cache
                )
                roles = list(roles) if roles else []
                if roles and not _roles_have_position(roles):
                    raw_roles = _normalize_raw_roles(
                        _fetch_raw_roles(default_bot_client, DISCORD_GUILD_ID)
                    )
                    if isinstance(raw_roles, list) and raw_roles:
                        raw_objects = [
                            _raw_role_from_payload(role)
                            for role in raw_roles
                            if isinstance(role, Mapping)
                        ]
                        if raw_objects:
                            return SimpleRolesSet(raw_objects)
                    logger.warning(
                        "Raw role fetch did not return a list; position data unavailable."
                    )
                    return SimpleRolesSet(roles)
                return RolesSet(roles)
            except DiscordRateLimitExhausted as exc:
                if attempt >= max_attempts:
                    raise
                delay = _rate_limit_delay(exc)
                logger.warning(
                    "Rate limit hit fetching roles; retrying in %.2fs (attempt %s/%s)",
                    delay,
                    attempt,
                    max_attempts,
                )
                time.sleep(delay)
    except Exception:
        logger.exception("Failed to fetch roles from Discord")
        return RolesSet([])
    return RolesSet([])


def resolve_group_role_name(
    group: Group,
    roleset: RolesSet,
    config: Optional[DiscordRoleObfuscation] = None,
) -> RoleNameResolution:
    """Resolve role name to be used for a group."""
    desired = role_name_for_group(group, config)
    desired_role = roleset.role_by_name(desired)
    if desired_role:
        if desired != group.name:
            logger.debug("Resolved group %s to existing obfuscated role %s", group.name, desired)
        return RoleNameResolution(
            group=group,
            desired_name=desired,
            used_name=desired,
            matched_role_id=desired_role.id,
            used_original=(desired == group.name),
        )

    original_role = roleset.role_by_name(group.name)
    if original_role:
        if desired != group.name:
            logger.debug(
                "Desired role %s missing; using original for group %s",
                desired,
                group.name,
            )
        return RoleNameResolution(
            group=group,
            desired_name=desired,
            used_name=group.name,
            matched_role_id=original_role.id,
            used_original=True,
        )

    if not require_existing_role():
        return RoleNameResolution(
            group=group,
            desired_name=desired,
            used_name=desired,
            matched_role_id=None,
            used_original=(desired == group.name),
        )

    return RoleNameResolution(
        group=group,
        desired_name=desired,
        used_name=None,
        matched_role_id=None,
        used_original=False,
    )


def obfuscated_user_group_names(
    user: User,
    state_name: Optional[str] = None,
) -> List[str]:
    """Return obfuscated role names for a user's groups."""
    groups = list(user.groups.all())
    return obfuscated_names_for_groups(groups)


def obfuscated_names_for_groups(groups: Iterable[Group]) -> List[str]:
    """Return obfuscated role names for a set of groups."""
    groups = list(groups)
    if not DEFAULT_OBFUSCATE_ENABLED:
        return [group.name for group in groups]

    roleset = _load_roleset_with_retry()
    if roleset is None:
        return [group.name for group in groups]

    role_names: List[str] = []
    for group in groups:
        config = DiscordRoleObfuscation.objects.filter(group=group).first()
        resolution = resolve_group_role_name(group, roleset, config=config)
        if resolution.used_name:
            if resolution.used_name != group.name:
                logger.debug(
                    "Obfuscate group %s -> %s",
                    group.name,
                    resolution.used_name,
                )
            else:
                logger.debug("Using original name for group %s", group.name)
            role_names.append(resolution.used_name)
        else:
            logger.debug(
                "Skipping group %s because no matching role exists in Discord",
                group.name,
            )

    return role_names


def obfuscated_names_for_role_names(role_names: Iterable[str]) -> List[str]:
    """Obfuscate matching group names within an arbitrary list of role names."""
    role_names = list(role_names)
    if not role_names:
        return []
    if not DEFAULT_OBFUSCATE_ENABLED:
        return role_names

    groups = list(Group.objects.filter(name__in=role_names))
    if not groups:
        return role_names

    roleset = _load_roleset_with_retry()
    if roleset is None:
        return role_names

    name_map: Dict[str, Optional[str]] = {}
    for group in groups:
        config = DiscordRoleObfuscation.objects.filter(group=group).first()
        resolution = resolve_group_role_name(group, roleset, config=config)
        name_map[group.name] = resolution.used_name

    output: List[str] = []
    for name in role_names:
        desired = name_map.get(name)
        if name in name_map:
            if desired:
                output.append(desired)
            continue
        output.append(name)
    return output


def _load_roleset_with_retry() -> Optional[RolesSet]:
    roleset = fetch_roleset(use_cache=True)
    if roleset and len(roleset):
        return roleset
    logger.warning("Roleset cache empty; refetching from Discord API.")
    roleset = None
    for attempt in range(1, 4):
        roleset = fetch_roleset(use_cache=False)
        if roleset and len(roleset):
            return roleset
        if attempt < 3:
            logger.warning(
                "Roleset still empty; retrying in 5s (attempt %s/3).",
                attempt,
            )
            time.sleep(5)
    logger.warning(
        "Roleset unavailable; returning original group names to avoid role creation."
    )
    return None


def get_group_configs(groups: Iterable[Group]) -> Dict[int, DiscordRoleObfuscation]:
    """Fetch configs keyed by group id."""
    configs = DiscordRoleObfuscation.objects.filter(group__in=groups)
    return {cfg.group_id: cfg for cfg in configs}
