# (c) cavaliba.com - ipam - common.py
# IPV4 only


import ipaddress

from django.conf import settings

from app_data.data import Instance
from app_data.models import DataEAV, DataInstance
from app_home import cache as home_cache


def is_ipv4(data):
    """Return True if data is a valid single IPv4 address (not a range, not IPv6)."""
    try:
        ipobj = ipaddress.ip_address(data)
    except Exception:
        return False
    return ipobj.version == 4


def is_ipv4_subnet(data):
    """Return True if data is a valid IPv4 CIDR range, aligned or not (host bits allowed)."""
    try:
        netobj = ipaddress.ip_network(data, strict=False)
    except Exception:
        return False
    return netobj.version == 4


def align_to_subnet(ipmask_str):
    """
    align ip/mask to subnet boundaries
    IN:  string   10.1.1.1/24
    OUT: string   10.1.1.0/24

    Example usage
    result = align_to_subnet("10.1.1.1/24")
    print(result)  # Output: 10.1.1.0/24
    """
    iface = ipaddress.ip_interface(ipmask_str)
    network_str = f"{iface.network.network_address}/{iface.network.prefixlen}"
    return network_str


def classify_query(query):
    """
    Classify a free-form IPAM search string (top search box, or any view's own "query" param).

    Returns (kind, value):
    - ("empty", "")                  blank/whitespace-only input
    - ("ipv6_unsupported", query)    valid IPv6 address/range - not supported by this IPv4-only feature
    - ("ip", query)                  valid single IPv4 address
    - ("subnet", cidr)               valid IPv4 CIDR range (aligned), or a partial dotted-octet prefix
                                      ("10", "10.2", "10.2.1") zero-padded and given a /8, /16 or /24 mask
    - ("text", query)                anything else - free-text (VLAN) search
    """
    if not query:
        return ("empty", "")

    query = query.strip()
    if not query:
        return ("empty", "")

    # IPv6 (address or range) ?
    try:
        ifaceobj = ipaddress.ip_interface(query)
        if ifaceobj.version != 4:
            return ("ipv6_unsupported", query)
    except Exception:
        pass

    if is_ipv4(query):
        return ("ip", query)

    if is_ipv4_subnet(query):
        return ("subnet", align_to_subnet(query))

    # partial dotted-octet prefix: "10", "10.2", "10.2.", "10.2.1" -> zero-padded subnet
    candidate = query[:-1] if query.endswith(".") else query
    segments = candidate.split(".")
    if 1 <= len(segments) <= 3:
        try:
            octets = [int(s) for s in segments]
        except ValueError:
            octets = None
        if octets is not None and all(0 <= o <= 255 for o in octets):
            padded = octets + [0] * (4 - len(octets))
            prefixlen = len(octets) * 8
            cidr = "{}/{}".format(".".join(str(o) for o in padded), prefixlen)
            return ("subnet", cidr)

    return ("text", query)


# --------------------------------------------------------------------
# IPAM - Subnet index (load-all-in-memory: ipam_subnet is bounded/small)
# --------------------------------------------------------------------


class SubnetRef:
    """
    Lightweight reference to an existing ipam_subnet instance: network/keyname/displayname/
    description only (no bound Instance()/schema fields) - built in bulk from the subnet index,
    used for parent/child subnet lists where a full IpamSubnet() would mean one extra DB query
    per row. Duck-type compatible with IpamSubnet for template use (__str__, .description).
    """

    def __init__(self, network, id, keyname, displayname, description):
        self.network = network
        self.prefixlen = network.prefixlen
        self.id = id
        self.subnet = keyname
        self.displayname = displayname
        self.description = description

    def __str__(self):
        return str(self.subnet)


