"""Tool to analyze and fix IPA ID ranges"""
#
# Copyright (C) 2024  FreeIPA Contributors see COPYING for license
#

import logging
import ldap

from ipalib import api, errors
from ipapython.admintool import AdminTool
from ipapython.dn import DN
from ipapython import ipautil
from ipaserver.install.installutils import check_server_configuration
from typing import List, Tuple

logger = logging.getLogger(__name__)


class IDRange:
    """Class for ID range entity"""

    def __init__(self):
        self.last_id: int = None
        self.last_base_rid: int = None
        self.last_secondary_rid: int = None
        self.name: str = None
        self.size: int = None
        self.first_id: int = None
        self.base_rid: int = None
        self.secondary_base_rid: int = None
        self.type: str = None
        self.suffix: str = None
        self.dn: str = None

    def _count(self) -> None:
        """Function to calculate last IDs for the range"""
        self.last_id = self.first_id + self.size - 1
        if self.type == "ipa-local":
            self.last_base_rid = (
                self.base_rid + self.size
                if self.base_rid is not None
                else None
            )
            self.last_secondary_rid = (
                self.secondary_base_rid + self.size
                if self.secondary_base_rid is not None
                else None
            )

    def __repr__(self):
        return (
            f"IDRange(name='{self.name}', "
            f"type={self.type}, "
            f"size={self.size}, "
            f"first_id={self.first_id}, "
            f"base_rid={self.base_rid}, "
            f"secondary_base_rid={self.secondary_base_rid})"
        )

    def __eq__(self, other):
        return self.first_id == other.first_id


class IDentity:
    """A generic class for ID entity - users or groups"""

    def __init__(self, **kwargs):
        self.dn: str = kwargs.get('dn')
        self.name: str = kwargs.get('name')
        self.user: str = kwargs.get('user')
        self.number: int = kwargs.get('number')

    def __str__(self):
        if self.user:
            return (f"user '{self.name}', uid={self.number}")
        return (f"group '{self.name}', gid={self.number}")

    def debug(self):
        if self.user:
            return (
                f"user(username='{self.name}', "
                f"uid={self.number}, "
                f"{self.dn})"
            )
        return (
            f"group(groupname='{self.name}', "
            f"gid={self.number}, "
            f"{self.dn})"
        )

    def __eq__(self, other):
        return self.number == other.number and self.user == other.user


