#!/usr/bin/env python3
"""
Icinga/Nagios plugin to check LDAP user password expiration
"""

import argparse
import getpass
import logging
import sys
from datetime import datetime, timedelta, timezone

import ldap
import ldap.modlist
from humanize import naturaldelta

# Configure logging to stderr
logging.basicConfig(
    level=logging.WARNING,
    format="%(asctime)s - %(levelname)s - %(message)s",
    stream=sys.stderr,
)
log = logging.getLogger(__name__)

# Icinga exit codes
EXIT_STATES = {
    "OK": 0,
    "WARNING": 1,
    "CRITICAL": 2,
    "UNKNOWN": 3,
}


def format_ldap_error(exc):
    """Format error message from an LDAPError exception"""
    desc = exc.args[0].get("desc", "Unknown error")
    code = exc.args[0].get("result")
    return f"{desc} ({code})" if code else desc


def parse_generalized_time(time_str):
    """Parse GeneralizedTime format (YYYYMMDDHHMMSS[.f...]Z or YYYYMMDDHHMMSS[.f...]+HHMM)"""
    if not time_str:
        return False

    try:
        # Handle UTC timezone (ends with Z)
        if time_str.endswith("Z"):
            time_str = time_str[:-1] + "+0000"

        # Handle fractional seconds
        if "." in time_str:
            time_str, _ = time_str.split(".")

        # Parse timezone offset
        if "+" in time_str:
            dt_str, tz_str = time_str.split("+")
            tz_hours = int(tz_str[:2])
            tz_minutes = int(tz_str[2:4])
            tz_offset = timedelta(hours=tz_hours, minutes=tz_minutes)
            tz = timezone(tz_offset)
        elif "-" in time_str[-5:]:
            dt_str, tz_str = time_str.rsplit("-", 1)
            tz_hours = int(tz_str[:2])
            tz_minutes = int(tz_str[2:4])
            tz_offset = timedelta(hours=-tz_hours, minutes=-tz_minutes)
            tz = timezone(tz_offset)
        else:
            dt_str = time_str
            tz = timezone.utc

        # Parse datetime
        dt = datetime.strptime(dt_str, "%Y%m%d%H%M%S")
        return dt.replace(tzinfo=tz)
    except (ValueError, IndexError) as exc:
        log.error("Failed to parse time string '%s': %s", time_str, exc)
        return False


def get_ldap_connection(ldap_uri, bind_dn=None, bind_password=None):
    """Establish LDAP connection and bind"""
    try:
        conn = ldap.initialize(ldap_uri)
        conn.protocol_version = ldap.VERSION3  # pylint: disable=no-member

        if bind_dn:
            conn.simple_bind_s(bind_dn, bind_password or "")
        elif ldap_uri.lower().startswith("ldapi://"):
            conn.sasl_interactive_bind_s("", ldap.sasl.external())

        return conn
    except ldap.LDAPError as exc:  # pylint: disable=no-member
        print(f"UNKNOWN - Failed to connect to LDAP server {ldap_uri} [{format_ldap_error(exc)}]")
        sys.exit(EXIT_STATES["UNKNOWN"])


def get_user_password_policy_info(conn, user_dn):
    """Get user's password policy info"""
    try:
        # Search for user attributes including operational attributes
        result = conn.search_s(
            user_dn,
            ldap.SCOPE_BASE,  # pylint: disable=no-member
            attrlist=["pwdPolicySubentry", "pwdChangedTime"],
        )

        if not result:
            return None, None

        user_attrs = result[0][1]
        pwd_policy_subentry = user_attrs.get("pwdPolicySubentry", [None])[0]
        pwd_policy_subentry = pwd_policy_subentry.decode("utf8") if pwd_policy_subentry else None
        pwd_changed_time = user_attrs.get("pwdChangedTime", [None])[0]
        pwd_changed_time = pwd_changed_time.decode("utf8") if pwd_changed_time else None

        return pwd_policy_subentry, pwd_changed_time
    except ldap.LDAPError as exc:  # pylint: disable=no-member
        print(f"UNKNOWN - Failed to fetch LDAP user {user_dn} info [{format_ldap_error(exc)}]")
        sys.exit(EXIT_STATES["UNKNOWN"])


def get_password_policy_max_age(conn, policy_dn):
    """Get pwdMaxAge from password policy"""
    if not policy_dn:
        return None

    try:
        result = conn.search_s(
            policy_dn, ldap.SCOPE_BASE, attrlist=["pwdMaxAge"]  # pylint: disable=no-member
        )
        if not result:
            return None

        policy_attrs = result[0][1]
        pwd_max_age = policy_attrs.get("pwdMaxAge", [None])[0]

        if pwd_max_age is not None:
            return timedelta(seconds=int(pwd_max_age))
        return None
    except (ldap.LDAPError, ValueError) as exc:  # pylint: disable=no-member
        print(
            f"UNKNOWN - Error fetching password policy info from {policy_dn} info "
            f"[{format_ldap_error(exc)}]"
        )
        sys.exit(EXIT_STATES["UNKNOWN"])


