"""
PyGitWeb permission keys and grant checks.

``auth.json`` field ``oauth_permissions`` maps permission keys to principal lists
(typically OAuth email addresses). The special key ``*`` lists principals granted
every permission.

Scoped keys use a project segment that supports ``fnmatch`` globs (``*``, ``?``,
``[seq]``), e.g. ``pgw.tasks.foo/*``, ``pgw.read.*``, or ``pgw.write.myproj``.
``pgw.write_ref`` scopes are ``project`` plus the path after ``refs/``
(for example ``demo/heads/main``, ``demo/tags/v1.*``, ``demo/notes/*``).

When any ``pgw.write_ref`` key applies to a project, those grants are a whitelist:
only listed principals may push matching refs, and project write alone is not
enough for any ref. When no ``write_ref`` key applies to the project, project
write remains sufficient for every ref under ``refs/``.

Principal ``*`` in a grant list matches any authenticated principal.
"""

from __future__ import annotations

import fnmatch
import re
from enum import StrEnum
from typing import Self

from pydantic import BaseModel, ConfigDict, RootModel, model_validator

WILDCARD_PERMISSION_KEY = "*"
WILDCARD_PRINCIPAL = "*"

# Known permission families (suffix after ``pgw.``).
PERMISSION_ADD_PROJECTS = "pgw.addprojects"
PERMISSION_SETTINGS = "pgw.settings"

_SCOPED_PREFIX = re.compile(
    r"^pgw\.(boards|tasks|comments|hooks|mr\.create|mr\.merge|read|write_ref|write)\.(.+)$",
)

_REFS_PREFIX = "refs/"


class Permission(StrEnum):
    """Logical permission checked by route dependencies."""

    READ = "read"
    WRITE = "write"
    WRITE_REF = "write_ref"
    BOARDS = "boards"
    TASKS = "tasks"
    COMMENTS = "comments"
    HOOKS = "hooks"
    MR_CREATE = "mr.create"
    MR_MERGE = "mr.merge"
    ADD_PROJECTS = "addprojects"
    SETTINGS = "settings"

    @property
    def global_key(self) -> str | None:
        if self is Permission.ADD_PROJECTS:
            return PERMISSION_ADD_PROJECTS
        if self is Permission.SETTINGS:
            return PERMISSION_SETTINGS
        return None

    @property
    def scoped_prefix(self) -> str | None:
        if self in (
            Permission.READ,
            Permission.WRITE,
            Permission.WRITE_REF,
            Permission.BOARDS,
            Permission.TASKS,
            Permission.COMMENTS,
            Permission.HOOKS,
            Permission.MR_CREATE,
            Permission.MR_MERGE,
        ):
            return f"pgw.{self.value}."
        return None


class ParsedPermissionKey(BaseModel):
    model_config = ConfigDict(frozen=True)

    raw: str
    permission: Permission
    project_scope: str | None = None

    @property
    def is_global(self) -> bool:
        return self.project_scope is None


def parse_permission_key(key: str) -> ParsedPermissionKey | None:
    """Return a parsed key, or ``None`` if ``key`` is not a known permission name."""
    raw = key.strip()
    if not raw or raw == WILDCARD_PERMISSION_KEY:
        return None
    if raw == PERMISSION_ADD_PROJECTS:
        return ParsedPermissionKey(raw=raw, permission=Permission.ADD_PROJECTS)
    if raw == PERMISSION_SETTINGS:
        return ParsedPermissionKey(raw=raw, permission=Permission.SETTINGS)
    m = _SCOPED_PREFIX.match(raw)
    if not m:
        return None
    kind, scope = m.group(1), m.group(2)
    try:
        perm = Permission(kind)
    except ValueError:
        return None
    if not scope:
        return None
    return ParsedPermissionKey(raw=raw, permission=perm, project_scope=scope)


def permission_key(permission: Permission, project: str) -> str:
    """Build the canonical scoped permission key for a project."""
    prefix = permission.scoped_prefix
    if prefix is None:
        raise ValueError(f"{permission!r} is not project-scoped")
    return f"{prefix}{project}"


def project_matches_scope(project: str, scope: str) -> bool:
    if scope == WILDCARD_PERMISSION_KEY:
        return True
    return fnmatch.fnmatchcase(project, scope)


def write_ref_project_segment(scope: str) -> str:
    """Project segment of a ``write_ref`` scope (text before the first ``/``)."""
    project_part, sep, _rest = scope.partition("/")
    return project_part if sep else scope


def ref_permission_subject(project: str, ref_name: str) -> str | None:
    if not ref_name.startswith(_REFS_PREFIX):
        return None
    rest = ref_name.removeprefix(_REFS_PREFIX)
    if not rest:
        return None
    return f"{project}/{rest}"


def project_has_write_ref_rules(permissions: PermissionsMap, project: str) -> bool:
    """True when any ``pgw.write_ref`` key's project segment matches ``project``."""
    for key in permissions.root:
        if key == WILDCARD_PERMISSION_KEY:
            continue
        parsed = parse_permission_key(key)
        if parsed is None or parsed.permission is not Permission.WRITE_REF or parsed.project_scope is None:
            continue
        if project_matches_scope(project, write_ref_project_segment(parsed.project_scope)):
            return True
    return False