class IPAIDRangeFix(AdminTool):
    """Tool to analyze and fix IPA ID ranges"""

    command_name = "ipa-idrange-fix"
    log_file_name = "/var/log/ipa-idrange-fix.log"
    usage = "%prog"
    description = "Analyze and fix IPA ID ranges"

    @classmethod
    def add_options(cls, parser, debug_option=False):
        super(IPAIDRangeFix, cls).add_options(parser)
        parser.add_option(
            "--ridoffset",
            dest="ridoffset",
            type=int,
            default=100000,
            metavar=100000,
            help="Offset for a next base RID from previous RID range. \
Needed for future range size expansions. Has to be > 0",
        )
        parser.add_option(
            "--rangegap",
            dest="rangegap",
            type=int,
            default=200000,
            metavar=200000,
            help="Threshold for a gap between out-of-range IDs to be \
considered a different range. Has to be > 0",
        )
        parser.add_option(
            "--minrange",
            dest="minrange",
            type=int,
            default=10,
            metavar=10,
            help="Minimal considered range size for out-of-range IDs.\
All ranges with amount of IDs lower than this number will be discarded and \
IDs will be listed to be moved manually. Has to be > 1",
        )
        parser.add_option(
            "--allowunder1000",
            dest="allowunder1000",
            action="store_true",
            default=False,
            help="Allow idranges to start below 1000. Be careful to not \
overlap IPA users/groups with existing system-local ones!",
        )
        parser.add_option(
            "--norounding",
            dest="norounding",
            action="store_true",
            default=False,
            help="Disable IDrange rounding attempt in order to get ranges \
exactly covering just IDs provided",
        )
        parser.add_option(
            "--unattended",
            dest="unattended",
            action="store_true",
            default=False,
            help="Automatically fix all range issues found without asking \
for confirmation",
        )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.realm: str = None
        self.suffix: DN = None
        self.proposals_rid: List[IDRange] = []
        self.proposals_new: List[IDRange] = []
        self.outliers: List[IDentity] = []
        self.under1000: List[IDentity] = []
        self.id_ranges: List[IDRange] = []

    def validate_options(self, needs_root=True):
        super().validate_options(needs_root)

    def run(self):
        check_server_configuration()

        api.bootstrap(in_server=True)
        api.finalize()

        self.realm = api.env.realm
        self.suffix = ipautil.realm_to_suffix(self.realm)
        try:
            api.Backend.ldap2.connect()

            # Reading range data
            self.id_ranges = read_ranges(self.suffix)

            # Evaluating existing ranges, if something is off, exit
            if self.evaluate_ranges() != 0:
                return 1

            # reading out of range IDs
            ids_out_of_range = read_outofrange_identities(
                self.suffix, self.id_ranges
            )

            # Evaluating out of range IDs
            self.evaluate_identities(ids_out_of_range)

            # Print the proposals
            self.print_intentions()

            # If there are no proposals, we have nothing to do, exiting
            if (len(self.proposals_rid) == 0
                    and len(self.proposals_new) == 0):
                logger.info("\nNo changes proposed, nothing to do.")
                return 0

            logger.info("\nID ranges table after proposed changes:")
            draw_ascii_table(self.id_ranges)

            if self.options.unattended:
                logger.info(
                    "Unattended mode, proceeding with applying changes!"
                )
            else:
                response = ipautil.user_input('Enter "yes" to proceed')
                if response.lower() != "yes":
                    logger.info("Not proceeding.")
                    return 0
                logger.info("Proceeding.")

            # Applying changes
            for id_range in self.proposals_rid:
                apply_ridbases(id_range)

            for id_range in self.proposals_new:
                create_range(id_range)

            logger.info("All changes applied successfully!")

        finally:
            if api.Backend.ldap2.isconnected():
                api.Backend.ldap2.disconnect()

        return 0

    def evaluate_ranges(self) -> int:
        """Function to evaluate existing ID ranges"""
        if len(self.id_ranges) == 0:
            logger.error("No ID ranges found!")
            return 1

        draw_ascii_table(self.id_ranges)

        if not ranges_overlap_check(self.id_ranges):
            logger.error(
                "Ranges overlap detected, cannot proceed! Please adjust \
existing ranges manually."
            )
            return 1

        # Checking RID bases for existing ranges
        id_ranges_nobase = get_ranges_no_base(self.id_ranges)

        if len(id_ranges_nobase) > 0:
            logger.info(
                "Found %s ranges without base RIDs", len(id_ranges_nobase)
            )
            for id_range in id_ranges_nobase:
                logger.debug(
                    "Range '%s' has RID base %s and secondary RID base %s",
                    id_range.name,
                    id_range.base_rid,
                    id_range.secondary_base_rid,
                )
            propose_rid_ranges(
                self.id_ranges,
                self.options.ridoffset,
                self.proposals_rid
            )
        else:
            logger.info(
                "All ID ranges have base RIDs set, RID adjustments are \
not needed."
            )
        return 0

    def evaluate_identities(self, ids_out_of_range: List[IDentity]) -> None:
        """Function to evaluate out of range IDs"""
        if len(ids_out_of_range) == 0:
            logger.info("No out of range IDs found!")
        else:
            logger.info(
                "Found overall %s IDs out of existing ID ranges.\n",
                len(ids_out_of_range),
            )
            # ruling out IDs under 1000 if flag is not set
            if not self.options.allowunder1000:
                self.under1000, ids_out_of_range = separate_under1000(
                    ids_out_of_range
                )
                if len(self.under1000) > 0:
                    logger.info(
                        "Found IDs under 1000, which is not recommended \
(if you definitely need ranges proposed for those, use --allowunder1000):"
                    )
                    for identity in self.under1000:
                        logger.info("%s", identity)

            # Get initial divide of IDs into groups
            groups = group_identities_by_threshold(
                ids_out_of_range, self.options.rangegap
            )

            # Get outliers from too small groups and clean groups for
            # further processing
            self.outliers, cleangroups = separate_ranges_and_outliers(
                groups, self.options.minrange
            )

            # Print the outliers, they have to be moved manually
            if len(self.outliers) > 0:
                logger.info(
                    "\nIdentities that don't fit the criteria to get a new "
                    "range found! Current attributes:\n"
                    "Minimal range size: %s\n"
                    "Maximum gap between IDs: %s\n"
                    "Try adjusting --minrange, --rangegap or move the "
                    "following identities into already existing ranges:",
                    self.options.minrange,
                    self.options.rangegap
                )
                for identity in self.outliers:
                    logger.info("%s", identity)

            if len(cleangroups) > 0:
                # Get IDrange name base
                basename = get_rangename_base(self.id_ranges)

                # Create proposals for new ranges from groups
                for group in cleangroups:
                    newrange = propose_range(
                        group,
                        self.id_ranges,
                        self.options.ridoffset,
                        basename,
                        self.options.norounding,
                        self.options.allowunder1000
                    )
                    if newrange is not None:
                        self.proposals_new.append(newrange)
                        self.id_ranges.append(newrange)
                        self.id_ranges.sort(key=lambda x: x.first_id)
            else:
                logger.info(
                    "\nNo IDs fit the criteria for a new ID range to propose!"
                )

    def print_intentions(self) -> None:
        """Function to print out the summary of the proposed changes"""
        logger.info("\nSummary:")

        if len(self.outliers) > 0:
            logger.info("Outlier IDs that are too far away to get a range:")
            for identity in self.outliers:
                logger.info("%s", identity)

        if len(self.under1000) > 0:
            if self.options.allowunder1000:
                logger.info("IDs under 1000 were treated like normal IDs.")
            else:
                logger.info("IDs under 1000:")
                for identity in self.under1000:
                    logger.info("%s", identity)
        else:
            logger.info("No IDs under 1000 found.")

        if len(self.proposals_rid) > 0:
            logger.info("Proposed changes to existing ranges:")
            for id_range in self.proposals_rid:
                logger.info(
                    "Range '%s' - base RID: %s, secondary base RID: %s",
                    id_range.name,
                    id_range.base_rid,
                    id_range.secondary_base_rid,
                )
        else:
            logger.info("No changes proposed for existing ranges.")

        if len(self.proposals_new) > 0:
            logger.info("Proposed new ranges:")
            for id_range in self.proposals_new:
                logger.info("%s", id_range)
        else:
            logger.info("No new ranges proposed.")