def main():
    """Entrypoint"""
    parser = argparse.ArgumentParser(
        description="Icinga/Nagios plugin to check LDAP user password expiration"
    )

    # Required arguments
    parser.add_argument("user_dn", type=str, help="DN of the user to check")

    # Optional arguments
    parser.add_argument(
        "-H",
        "--ldap-uri",
        type=str,
        default="ldapi:///",
        help="LDAP URI (default: ldapi:///)",
    )
    parser.add_argument(
        "-D", "--bind-dn", type=str, default=None, help="Bind DN for authentication"
    )
    parser.add_argument(
        "-w",
        "--bind-password",
        type=str,
        default=None,
        help="Bind password for authentication",
    )
    parser.add_argument(
        "-W",
        "--ask-bind-password",
        action="store_true",
        help="Prompt for bind password",
    )
    parser.add_argument(
        "--pwd-policy-dn", type=str, default=None, help="Default password policy DN"
    )
    parser.add_argument(
        "--warning", type=int, default=7, help="Warning threshold in days (default: 7)"
    )
    parser.add_argument(
        "--critical",
        type=int,
        default=3,
        help="Critical threshold in days (default: 3)",
    )
    parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Enable verbose logging (INFO level)",
    )
    parser.add_argument(
        "-d", "--debug", action="store_true", help="Enable debug logging (DEBUG level)"
    )

    args = parser.parse_args()

    # Configure log level based on arguments
    log.setLevel(logging.DEBUG if args.debug else logging.INFO if args.verbose else logging.WARNING)

    # Handle password input
    if args.ask_bind_password:
        args.bind_password = getpass.getpass("Enter bind password: ")

    # Establish LDAP connection
    conn = get_ldap_connection(args.ldap_uri, args.bind_dn, args.bind_password)

    # Get user password policy info
    pwd_policy_subentry, pwd_changed_time_str = get_user_password_policy_info(conn, args.user_dn)
    log.debug("User pwdPolicySubentry: %s", pwd_policy_subentry)
    log.debug("User pwdChangedTime: %s", pwd_changed_time_str)

    if pwd_changed_time_str is None:
        print(
            f"UNKNOWN - Last password change date of user {args.user_dn} unknown "
            "(pwdChangedTime attribute undefined)"
        )
        sys.exit(EXIT_STATES["UNKNOWN"])

    # Parse pwdChangedTime
    pwd_changed_time = parse_generalized_time(pwd_changed_time_str)
    if not pwd_changed_time:
        print(
            f"UNKNOWN - Failed to parse last password change date of user {args.user_dn} "
            f"(pwdChangedTime={pwd_changed_time_str})"
        )
        sys.exit(EXIT_STATES["UNKNOWN"])

    # Determine which password policy to use
    policy_dn = pwd_policy_subentry or args.pwd_policy_dn
    if not policy_dn:
        print(
            f"UNKNOWN - No password policy found for user {args.user_dn} "
            "(neither pwdPolicySubentry nor default policy provided)"
        )
        sys.exit(EXIT_STATES["UNKNOWN"])

    # Get pwdMaxAge from policy
    pwd_max_age = get_password_policy_max_age(conn, policy_dn)
    log.debug("Password policy pwdMaxAge: %s", pwd_max_age)
    if pwd_max_age is None:
        print(
            f"UNKNOWN - The password policy {policy_dn} of the account {args.user_dn} does not "
            "provide for password expiration (pwdMaxAge undefined)"
        )
        sys.exit(EXIT_STATES["UNKNOWN"])

    # Calculate expiration date
    expiration_time = pwd_changed_time + pwd_max_age
    now = datetime.now(pwd_changed_time.tzinfo)

    # Calculate days remaining
    delta = expiration_time - now

    # Format expiration time for local timezone
    local_expiration = expiration_time.astimezone()
    expiration_str = local_expiration.strftime("%Y-%m-%d %H:%M:%S %Z")

    log.debug("Password changed: %s", pwd_changed_time)
    log.debug("Password expires: %s", expiration_time)
    log.debug("Remaining time: %s", naturaldelta(delta))

    # Determine status
    if now >= expiration_time:
        print(
            f"CRITICAL: User {args.user_dn} password expired since {naturaldelta(delta)} "
            f"({expiration_str})"
        )
        sys.exit(EXIT_STATES["CRITICAL"])
    elif delta.days <= args.critical:
        print(
            f"CRITICAL: User {args.user_dn} password expires in {naturaldelta(delta)} "
            f"({expiration_str})"
        )
        sys.exit(EXIT_STATES["CRITICAL"])
    elif delta.days <= args.warning:
        print(
            f"WARNING: User {args.user_dn} password expires in {naturaldelta(delta)} "
            f"({expiration_str})"
        )
        sys.exit(EXIT_STATES["WARNING"])
    else:
        print(
            f"OK - User {args.user_dn} password expires in {naturaldelta(delta)} "
            f"({expiration_str})"
        )
        sys.exit(EXIT_STATES["OK"])


if __name__ == "__main__":
    main()
