from __future__ import annotations

from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.utils import timezone

from apps.access.models import MembershipRole, Role
from apps.access.services import AccessContext, seed_roles, set_member_roles
from apps.audit import services as audit
from apps.modules import services as modules
from apps.organizations.models import Organization, OrganizationAssignment, OrganizationMembership
from domain.exceptions import DomainError, OrganizationStateError
from domain.value_objects import normalize_iran_mobile

# Dashboard colours a manager may choose for the whole organization (the palettes live in the frontend).
ACCENT_COLORS = ("violet", "blue", "green", "orange", "rose", "teal")

# Modules every new customer organization starts with (attendance, tasks, personnel); the super admin
# can enable the others.
DEFAULT_MODULES = ("attendance", "tasks", "personnel")


@transaction.atomic
def create_organization(*, actor, name: str, slug: str, request=None) -> Organization:
    if Organization.objects.filter(slug=slug).exists():
        raise DomainError("This slug is already taken.")
    organization = Organization.objects.create(name=name.strip(), slug=slug, kind=Organization.Kind.CUSTOMER)
    seed_roles(organization)
    audit.record(
        actor=actor, action="organization.created", organization=organization, entity_type="organization",
        entity_id=organization.pk, after={"name": organization.name, "slug": organization.slug}, request=request,
    )
    for code in DEFAULT_MODULES:  # a new organization starts with attendance and tasks only
        modules.enable_module(actor=actor, organization=organization, module_code=code, request=request)
    return organization


@transaction.atomic
def update_settings(
    *, actor, organization: Organization, accent_color=None, name=None, request=None
) -> Organization:
    """Organization-wide identity and look, changed by the organization's own manager (settings.manage)."""
    before = {"accent_color": organization.accent_color, "name": organization.name}
    if accent_color is not None:
        if accent_color not in ACCENT_COLORS:
            raise DomainError("Unknown colour.")
        organization.accent_color = accent_color
    if name is not None:
        name = str(name).strip()
        if not 2 <= len(name) <= 120:
            raise DomainError("The name must have between 2 and 120 characters.")
        organization.name = name
    organization.save(update_fields=["accent_color", "name", "updated_at"])
    audit.record(
        actor=actor, action="organization.settings_updated", organization=organization,
        entity_type="organization", entity_id=organization.pk, before=before,
        after={"accent_color": organization.accent_color, "name": organization.name}, request=request,
    )
    return organization


LOGO_MAX_BYTES = 1024 * 1024
# magic bytes -> extension; SVG is refused on purpose (it can carry scripts)
_LOGO_SIGNATURES = ((b"\x89PNG\r\n\x1a\n", "png"), (b"\xff\xd8\xff", "jpg"))


def _logo_extension(head: bytes) -> str | None:
    for signature, extension in _LOGO_SIGNATURES:
        if head.startswith(signature):
            return extension
    if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
        return "webp"
    return None


@transaction.atomic
def set_logo(*, actor, organization: Organization, upload, request=None) -> Organization:
    from django.core.files.base import ContentFile

    if upload is None:
        raise DomainError("Choose an image file.")
    if upload.size > LOGO_MAX_BYTES:
        raise DomainError("The logo must be at most 1 MB.")
    data = upload.read()
    extension = _logo_extension(data[:16])
    if extension is None:
        raise DomainError("The logo must be a PNG, JPEG or WebP image.")
    if organization.logo:
        organization.logo.delete(save=False)
    # the stored name is ours (never the uploaded one)
    organization.logo.save(f"{organization.pk}-{timezone.now():%Y%m%d%H%M%S}.{extension}", ContentFile(data), save=False)
    organization.save(update_fields=["logo", "updated_at"])
    audit.record(
        actor=actor, action="organization.logo_updated", organization=organization,
        entity_type="organization", entity_id=organization.pk, request=request,
    )
    return organization


@transaction.atomic
def remove_logo(*, actor, organization: Organization, request=None) -> Organization:
    if organization.logo:
        organization.logo.delete(save=False)
    organization.logo = None
    organization.save(update_fields=["logo", "updated_at"])
    audit.record(
        actor=actor, action="organization.logo_removed", organization=organization,
        entity_type="organization", entity_id=organization.pk, request=request,
    )
    return organization