# Working with output
# region


def draw_ascii_table(id_ranges: List[IDRange], stdout: bool = False) -> None:
    """Function to draw a table with ID ranges in ASCII"""
    table: str = "\n"
    # Calculate the maximum width required for each column using column names
    max_widths = {
        column: max(
            len(str(column)),
            max(
                (
                    len(str(getattr(id_range, column)))
                    if getattr(id_range, column) is not None
                    else 0
                )
                for id_range in id_ranges
            ),
        )
        for column in [
            "name",
            "type",
            "size",
            "first_id",
            "last_id",
            "base_rid",
            "last_base_rid",
            "secondary_base_rid",
            "last_secondary_rid",
        ]
    }

    # Draw the table header
    header = "| "
    for column, width in max_widths.items():
        header += f"{column.ljust(width)} | "
    horizontal_line = "-" * (len(header) - 1)
    table += horizontal_line + "\n"
    table += header + "\n"
    table += horizontal_line + "\n"

    # Draw the table rows
    for id_range in id_ranges:
        row = "| "
        for column, width in max_widths.items():
            value = getattr(id_range, column)
            if value is not None:
                row += f"{str(value).rjust(width)} | "
            else:
                # Adding the separator
                row += " " * (width + 1) + "| "
        table += row + "\n"
    table += horizontal_line + "\n"
    if stdout:
        print(table)
    else:
        logger.info(table)
