"""Client IP extraction behind a reverse proxy.

Prod runs behind IIS ARR, which sets X-Forwarded-For to "clientip:port" (and
appends a hop per proxy). The trailing source port is ephemeral - it changes
every connection - so it must be stripped before the value is stored (audit),
matched (dashboard IP fallback), or used as a rate-limit key (login). Left in,
it makes every request look like a different client.

Port stripping is IPv6-aware: a bracketed [2001:db8::1]:443 loses its port; a
bare 2001:db8::1 (many colons, no brackets) is returned untouched.
"""


def strip_port(ipaddress):
    """Return ipaddress without a trailing :port. IPv6-safe. None/empty-safe."""
    if not ipaddress:
        return ipaddress
    ipaddress = ipaddress.strip()
    if ipaddress.startswith('['):
        # [IPv6] or [IPv6]:port -> the address inside the brackets
        return ipaddress[1:].split(']', 1)[0]
    if ipaddress.count(':') == 1:
        # exactly one colon = IPv4:port (a bare IPv6 has several colons)
        return ipaddress.split(':', 1)[0]
    return ipaddress


def client_ip(request_obj):
    """First-hop client IP behind one proxy, port stripped.

    Honors the first X-Forwarded-For hop, else remote_addr. Returns None only
    when neither is present (callers that need a non-null key coerce it).
    """
    forwarded = request_obj.headers.get('X-Forwarded-For')
    if forwarded:
        raw = forwarded.split(',')[0].strip()
    else:
        raw = request_obj.remote_addr
    return strip_port(raw)