@transaction.atomic
def add_first_manager(
    *, actor_user, organization: Organization, username: str, password: str, name: str = "", request=None
) -> OrganizationMembership:
    """Create the login of a new organization's manager (role org_manager) on behalf of platform staff."""
    from apps.access.services import resolve_access

    ctx = resolve_access(actor_user, organization)
    if ctx is None:
        raise DomainError("You have no access to this organization.")
    membership = add_member(actor=ctx, username=username, password=password, name=name, request=request)
    # Appointing the founding manager is a platform action, so it bypasses the delegation check (the
    # manager role also lists permissions of modules that are not enabled yet, which nobody holds here).
    role = Role.objects.get(organization=organization, code="org_manager")
    MembershipRole.objects.get_or_create(membership=membership, role=role)
    audit.record(
        actor=actor_user, action="member.roles_set", organization=organization, entity_type="membership",
        entity_id=membership.pk, after={"roles": [role.pk]}, request=request,
    )
    return membership


@transaction.atomic
def update_organization(*, actor, organization: Organization, name=None, is_active=None, request=None):
    if organization.is_platform and is_active is False:
        raise OrganizationStateError("The platform organization cannot be deactivated.")
    before = {"name": organization.name, "is_active": organization.is_active}
    if name is not None:
        organization.name = name.strip()
    if is_active is not None:
        organization.is_active = is_active
    organization.save()
    audit.record(
        actor=actor, action="organization.updated", organization=organization, entity_type="organization",
        entity_id=organization.pk, before=before,
        after={"name": organization.name, "is_active": organization.is_active}, request=request,
    )
    return organization


def _validate_new_password(password: str) -> None:
    try:
        validate_password(password)
    except DjangoValidationError as exc:
        raise DomainError(" ".join(exc.messages))


@transaction.atomic
def add_member(
    *, actor: AccessContext, username: str, password: str, name: str = "", mobile: str | None = None,
    personnel_code: str | None = None, role_ids: list[int] | None = None, request=None,
) -> OrganizationMembership:
    """Create a new login account and make it a member of the actor's organization."""
    User = get_user_model()
    organization = actor.organization
    if not organization.is_active:
        raise OrganizationStateError("The organization is inactive.")

    username = (username or "").strip().lower()
    if len(username) < 3:
        raise DomainError("username must have at least 3 characters.")
    normalized_mobile = None
    if mobile:
        normalized_mobile = normalize_iran_mobile(mobile)
        if normalized_mobile is None:
            raise DomainError("mobile is not a valid Iranian mobile number.")

    # One generic message: do not reveal whether a username/mobile belongs to another organization.
    taken = User.objects.filter(username__iexact=username).exists() or User.objects.filter(
        personnel_code=username
    ).exists()
    if normalized_mobile and User.objects.filter(mobile=normalized_mobile).exists():
        taken = True
    if taken:
        raise DomainError("The username or mobile number is not available.")
    if personnel_code and OrganizationMembership.objects.filter(
        organization=organization, personnel_code=personnel_code
    ).exists():
        raise DomainError("This personnel code is already used in this organization.")
    _validate_new_password(password)

    user = User.objects.create_user(
        personnel_code=username, password=password, name=(name or "").strip() or None,
        username=username, mobile=normalized_mobile,
    )
    membership = OrganizationMembership.objects.create(
        user=user, organization=organization, personnel_code=personnel_code or None
    )
    if role_ids:
        roles = list(Role.objects.filter(organization=organization, pk__in=role_ids))
        if len(roles) != len(set(role_ids)):
            raise DomainError("One or more roles do not exist in this organization.")
        set_member_roles(actor=actor, target=membership, roles=roles, request=request)

    audit.record(
        actor=actor.user, action="member.added", organization=organization, entity_type="membership",
        entity_id=membership.pk, after={"username": username, "user_id": user.pk}, request=request,
    )
    return membership


@transaction.atomic
def link_existing_member(
    *, actor: AccessContext, username: str, personnel_code: str | None = None, request=None
) -> OrganizationMembership:
    """Platform staff only: give an existing login a membership in another organization."""
    User = get_user_model()
    user = User.objects.filter(username__iexact=(username or "").strip()).first()
    if user is None:
        raise DomainError("No account with this username.")
    if OrganizationMembership.objects.filter(user=user, organization=actor.organization).exists():
        raise DomainError("The account is already a member of this organization.")
    membership = OrganizationMembership.objects.create(
        user=user, organization=actor.organization, personnel_code=personnel_code or None
    )
    audit.record(
        actor=actor.user, action="member.linked", organization=actor.organization, entity_type="membership",
        entity_id=membership.pk, after={"user_id": user.pk}, request=request,
    )
    return membership