# endregion
# Reading from LDAP
# region


def read_ranges(suffix) -> List[IDRange]:
    """Function to read ID ranges from LDAP"""
    id_ranges: IDRange = []
    try:
        ranges = api.Backend.ldap2.get_entries(
            DN(api.env.container_ranges, suffix),
            ldap.SCOPE_ONELEVEL,
            "(objectclass=ipaIDRange)",
        )
    except errors.NotFound:
        logger.error("LDAPError: No ranges found!")
    except errors.ExecutionError as e:
        logger.error("Exception while reading users: %s", e)
    else:
        for entry in ranges:
            sv = entry.single_value
            id_range = IDRange()
            id_range.name = sv.get("cn")
            id_range.size = int(sv.get("ipaidrangesize"))
            id_range.first_id = int(sv.get("ipabaseid"))
            id_range.base_rid = (
                int(sv.get("ipabaserid")) if sv.get("ipabaserid") else None
            )
            id_range.secondary_base_rid = (
                int(sv.get("ipasecondarybaserid"))
                if sv.get("ipasecondarybaserid")
                else None
            )
            id_range.suffix = suffix
            id_range.type = sv.get("iparangetype")
            id_range.dn = entry.dn

            id_range._count()
            logger.debug("ID range found: %s", id_range)

            id_ranges.append(id_range)

        id_ranges.sort(key=lambda x: x.first_id)
    return id_ranges


def read_outofrange_identities(suffix, id_ranges) -> List[IDentity]:
    """Function to read out of range users and groups from LDAP"""
    users_outofrange = read_ldap_ids(
        DN(api.env.container_user, suffix),
        True,
        id_ranges
    )
    logger.info("Users out of range found: %s", len(users_outofrange))
    del_outofrange = read_ldap_ids(
        DN(api.env.container_deleteuser, suffix),
        True,
        id_ranges
    )
    logger.info("Preserved users out of range found: %s", len(del_outofrange))
    groups_outofrange = read_ldap_ids(
        DN(api.env.container_group, suffix),
        False,
        id_ranges
    )
    logger.info("Groups out of range found: %s", len(groups_outofrange))
    outofrange = users_outofrange + del_outofrange + groups_outofrange
    outofrange.sort(key=lambda x: x.number)
    return outofrange


def read_ldap_ids(container_dn, user: bool, id_ranges) -> List[IDentity]:
    """Function to read IDs from containter in LDAP"""
    id_entities = []
    if user:
        id_name = "user"
        ldap_filter = get_outofrange_filter(
            id_ranges,
            "posixaccount",
            "uidNumber"
        )
    else:
        id_name = "group"
        ldap_filter = get_outofrange_filter(
            id_ranges,
            "posixgroup",
            "gidNumber"
        )

    logger.debug("Searching %ss in %s with filter: %s", id_name, container_dn,
                 ldap_filter)
    try:
        identities = api.Backend.ldap2.get_entries(
            container_dn,
            ldap.SCOPE_ONELEVEL,
            ldap_filter,
        )
        for entry in identities:
            id_entities.append(read_identity(entry, user))
    except errors.NotFound:
        logger.debug("No out of range %ss found in %s!", id_name, container_dn)
    except errors.ExecutionError as e:
        logger.error("Exception while reading %s: %s", container_dn, e)
    return id_entities


def read_identity(ldapentry, user: bool = True) -> IDentity:
    """Function to convert LDAP entry to IDentity object"""
    sv = ldapentry.single_value
    id_entity = IDentity()
    id_entity.dn = ldapentry.dn
    id_entity.name = sv.get("cn")
    id_entity.number = (
        int(sv.get("uidNumber")) if user else int(sv.get("gidNumber"))
    )
    id_entity.user = user
    logger.debug("Out of range found: %s", id_entity.debug())
    return id_entity