def get_subnet_index():
    """
    Load every ipam_subnet instance into memory as a list of SubnetRef, sorted by prefixlen
    ascending (largest network first). Cached (app_home.cache.cache2_ipam_subnet_index) since
    ipam_subnet instances are administratively created and bounded - cheap to hold entirely in
    memory, unlike end-host IPs.
    """
    cached = home_cache.cache2_ipam_subnet_index.get("index")
    if cached is not None:
        return cached

    rows = DataInstance.objects.filter(classname="ipam_subnet").values(
        "id", "keyname", "displayname"
    )

    desc_by_keyname = dict(
        DataEAV.objects.filter(classname="ipam_subnet", fieldname="description").values_list(
            "keyname", "value"
        )
    )

    index = []
    for row in rows:
        try:
            network = ipaddress.ip_network(row["keyname"])
        except Exception:
            continue
        if network.version != 4:
            continue
        index.append(
            SubnetRef(
                network,
                row["id"],
                row["keyname"],
                row["displayname"],
                desc_by_keyname.get(row["keyname"], ""),
            )
        )

    index.sort(key=lambda ref: ref.prefixlen)

    home_cache.cache2_ipam_subnet_index.set("index", index)
    return index


def find_containing_subnets(network, index, include_self=False):
    """Existing subnets (SubnetRef) containing `network`, most-specific first (smallest network,
    i.e. highest prefixlen, first). Includes `network` itself only if include_self=True."""

    matches = []
    for ref in index:
        if ref.network == network:
            if include_self:
                matches.append(ref)
            continue
        if network.subnet_of(ref.network):
            matches.append(ref)

    matches.sort(key=lambda ref: ref.prefixlen, reverse=True)
    return matches


def find_child_subnets(network, index):
    """First-rank existing subnets (SubnetRef) strictly contained within `network` - no ancestor
    of theirs (other than `network` itself) is also inside `network`. Replaces the old
    depth-capped recurse_child."""

    contained = [ref for ref in index if ref.network != network and ref.network.subnet_of(network)]

    reply = []
    for ref in contained:
        is_nested = any(
            other.network != ref.network and ref.network.subnet_of(other.network)
            for other in contained
        )
        if not is_nested:
            reply.append(ref)

    return reply


def subnet_size_class(prefixlen):
    """Classify a subnet as "large"/"medium"/"small" for UI display (e.g. the Subnet list),
    thresholds matching this app's own addressing convention: /16-and-wider country
    supernets (large), /17-/24 clinic-level subnets (medium), /25-and-narrower VLANs
    (small)."""

    if prefixlen <= 16:
        return "large"
    if prefixlen <= 24:
        return "medium"
    return "small"


def _constant_prefix(network):
    """Return the dotted-octet prefix string (e.g. "10.11.1.") guaranteed constant across the
    whole network range, or "" if prefixlen < 8 (no useful prefix - full scan)."""

    octets = network.prefixlen // 8
    if octets <= 0:
        return ""
    parts = str(network.network_address).split(".")
    return ".".join(parts[:octets]) + "."