@transaction.atomic
def update_member(
    *, actor: AccessContext, membership: OrganizationMembership, data: dict, request=None
) -> OrganizationMembership:
    """Edit a member. The display name and this organization's personnel code are safe to change.
    Account identity (username, mobile, password) belongs to the login, which may be shared by
    other organizations, so it can only be changed when this is the account's only membership
    and the target is not the actor."""
    User = get_user_model()
    if membership.organization_id != actor.organization.pk:
        raise DomainError("The member does not belong to the selected organization.")
    user = membership.user
    identity = {k for k in ("username", "mobile", "password") if data.get(k) not in (None, "")}
    if "mobile_verified" in data:
        identity.add("mobile_verified")
    if identity:
        if membership.pk == actor.membership.pk:
            raise DomainError("You cannot change your own account details here.")
        if OrganizationMembership.objects.filter(user=user).exclude(pk=membership.pk).exists():
            raise DomainError("This account belongs to other organizations; its login details cannot be changed here.")

    changed = []
    if "name" in data:
        user.name = (data["name"] or "").strip() or None
        changed.append("name")
    if data.get("username"):
        username = data["username"].strip().lower()
        if len(username) < 3:
            raise DomainError("username must have at least 3 characters.")
        if username != (user.username or "").lower():
            if User.objects.filter(username__iexact=username).exclude(pk=user.pk).exists() or User.objects.filter(
                personnel_code=username
            ).exclude(pk=user.pk).exists():
                raise DomainError("The username or mobile number is not available.")
            if user.personnel_code == user.username:
                user.personnel_code = username
            user.username = username
            changed.append("username")
    if data.get("mobile"):
        mobile = normalize_iran_mobile(data["mobile"])
        if mobile is None:
            raise DomainError("mobile is not a valid Iranian mobile number.")
        if mobile != user.mobile:
            if User.objects.filter(mobile=mobile).exclude(pk=user.pk).exists():
                raise DomainError("The username or mobile number is not available.")
            user.mobile = mobile
            user.mobile_verified_at = None
            changed.append("mobile")
    if "mobile_verified" in data:
        if data["mobile_verified"] and not user.mobile:
            raise DomainError("The account has no mobile number to verify.")
        user.mobile_verified_at = timezone.now() if data["mobile_verified"] else None
        changed.append("mobile_verified")
    if data.get("password"):
        _validate_new_password(data["password"])
        user.set_password(data["password"])
        changed.append("password")
    if changed:
        user.save()

    if "personnel_code" in data:
        code = data["personnel_code"] or None
        if code and OrganizationMembership.objects.filter(
            organization=actor.organization, personnel_code=code
        ).exclude(pk=membership.pk).exists():
            raise DomainError("This personnel code is already used in this organization.")
        membership.personnel_code = code
        membership.save(update_fields=["personnel_code"])
        changed.append("personnel_code")

    audit.record(
        actor=actor.user, action="member.updated", organization=actor.organization,
        entity_type="membership", entity_id=membership.pk, after={"fields": changed}, request=request,
    )
    return membership


@transaction.atomic
def set_member_status(
    *, actor: AccessContext, membership: OrganizationMembership, status: str, request=None
) -> OrganizationMembership:
    if membership.organization_id != actor.organization.pk:
        raise DomainError("The member does not belong to the selected organization.")
    if membership.pk == actor.membership.pk:
        raise DomainError("You cannot change your own membership status.")
    if status not in OrganizationMembership.Status.values:
        raise DomainError("Unknown status.")
    before = membership.status
    membership.status = status
    membership.save(update_fields=["status"])
    audit.record(
        actor=actor.user, action="member.status_changed", organization=actor.organization,
        entity_type="membership", entity_id=membership.pk, before={"status": before},
        after={"status": status}, request=request,
    )
    return membership


@transaction.atomic
def set_staff_assignments(*, actor, platform_membership: OrganizationMembership, organization_ids: list[int], request=None):
    if not platform_membership.organization.is_platform:
        raise DomainError("Assignments apply to NoTeam staff memberships only.")
    orgs = list(Organization.objects.filter(pk__in=organization_ids, kind=Organization.Kind.CUSTOMER))
    if len(orgs) != len(set(organization_ids)):
        raise DomainError("One or more organizations do not exist.")
    OrganizationAssignment.objects.filter(membership=platform_membership).exclude(
        organization__in=orgs
    ).delete()
    for org in orgs:
        OrganizationAssignment.objects.get_or_create(membership=platform_membership, organization=org)
    audit.record(
        actor=actor, action="staff.assignments_set", organization=platform_membership.organization,
        entity_type="membership", entity_id=platform_membership.pk,
        after={"organization_ids": sorted(organization_ids)}, request=request,
    )