def get_outofrange_filter(
    id_ranges_all: List[IDRange], object_class: str, posix_id: str
) -> str:
    """Function to create LDAP filter for out of range users and groups"""
    # we need to look only for ipa-local ranges
    id_ranges = get_ipa_local_ranges(id_ranges_all)

    ldap_filter = f"(&(objectClass={object_class})(|"

    # adding gaps in ranges to the filter
    for i in range(len(id_ranges) + 1):
        if i == 0:
            start_condition = f"({posix_id}>=1)"
        else:
            start_condition = f"({posix_id}>={id_ranges[i - 1].last_id + 1})"

        if i < len(id_ranges):
            end_condition = f"({posix_id}<={id_ranges[i].first_id - 1})"
        else:
            end_condition = f"({posix_id}<=2147483647)"

        ldap_filter += f"(&{start_condition}{end_condition})"

    ldap_filter += "))"

    return ldap_filter
# endregion
# Writing to LDAP
# region


def apply_ridbases(id_range: IDRange) -> None:
    """Funtion to apply RID bases to the range in LDAP"""
    try:
        api.Backend.ldap2.modify_s(
            id_range.dn,
            [
                (ldap.MOD_ADD, "ipaBaseRID", str(id_range.base_rid)),
                (
                    ldap.MOD_ADD,
                    "ipaSecondaryBaseRID",
                    str(id_range.secondary_base_rid),
                ),
            ],
        )
        logger.info("RID bases updated for range '%s'", id_range.name)

    except ldap.CONSTRAINT_VIOLATION as e:
        logger.error(
            "Failed to add RID bases to the range '%s': %s",
            id_range.name,
            e
        )
        raise RuntimeError("Constraint violation.\n") from e

    except Exception as e:
        logger.error(
            "Exception while updating RID bases for range '%s': %s",
            id_range.name,
            e,
        )
        raise RuntimeError("Failed to update RID bases.\n") from e


def create_range(id_range: IDRange) -> None:
    """Function to create a new range in LDAP"""
    try:
        logger.info("Creating range '%s'...", id_range.name)

        entry = api.Backend.ldap2.make_entry(
            DN(id_range.dn),
            objectclass=["ipaIDRange", "ipaDomainIDRange"],
            ipaidrangesize=[str(id_range.size)],
            ipabaseid=[str(id_range.first_id)],
            ipabaserid=[str(id_range.base_rid)],
            ipasecondarybaserid=[str(id_range.secondary_base_rid)],
            iparangetype=[id_range.type],
        )

        api.Backend.ldap2.add_entry(entry)
        logger.info("Range '%s' created successfully", id_range.name)
    except Exception as e:
        logger.error(
            "Exception while creating range '%s': %s",
            id_range.name,
            e
        )
        raise RuntimeError("Failed to create range.\n") from e
# endregion
# Working with ranges
# region


def get_ipa_local_ranges(id_ranges: List[IDRange]) -> List[IDRange]:
    """Function to get only ipa-local ranges from the list of ranges"""
    ipa_local_ranges = []

    for id_range in id_ranges:
        if id_range.type == "ipa-local":
            ipa_local_ranges.append(id_range)

    return ipa_local_ranges


def range_overlap_check(
    range1_start: int, range1_end: int, range2_start: int, range2_end: int
) -> bool:
    """Function to check if two ranges overlap"""
    # False when overlapping
    return not (range1_start <= range2_end and range2_start <= range1_end)


def range_overlap_check_idrange(range1: IDRange, range2: IDRange) -> bool:
    """Function to check if two ranges overlap"""
    # False when overlapping
    return range_overlap_check(
        range1.first_id, range1.last_id, range2.first_id, range2.last_id)


def newrange_overlap_check(
    id_ranges: List[IDRange], newrange: IDRange
) -> bool:
    """Function to check if proposed range overlaps with existing ones"""
    for id_range in id_ranges:
        if not range_overlap_check_idrange(id_range, newrange):
            return False
    return True