def has_ref_write_permission(
    permissions: PermissionsMap,
    principal: str | None,
    project: str,
    ref_name: str,
) -> bool:
    """Whether ``principal`` may push ``ref_name`` in ``project``.

    If any ``write_ref`` rule applies to ``project``, only matching ``write_ref``
    grants allow the push (whitelist). Otherwise project ``write`` is enough.
    """
    subject = ref_permission_subject(project, ref_name)
    if subject is None:
        return False
    if project_has_write_ref_rules(permissions, project):
        return permissions.has_permission(principal, Permission.WRITE_REF, subject)
    return permissions.has_permission(principal, Permission.WRITE, project)


class PermissionsMap(RootModel[dict[str, list[str]]]):
    """Validated ``oauth_permissions`` object from auth.json."""

    root: dict[str, list[str]]

    @model_validator(mode="before")
    @classmethod
    def _coerce_root(cls, v: object) -> object:
        if isinstance(v, PermissionsMap):
            return v.root
        if v is None:
            return {WILDCARD_PERMISSION_KEY: []}
        if not isinstance(v, dict):
            raise TypeError("oauth_permissions must be an object")
        out: dict[str, list[str]] = {}
        for key, principals in v.items():
            if not isinstance(key, str):
                raise TypeError("permission keys must be strings")
            if key != WILDCARD_PERMISSION_KEY and parse_permission_key(key) is None:
                raise ValueError(f"unknown permission key: {key!r}")
            if not isinstance(principals, list):
                raise TypeError(f"oauth_permissions[{key!r}] must be a list")
            normalized: list[str] = []
            for p in principals:
                if not isinstance(p, str):
                    raise TypeError(f"oauth_permissions[{key!r}] entries must be strings")
                s = p.strip()
                if s:
                    normalized.append(s)
            out[key] = normalized
        if WILDCARD_PERMISSION_KEY not in out:
            out = {**out, WILDCARD_PERMISSION_KEY: []}
        return out

    @classmethod
    def empty(cls) -> Self:
        return cls({WILDCARD_PERMISSION_KEY: []})

    @classmethod
    def default_access(cls) -> Self:
        """Default grants: any authenticated principal may read and write every project."""
        return cls({
            WILDCARD_PERMISSION_KEY: [],
            permission_key(Permission.READ, WILDCARD_PERMISSION_KEY): [WILDCARD_PRINCIPAL],
            permission_key(Permission.WRITE, WILDCARD_PERMISSION_KEY): [WILDCARD_PRINCIPAL],
        })

    def principals_for_key(self, key: str) -> frozenset[str]:
        return frozenset(self.root.get(key, []))

    def superuser_principals(self) -> frozenset[str]:
        return self.principals_for_key(WILDCARD_PERMISSION_KEY)

    def matching_scoped_principals(self, permission: Permission, project: str) -> frozenset[str]:
        prefix = permission.scoped_prefix
        if prefix is None:
            return frozenset()
        acc: set[str] = set()
        for key, principals in self.root.items():
            if key == WILDCARD_PERMISSION_KEY:
                continue
            parsed = parse_permission_key(key)
            if parsed is None or parsed.permission is not permission or parsed.project_scope is None:
                continue
            if project_matches_scope(project, parsed.project_scope):
                acc.update(principals)
        return frozenset(acc)

    def principals_with_permission(self, permission: Permission, project: str | None = None) -> frozenset[str]:
        acc = set(self.superuser_principals())
        global_key = permission.global_key
        if global_key is not None:
            acc.update(self.principals_for_key(global_key))
            return frozenset(acc)
        if project is None:
            return frozenset(acc)
        acc.update(self.matching_scoped_principals(permission, project))
        return frozenset(acc)

    def has_permission(self, principal: str | None, permission: Permission, project: str | None = None) -> bool:
        if not principal or not principal.strip():
            return False
        normalized = principal.strip()
        allowed = self.principals_with_permission(permission, project)
        if WILDCARD_PRINCIPAL in allowed:
            return True
        if normalized in allowed:
            return True
        # Case-insensitive match for email principals.
        lower = normalized.lower()
        return any(p.lower() == lower for p in allowed if p != WILDCARD_PRINCIPAL)


def normalize_oauth_permissions(raw: object) -> dict[str, list[str]]:
    return PermissionsMap.model_validate(raw).root


def permissions_with_defaults(extra: dict[str, list[str]] | None = None) -> PermissionsMap:
    """Default read/write for all authenticated users, merged with test-specific grants."""
    root = PermissionsMap.default_access().root.copy()
    if extra:
        root.update(extra)
    return PermissionsMap.model_validate(root)


class PermissionPrincipal(BaseModel):
    model_config = ConfigDict(frozen=True)

    username: str
    email: str | None = None

    @property
    def identity(self) -> str:
        """Principal string used for grant lookup (email when present, else username)."""
        if self.email and self.email.strip():
            return self.email.strip()
        return self.username.strip()