def compute_subnet_occupancy(subnet_cidr, subobj, child_networks):
    """
    Occupancy for a subnet: how many IP-bearing objects (EAV ipv4-format fields, plus curated
    ipam_ip records) sit directly in it (excluding its child subnets).

    subnet_cidr: str CIDR of the target subnet (used as cache key).
    subobj: ipaddress.IPv4Network of the target subnet.
    child_networks: list of ipaddress.IPv4Network already known to exist as child subnets of
        the target (e.g. [ref.network for ref in ipam_subnet.child_subnet], or
        [ref.network for ref in find_child_subnets(subobj, get_subnet_index())]) - excluded
        from the occupancy count/list. Decoupled from IpamSubnet so both the single Subnet
        view and the Subnet list view (looping the cached subnet index, no per-row instance
        lookup) can call this the same way.

    Returns dict: {"computed": bool, "count": int|None, "percent": float|None, "ip_list": [...]}
    "ip_list" entries: {"ip", "classname", "keyname", "displayname"}.

    Skipped (computed=False) for subnets wider than settings.CAVALIBA_IPAM_OCCUPANCY_MINPREFIX,
    to avoid scanning a large fraction of all ipv4-format EAV rows for a country-level (or
    bigger) supernet. Result is cached per subnet (app_home.cache.cache2_ipam_occupancy).
    """

    if subobj.prefixlen < settings.CAVALIBA_IPAM_OCCUPANCY_MINPREFIX:
        return {"computed": False, "count": None, "percent": None, "ip_list": []}

    cachekey = subnet_cidr
    cached = home_cache.cache2_ipam_occupancy.get(cachekey)
    if cached is not None:
        return cached

    prefix = _constant_prefix(subobj)

    eav_filter = {"format": "ipv4"}
    ipamip_filter = {"classname": "ipam_ip"}
    if prefix:
        eav_filter["value__startswith"] = prefix
        ipamip_filter["keyname__startswith"] = prefix

    eav_qs = DataEAV.objects.filter(**eav_filter).values(
        "iid", "classname", "keyname", "displayname", "value"
    )
    ipamip_qs = DataEAV.objects.filter(**ipamip_filter).values(
        "iid", "classname", "keyname", "displayname"
    )

    def in_target_not_child(ip_str):
        try:
            ipobj = ipaddress.ip_address(ip_str)
        except Exception:
            return False
        if ipobj not in subobj:
            return False
        return not any(ipobj in child for child in child_networks)

    seen = set()
    ip_list = []

    for row in eav_qs:
        if row["iid"] in seen:
            continue
        if not in_target_not_child(row["value"]):
            continue
        seen.add(row["iid"])
        ip_list.append(
            {
                "ip": row["value"],
                "classname": row["classname"],
                "keyname": row["keyname"],
                "displayname": row["displayname"],
            }
        )

    for row in ipamip_qs:
        if row["iid"] in seen:
            continue
        if not in_target_not_child(row["keyname"]):
            continue
        seen.add(row["iid"])
        ip_list.append(
            {
                "ip": row["keyname"],
                "classname": row["classname"],
                "keyname": row["keyname"],
                "displayname": row["displayname"],
            }
        )

    count = len(ip_list)
    size = subobj.num_addresses
    # clamp defensively: count should never exceed size, but a display bar/width computed
    # from a percent above 100 would render as (or past) full - never show that
    percent = min(round(count / size * 100, 2), 100.0) if size else 0.0

    result = {"computed": True, "count": count, "percent": percent, "ip_list": ip_list}
    home_cache.cache2_ipam_occupancy.set(cachekey, result)
    return result


# --------------------------------------------------------------------
# IPAM - IP
# --------------------------------------------------------------------


class IpamIP:
    def __init__(self, data=None, set_subnet=False):

        # data is 'A.B.C.D' (string) or an ipaddress object

        self.ip = None  # string / no mask
        self.ipobj = None  # ipaddress object

        self.version = None

        self.db_objects = None  # list of DB object related  to this IP

        self.is_private = None
        # self.is_rfc1918 = None
        self.is_global = None
        self.is_public = None  # == is_global
        self.is_multicast = None
        self.is_unspecified = None
        self.is_reserved = None
        self.is_loopback = None

        # self.fqdn = None

        self.subnet = None  # smallest containing subnet : SubnetRef, or None
        self.parent_subnet = []  # hierarchy (without first) ; small to largest : SubnetRef
        self.child_subnet = []  #  first rank : SubnetRef

        if not data:
            return

        # check valid IPv4
        try:
            ipobj = ipaddress.ip_address(data)
        except Exception:
            return

        if ipobj.version != 4:
            return

        self.ipobj = ipobj
        self.ip = str(ipobj)
        self.version = ipobj.version
        self.is_private = ipobj.is_private
        self.is_multicast = ipobj.is_multicast
        self.is_global = ipobj.is_global
        self.is_public = ipobj.is_global
        self.is_unspecified = ipobj.is_unspecified
        self.is_reserved = ipobj.is_reserved
        self.is_loopback = ipobj.is_loopback

        if set_subnet:
            self.set_subnet()

    def __str__(self):
        return str(self.ip)

    def print(self):

        print("IP: ", self)
        print(f"- private: {self.is_private}")
        print(f"- subnet : {self.subnet}")
        print("Parent subnets:")
        for i in self.parent_subnet:
            print("SUBNET: ", i)
        print("Child subnets:")
        for i in self.child_subnet:
            print("SUBNET: ", i)

    def set_subnet(self):
        """Find the smallest containing subnet, its ancestors and (if found) its direct
        children, using the in-memory subnet index (get_subnet_index) instead of a per-level
        DB-query climb."""

        network = ipaddress.ip_network(self.ipobj, strict=False)  # /32

        index = get_subnet_index()
        matches = find_containing_subnets(network, index, include_self=True)
        if not matches:
            return

        self.subnet = matches[0]
        self.parent_subnet = matches[1:]
        self.child_subnet = find_child_subnets(self.subnet.network, index)

    def get_related_objects(self):
        """
        Objects at this exact IP: EAV rows whose ipv4-format field equals this IP, plus curated
        ipam_ip records keyed by this IP (which have no ipv4-format field of their own - see
        common.py's classify_query docstring / the IPAM redesign plan, Bug #3). Deduped by iid.
        """

        eav_hits = DataEAV.objects.filter(format="ipv4", value=self.ip)
        ipamip_hits = DataEAV.objects.filter(classname="ipam_ip", keyname=self.ip)

        seen = set()
        reply = []
        for row in list(eav_hits) + list(ipamip_hits):
            if row.iid in seen:
                continue
            seen.add(row.iid)
            reply.append(row)

        return reply