def ranges_overlap_check(id_ranges: List[IDRange]) -> bool:
    """Function to check if any of the existing ranges overlap"""
    if len(id_ranges) < 2:
        return True
    for i in range(len(id_ranges) - 1):
        for j in range(i + 1, len(id_ranges)):
            if not range_overlap_check_idrange(id_ranges[i], id_ranges[j]):
                logger.error(
                    "Ranges '%s' and '%s' overlap!",
                    id_ranges[i].name,
                    id_ranges[j].name,
                )
                return False
    return True
# endregion
# Working with RID bases
# region


def propose_rid_ranges(
    id_ranges: List[IDRange], delta: int, proposals: List[IDRange]
) -> None:
    """
    Function to propose RID bases for ranges that don't have them set.

    - delta represents how far we start new base off existing range,
    used in order to allow for future expansion of existing ranges up
    to [delta] IDs.
    """
    ipa_local_ranges = get_ipa_local_ranges(id_ranges)

    for id_range in ipa_local_ranges:
        proposed_base_rid = 0
        proposed_secondary_base_rid = 0

        # Calculate proposed base RID and secondary base RID
        if id_range.base_rid is None:
            result, proposed_base_rid = propose_rid_base(
                id_range, ipa_local_ranges, delta, True
            )
            if result:
                id_range.base_rid = proposed_base_rid
                id_range.last_base_rid = proposed_base_rid + id_range.size
            else:
                # if this fails too, we print the warning and abandon the idea
                logger.warning(
                    "Warning: Proposed base RIDs %s for '%s' both failed, \
please adjust manually",
                    proposed_base_rid,
                    id_range.name,
                )
                continue

        if id_range.secondary_base_rid is None:
            result, proposed_secondary_base_rid = propose_rid_base(
                id_range, ipa_local_ranges, delta, False, proposed_base_rid
            )
            if result:
                id_range.secondary_base_rid = proposed_secondary_base_rid
                id_range.last_secondary_rid = (
                    proposed_secondary_base_rid + id_range.size
                )
            else:
                # if this fails too, we print the warning and abandon the idea
                logger.warning(
                    "Warning: Proposed secondary base RIDs %s for '%s' \
both failed, please adjust manually",
                    proposed_secondary_base_rid,
                    id_range.name,
                )
                continue

        # Add range to the proposals if we changed something successfully
        if proposed_base_rid > 0 or proposed_secondary_base_rid > 0:
            logger.debug(
                "Proposed RIDs for range '%s': pri %s, sec %s",
                id_range.name,
                proposed_base_rid,
                proposed_secondary_base_rid,
            )
            proposals.append(id_range)


def propose_rid_base(
    idrange: IDRange,
    ipa_local_ranges: List[IDRange],
    delta: int,
    primary: bool = True,
    previous_base_rid: int = -1
) -> Tuple[bool, str]:
    """
    Function to propose a base RID for a range, primary or secondary.
    We are getting the biggest base RID + size + delta and try
    if it's a viable option, check same kind first, then the other.
    """
    proposed_base_rid = max_rid(ipa_local_ranges, primary) + delta
    if proposed_base_rid == previous_base_rid:
        proposed_base_rid += idrange.size + delta
    if check_rid_base(ipa_local_ranges, proposed_base_rid, idrange.size):
        return True, proposed_base_rid

    # if we fail, we try the same with biggest of a different kind
    proposed_base_rid_orig = proposed_base_rid
    proposed_base_rid = max_rid(ipa_local_ranges, not primary) + delta
    if proposed_base_rid == previous_base_rid:
        proposed_base_rid += idrange.size + delta
    if check_rid_base(ipa_local_ranges, proposed_base_rid, idrange.size):
        return True, proposed_base_rid

    # if it fails, we return both RID proposals for the range
    return False, f"{proposed_base_rid_orig} and {proposed_base_rid}"


def max_rid(id_ranges: List[IDRange], primary: bool = True) -> int:
    """Function to get maximum RID of primary or secondary RIDs"""
    maximum_rid = 0
    for id_range in id_ranges:

        # looking only for primary RIDs
        if primary:
            if id_range.last_base_rid is not None:
                maximum_rid = max(maximum_rid, id_range.last_base_rid)
        # looking only for secondary RIDs
        else:
            if id_range.last_secondary_rid is not None:
                maximum_rid = max(maximum_rid, id_range.last_secondary_rid)

    return maximum_rid


