"""Where may people check in and out from?

A check-in / check-out is evaluated against the organization's LocationPolicy:

  * policy off, or check-out not covered, or the person is exempt today  -> accepted
  * the request comes from an allowed office network                      -> accepted
  * a browser location inside an active work place (accurate enough)      -> accepted
  * a location was sent but it is outside / too inaccurate / nothing could be verified
                                                                          -> recorded, **pending a manager's approval**
  * no location at all and no allowed network                             -> refused (LocationRequiredError)

The distance and the network are computed here, never trusted from the client. The client address
comes from REMOTE_ADDR; behind a reverse proxy set ATTENDANCE_TRUSTED_PROXY_HOPS so the right
X-Forwarded-For entry is used (a client-supplied header is otherwise ignored, it can be forged).
"""
from __future__ import annotations

import datetime
import ipaddress
import math
from dataclasses import dataclass

from django.conf import settings
from django.db import transaction
from django.utils import timezone
from rest_framework.exceptions import NotFound, ValidationError

from apps.audit import services as audit
from apps.organizations.models import OrganizationMembership
from apps.workplace.models import AllowedNetwork, LocationExemption, LocationPolicy, PunchLog, WorkPlace
from domain.exceptions import DomainError


class LocationRequiredError(DomainError):
    """The organization requires proof of presence and none was given."""


class PunchStateError(DomainError):
    pass


class Verdict:
    NOT_REQUIRED = "not_required"
    EXEMPT = "exempt"
    NETWORK = "network"
    VERIFIED = "verified"
    OUTSIDE = "outside"
    LOW_ACCURACY = "low_accuracy"
    UNVERIFIABLE = "unverifiable"  # policy needs a place / network that is not configured


@dataclass
class Decision:
    verdict: str
    needs_approval: bool = False
    distance_m: float | None = None
    place_name: str = ""


# ---------------------------------------------------------------- helpers
def client_ip(request) -> str:
    """The caller's address: REMOTE_ADDR, or the entry added by our own proxy chain when configured."""
    hops = int(getattr(settings, "ATTENDANCE_TRUSTED_PROXY_HOPS", 0) or 0)
    if hops > 0:
        parts = [p.strip() for p in request.META.get("HTTP_X_FORWARDED_FOR", "").split(",") if p.strip()]
        if len(parts) >= hops:
            return parts[-hops]
    return request.META.get("REMOTE_ADDR", "") or ""


def distance_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    """Great-circle distance in meters (haversine)."""
    r = 6_371_000
    p1, p2 = math.radians(lat1), math.radians(lat2)
    dp, dl = p2 - p1, math.radians(lon2 - lon1)
    a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
    return 2 * r * math.asin(math.sqrt(a))


def get_policy(org) -> LocationPolicy:
    return LocationPolicy.objects.get_or_create(organization=org)[0]


def is_exempt(org, user, day: datetime.date) -> bool:
    for ex in LocationExemption.objects.filter(organization=org, user=user):
        if (ex.start_date is None or ex.start_date <= day) and (ex.end_date is None or day <= ex.end_date):
            return True
    return False


def ip_allowed(org, ip: str) -> bool:
    try:
        address = ipaddress.ip_address(ip)
    except ValueError:
        return False
    for net in AllowedNetwork.objects.filter(organization=org):
        try:
            if address in ipaddress.ip_network(net.cidr, strict=False):
                return True
        except ValueError:
            continue
    return False


def requirement(org, user, request, day: datetime.date | None = None) -> dict:
    """What the client needs to do before punching (used by the UI to decide whether to ask for a location)."""
    day = day or datetime.date.today()
    policy = get_policy(org)
    exempt = policy.enabled and is_exempt(org, user, day)
    network_ok = policy.enabled and policy.use_network and ip_allowed(org, client_ip(request))
    active = policy.enabled and not exempt
    return {
        "enabled": policy.enabled,
        "exempt": exempt,
        "network_ok": network_ok,
        "needs_location": bool(active and not network_ok),
        "use_location": policy.use_location,
        "max_accuracy_m": policy.max_accuracy_m,
        "apply_to_checkout": policy.apply_to_checkout,
    }


