"""
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``.

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)\.(.+)$")


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

	READ = "read"
	WRITE = "write"
	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.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)


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()