def check_rid_base(id_ranges: List[IDRange], base: int, size: int) -> bool:
    """Function to check if proposed RID base is viable"""
    end = base + size + 1

    # Checking sanity of RID range
    if base + size > 2147483647:
        return False
    if base < 1000:
        return False

    # Checking RID range overlaps
    for id_range in id_ranges:
        # we are interested only in ipa-local ranges
        if id_range.type != "ipa-local":
            continue

        # if there is no base rid set, there is no secondary base rid set,
        # so nothing to overlap with
        if id_range.base_rid is None:
            continue

        # checking for an overlap
        if not range_overlap_check(
            base, end, id_range.base_rid, id_range.last_base_rid
        ):
            logger.debug(
                "RID check failure: proposed Primary %s + %s, \
intersects with %s-%s from range '%s'",
                base,
                size,
                id_range.base_rid,
                id_range.last_base_rid,
                id_range.name,
            )
            return False

        # if there is no secondary base rid set, nothing to overlap with
        if id_range.secondary_base_rid is None:
            continue

        # if either start of end of the range fails inside existing range,
        # or existing range is inside proposed one, we have an overlap
        if not range_overlap_check(
            base, end, id_range.secondary_base_rid, id_range.last_secondary_rid
        ):
            logger.debug(
                "RID check failure: proposed Secondary %s + %s, \
intersects with %s-%s from range '%s'",
                base,
                size,
                id_range.secondary_base_rid,
                id_range.last_secondary_rid,
                id_range.name,
            )
            return False

    return True


def get_ranges_no_base(id_ranges: List[IDRange]) -> List[IDRange]:
    """Function to get ranges without either of base RIDs set"""
    ipa_local_ranges = get_ipa_local_ranges(id_ranges)
    ranges_no_base = []
    for id_range in ipa_local_ranges:
        if id_range.base_rid is None or id_range.secondary_base_rid is None:
            ranges_no_base.append(id_range)

    return ranges_no_base
# endregion
# Working with IDentities out of range
# region


def group_identities_by_threshold(
    identities: List[IDentity], threshold: int
) -> List[List[IDentity]]:
    """Function to group out of range IDs by threshold"""
    groups: List[List[IDentity]] = []
    currentgroup: List[IDentity] = []
    if len(identities) == 0:
        return groups

    for i in range(len(identities) - 1):
        # add id to current group
        currentgroup.append(identities[i])

        # If the difference with the next one is greater than the threshold,
        # start a new group
        if identities[i + 1].number - identities[i].number > threshold:
            groups.append(currentgroup)
            currentgroup = []

    # Add the last ID number to the last group
    currentgroup.append(identities[-1])
    groups.append(currentgroup)

    return groups


def separate_under1000(
    identities: List[IDentity],
) -> Tuple[List[IDentity], List[IDentity]]:
    """Function to separate IDs under 1000, expects sorted list"""
    for i, identity in enumerate(identities):
        if identity.number >= 1000:
            return identities[:i], identities[i:]
    return identities, []


def separate_ranges_and_outliers(
    groups: List[List[IDentity]], minrangesize=int
) -> Tuple[List[List[IDentity]], List[List[IDentity]]]:
    """Function to separate IDs into outliers and IDs that can get ranges"""
    outliers = []
    cleangroups = []
    for group in groups:
        # if group is smaller than minrangesize, add it's memebers to ourliers
        if group[-1].number - group[0].number + 1 < minrangesize:
            for identity in group:
                outliers.append(identity)
        # if the group is OK, add it to cleaned groups
        else:
            cleangroups.append(group)

    return outliers, cleangroups