# ---------------------------------------------------------------- the decision
def evaluate(org, user, request, *, kind: str, latitude, longitude, accuracy) -> Decision:
    policy = get_policy(org)
    today = datetime.date.today()
    if not policy.enabled or (kind == "out" and not policy.apply_to_checkout):
        return Decision(Verdict.NOT_REQUIRED)
    if is_exempt(org, user, today):
        return Decision(Verdict.EXEMPT)
    if policy.use_network and ip_allowed(org, client_ip(request)):
        return Decision(Verdict.NETWORK)

    has_position = latitude is not None and longitude is not None
    if not has_position:
        raise LocationRequiredError("Your location is required to check in or out. Allow location access and try again.")
    if not policy.use_location:
        return Decision(Verdict.OUTSIDE, needs_approval=True)

    places = list(WorkPlace.objects.filter(organization=org, is_active=True))
    if not places:
        return Decision(Verdict.UNVERIFIABLE, needs_approval=True)
    nearest = min(places, key=lambda p: distance_m(latitude, longitude, p.latitude, p.longitude))
    dist = distance_m(latitude, longitude, nearest.latitude, nearest.longitude)
    if accuracy is None or accuracy > policy.max_accuracy_m:
        return Decision(Verdict.LOW_ACCURACY, needs_approval=True, distance_m=dist, place_name=nearest.name)
    # accuracy is already capped by the policy; the measured point must lie inside the work place
    if dist <= nearest.radius_m:
        return Decision(Verdict.VERIFIED, distance_m=dist, place_name=nearest.name)
    return Decision(Verdict.OUTSIDE, needs_approval=True, distance_m=dist, place_name=nearest.name)


def record_punch(org, user, request, *, kind, at: datetime.datetime, decision: Decision, latitude, longitude, accuracy) -> PunchLog | None:
    """Keep the evidence. Nothing is stored when the policy is off (no behaviour change for those organizations)."""
    if decision.verdict == Verdict.NOT_REQUIRED:
        return None
    return PunchLog.objects.create(
        organization=org, user=user, kind=kind, date=at.date(), time=at.time().replace(microsecond=0),
        latitude=latitude, longitude=longitude, accuracy_m=accuracy, ip_address=client_ip(request),
        distance_m=None if decision.distance_m is None else round(decision.distance_m, 1),
        place_name=decision.place_name, verdict=decision.verdict,
        status="pending" if decision.needs_approval else "approved",
    )


# ---------------------------------------------------------------- manager: review
def review_punch(ctx, pk: int, *, approve: bool, note: str = "", request=None) -> PunchLog:
    punch = PunchLog.objects.filter(organization=ctx.organization, pk=pk).select_related("user").first()
    if punch is None:
        raise NotFound()
    note = (note or "").strip()
    if len(note) > 500:
        raise ValidationError({"note": "The note must be at most 500 characters."})
    if not approve and not note:
        raise ValidationError({"note": "A reason is required to reject."})
    if punch.status != "pending":
        raise PunchStateError("This check-in/out was already reviewed.")
    with transaction.atomic():
        punch.status = "approved" if approve else "rejected"
        punch.reviewed_by, punch.reviewed_at, punch.review_note = ctx.user, timezone.now(), note
        punch.save()
        if not approve:  # the rejected time does not count: remove it from the day's record
            from infrastructure.models import Attendance

            row = Attendance.objects.filter(organization=ctx.organization, user=punch.user, date=punch.date).first()
            if row is not None:
                if punch.kind == "in" and row.check_in == punch.time:
                    row.check_in = None
                elif punch.kind == "out" and row.check_out == punch.time:
                    row.check_out, row.is_default_checkout, row.tasks_note = None, False, None
                row.save()
    audit.record(
        actor=ctx.user, action="punch.approve" if approve else "punch.reject", organization=ctx.organization,
        entity_type="punch", entity_id=punch.pk, after={"note": note}, request=request,
    )
    return punch


# ---------------------------------------------------------------- manager: settings
def update_policy(ctx, *, data: dict, request=None) -> LocationPolicy:
    policy = get_policy(ctx.organization)
    for field in ("enabled", "use_location", "use_network", "apply_to_checkout"):
        if field in data:
            setattr(policy, field, bool(data[field]))
    if "max_accuracy_m" in data:
        if not 20 <= int(data["max_accuracy_m"]) <= 2000:
            raise ValidationError({"max_accuracy_m": "Must be between 20 and 2000 meters."})
        policy.max_accuracy_m = int(data["max_accuracy_m"])
    policy.save()
    audit.record(actor=ctx.user, action="location.policy_update", organization=ctx.organization, entity_type="location_policy", entity_id=policy.pk, after={k: str(v) for k, v in data.items()}, request=request)
    return policy