# --------------------------------------------------------------------
# IPAM - Subnet
# --------------------------------------------------------------------


class IpamSubnet:
    def __init__(self, data=None, set_subnet=False):

        self.subnet = None  # string repr 'A.B.C.D/E'
        self.subobj = None  # ipaddress object

        self.instance = None  # Schema Instance() ; if None, doesn't exist in DB
        self.description = ""

        self.netmask = None  # from ipaddress
        self.prefixlen = None
        self.broadcast = None
        self.size = None
        self.first = None
        self.last = None

        self.gateway = None  # from DB / Instance()
        self.vlan = None
        self.site = None
        # dhcp, nac, ...

        self.parent_subnet = []  # hierarchy (without first) ; small to largest : SubnetRef
        self.child_subnet = []  #  first rank : SubnetRef

        # align unaligned input ("10.1.2.34/24") before parsing, so search never silently fails
        if isinstance(data, str):
            try:
                data = align_to_subnet(data)
            except Exception:
                return

        try:
            subnet = ipaddress.ip_network(data)
        except Exception:
            return

        if subnet.version != 4:
            return

        self.subnet = str(subnet)
        self.subobj = subnet

        self.netmask = subnet.netmask
        self.prefixlen = subnet.prefixlen
        self.broadcast = subnet.broadcast_address
        self.size = subnet.num_addresses
        self.first = str(subnet[0])
        self.last = str(subnet[-1])

        keyname = str(subnet)
        instance = Instance.from_keyname(classname="ipam_subnet", keyname=keyname)
        if not instance:
            return
        if instance.is_bound:
            self.instance = instance
            try:
                self.description = instance.fields["description"].get_first_value()
            except Exception:
                pass
        else:
            self.instance = None
            return

        if set_subnet:
            self.set_subnet()

    def __str__(self):
        return str(self.subnet)

    def print(self):
        print("SUBNET: ", self)

    def set_subnet(self):
        """Find this subnet's ancestors and direct children, using the in-memory subnet index
        (get_subnet_index) instead of a per-level DB-query climb / depth-capped recursion."""

        index = get_subnet_index()
        self.parent_subnet = find_containing_subnets(self.subobj, index, include_self=False)
        self.child_subnet = find_child_subnets(self.subobj, index)


# --------------------------------------------------------------------
# IPAM - VLAN
# --------------------------------------------------------------------


class IpamVLAN:
    def __init__(self, data=None):

        self.vlan = None  # keyname
        self.instance = None

        self.displayname = ""
        self.description = ""
        self.vlan_id = None
        self.vlan_family = None
        self.subnet = []  # list of IpamSubnet, one per linked ipam_subnet keyname

        if not data:
            return

        instance = Instance.from_keyname(classname="ipam_vlan", keyname=data)
        if not instance or not instance.is_bound:
            return

        self.instance = instance
        self.vlan = instance.keyname
        self.displayname = instance.displayname or instance.keyname

        try:
            self.description = instance.fields["description"].get_first_value()
        except Exception:
            pass

        try:
            self.vlan_id = instance.fields["vlan_id"].get_first_value()
        except Exception:
            pass

        try:
            self.vlan_family = instance.fields["vlan_family"].get_first_value()
        except Exception:
            pass

        try:
            subnet_keynames = instance.fields["subnet"].get_value()
        except Exception:
            subnet_keynames = []

        for keyname in subnet_keynames:
            subnetobj = IpamSubnet(keyname)
            if subnetobj.subnet:
                self.subnet.append(subnetobj)

    def __str__(self):
        return str(self.vlan)