def round_idrange(start: int, end: int, under1000: bool) -> Tuple[int, int]:
    """Function to round up range margins to look pretty"""
    # calculating power of the size
    sizepower = len(str(end - start + 1))
    # multiplier for the nearest rounded number
    multiplier = 10 ** (sizepower - 1)
    # getting rounded range margins
    rounded_start = (start // multiplier) * multiplier
    if not under1000:
        rounded_start = max(rounded_start, 1000)
    else:
        rounded_start = max(rounded_start, 1)
    rounded_end = ((end + multiplier) // multiplier) * multiplier - 1

    return rounded_start, rounded_end


def get_rangename_base(id_ranges: List[IDRange]) -> str:
    """Function to get a base name for new range proposals"""
    base_name = ""
    # we want to use default range name as a base for new ranges
    for id_range in id_ranges:
        if id_range.base_rid == 1000:
            base_name = id_range.name

    # if we didn't find it, propose generic name
    if base_name == "":
        base_name = "Auto_added_range"

    return base_name


def get_rangename(id_ranges: List[IDRange], basename: str) -> str:
    """
    Function to get a new range name, we add the counter as 3-digit number
    extension and make sure it's unique
    """
    counter = 1
    full_name = f"{basename}_{counter:03}"
    while any(id_range.name == full_name for id_range in id_ranges):
        counter += 1
        full_name = f"{basename}_{counter:03}"
    return full_name


def propose_range(
    group: List[IDentity],
    id_ranges: List[IDRange],
    delta: int,
    basename: str,
    norounding: bool,
    allowunder1000: bool
) -> IDRange:
    """Function to propose a new range for group of IDs out of ranges"""
    startid = group[0].number
    endid = group[-1].number

    logger.debug(
        "Proposing a range for existing IDs out of ranges with start id %s \
and end id %s...",
        startid,
        endid,
    )

    # creating new range
    newrange = IDRange()
    newrange.type = "ipa-local"
    newrange.name = get_rangename(id_ranges, basename)
    newrange.suffix = id_ranges[0].suffix
    newrange.dn = f"cn={newrange.name},cn=ranges,cn=etc,{newrange.suffix}"

    if norounding:
        newrange.first_id = startid
        newrange.last_id = endid
        newrange.size = newrange.last_id - newrange.first_id + 1
    else:
        # first trying to round up ranges to look pretty
        newrange.first_id, newrange.last_id = round_idrange(
            startid,
            endid,
            allowunder1000
        )
        newrange.size = newrange.last_id - newrange.first_id + 1

    # if this creates an overlap, try without rounding
    if not newrange_overlap_check(id_ranges, newrange):
        newrange.first_id = startid
        newrange.last_id = endid
        newrange.size = newrange.last_id - newrange.first_id + 1
        # if we still failed, abandon idea
        if not newrange_overlap_check(id_ranges, newrange):
            logger.error(
                "ERROR! Failed to create idrange for existing IDs out of \
ranges with start id %s and end id %s, it overlaps with existing range!",
                startid,
                endid,
            )
            return None

    # creating RID bases
    ipa_local_ranges = get_ipa_local_ranges(id_ranges)

    result, proposed_base_rid = propose_rid_base(
        newrange, ipa_local_ranges, delta, True
    )
    if result:
        newrange.base_rid = proposed_base_rid
        newrange.last_base_rid = proposed_base_rid + newrange.size
    else:
        # if this fails we print the warning
        logger.warning(
            "Warning! Proposed base RIDs %s for new range start id %s and \
end id %s both failed, please adjust manually",
            proposed_base_rid,
            newrange.first_id,
            newrange.last_id,
        )

    result, proposed_secondary_base_rid = propose_rid_base(
        newrange, ipa_local_ranges, delta, False, proposed_base_rid
    )
    if result:
        newrange.secondary_base_rid = proposed_secondary_base_rid
        newrange.last_secondary_rid = (
            proposed_secondary_base_rid + newrange.size
        )
    else:
        # if this fails we print the warning
        logger.warning(
            "Warning! Proposed secondary base RIDs %s for new range start id \
%s and end id %s both failed, please adjust manually",
            proposed_secondary_base_rid,
            newrange.first_id,
            newrange.last_id,
        )

    logger.debug("Proposed range: %s", newrange)
    return newrange
# endregion