def _check_place(data: dict) -> None:
    if "latitude" in data and not -90 <= float(data["latitude"]) <= 90:
        raise ValidationError({"latitude": "Latitude must be between -90 and 90."})
    if "longitude" in data and not -180 <= float(data["longitude"]) <= 180:
        raise ValidationError({"longitude": "Longitude must be between -180 and 180."})
    if "radius_m" in data and not 20 <= int(data["radius_m"]) <= 5000:
        raise ValidationError({"radius_m": "The radius must be between 20 and 5000 meters."})
    if "name" in data and not str(data["name"]).strip():
        raise ValidationError({"name": "A name is required."})


def create_place(ctx, *, data: dict, request=None) -> WorkPlace:
    _check_place({"name": data.get("name", ""), **data})
    if "latitude" not in data or "longitude" not in data:
        raise ValidationError({"latitude": "Latitude and longitude are required."})
    place = WorkPlace.objects.create(
        organization=ctx.organization, name=data["name"].strip(), latitude=data["latitude"], longitude=data["longitude"],
        radius_m=data.get("radius_m", 150),
    )
    audit.record(actor=ctx.user, action="location.place_create", organization=ctx.organization, entity_type="work_place", entity_id=place.pk, request=request)
    return place


def update_place(ctx, pk: int, *, data: dict, request=None) -> WorkPlace:
    place = WorkPlace.objects.filter(organization=ctx.organization, pk=pk).first()
    if place is None:
        raise NotFound()
    _check_place(data)
    for field in ("name", "latitude", "longitude", "radius_m", "is_active"):
        if field in data:
            setattr(place, field, data[field].strip() if field == "name" else data[field])
    place.save()
    audit.record(actor=ctx.user, action="location.place_update", organization=ctx.organization, entity_type="work_place", entity_id=place.pk, request=request)
    return place


def delete_place(ctx, pk: int, *, request=None) -> None:
    deleted, _ = WorkPlace.objects.filter(organization=ctx.organization, pk=pk).delete()
    if not deleted:
        raise NotFound()
    audit.record(actor=ctx.user, action="location.place_delete", organization=ctx.organization, entity_type="work_place", entity_id=pk, request=request)


def add_network(ctx, *, label: str, cidr: str, request=None) -> AllowedNetwork:
    try:
        network = ipaddress.ip_network(cidr.strip(), strict=False)
    except ValueError:
        raise ValidationError({"cidr": "Enter a valid IP address or network, e.g. 5.6.7.8 or 5.6.7.0/24."})
    if network.num_addresses > 2**16 and network.version == 4:
        raise ValidationError({"cidr": "That network is too large; use /16 or smaller."})
    if not str(label).strip():
        raise ValidationError({"label": "A label is required."})
    row = AllowedNetwork.objects.create(organization=ctx.organization, label=label.strip(), cidr=str(network) if "/" in cidr else cidr.strip())
    audit.record(actor=ctx.user, action="location.network_add", organization=ctx.organization, entity_type="allowed_network", entity_id=row.pk, after={"cidr": row.cidr}, request=request)
    return row


def delete_network(ctx, pk: int, *, request=None) -> None:
    deleted, _ = AllowedNetwork.objects.filter(organization=ctx.organization, pk=pk).delete()
    if not deleted:
        raise NotFound()
    audit.record(actor=ctx.user, action="location.network_delete", organization=ctx.organization, entity_type="allowed_network", entity_id=pk, request=request)


def add_exemption(ctx, *, user_id: int, start_date, end_date, reason: str, request=None) -> LocationExemption:
    member = OrganizationMembership.objects.filter(organization=ctx.organization, user_id=user_id, status="active").first()
    if member is None:
        raise ValidationError({"user": "The person is not an active member of this organization."})
    if start_date and end_date and end_date < start_date:
        raise ValidationError({"end_date": "The end date is before the start date."})
    row = LocationExemption.objects.create(
        organization=ctx.organization, user_id=user_id, start_date=start_date, end_date=end_date,
        reason=(reason or "").strip()[:200], created_by=ctx.user,
    )
    audit.record(actor=ctx.user, action="location.exemption_add", organization=ctx.organization, entity_type="location_exemption", entity_id=row.pk, after={"user": user_id}, request=request)
    return row


def delete_exemption(ctx, pk: int, *, request=None) -> None:
    deleted, _ = LocationExemption.objects.filter(organization=ctx.organization, pk=pk).delete()
    if not deleted:
        raise NotFound()
    audit.record(actor=ctx.user, action="location.exemption_delete", organization=ctx.organization, entity_type="location_exemption", entity_id=pk, request=request)
