""" This module is used to check the security of the commands entered by the
user. It checks if the command is allowed, if the path is allowed, if the
command contains forbidden characters, etc.
"""

import sys
import re
import os
import shlex
import glob

# import lshell specifics
from lshell import utils

EXTENSION_RESTRICTION_EXEMPT_COMMANDS = {"cd", "clear", "fg", "bg", "ls"}


def _is_assignment_word(word):
    return bool(re.match(r"^[A-Za-z_][A-Za-z0-9_]*=.*$", word))


def should_enforce_file_extensions(command):
    """Return True when extension restrictions should apply to this command."""
    return command not in EXTENSION_RESTRICTION_EXEMPT_COMMANDS


def _split_command_for_auth(command_line):
    """Return (command, args, full_command) for auth checks, skipping VAR=VALUE prefixes."""
    try:
        tokens = shlex.split(command_line, posix=True)
    except ValueError:
        return "", [], ""

    index = 0
    while index < len(tokens) and _is_assignment_word(tokens[index]):
        index += 1

    if index >= len(tokens):
        return "", [], ""

    command = tokens[index]
    args = tokens[index + 1 :]
    full_command = " ".join([command] + args).strip()
    return command, args, full_command


def warn_count(messagetype, command, conf, strict=None, ssh=None):
    """Update the warning_counter, log and display a warning to the user"""

    log = conf["logpath"]
    if messagetype == "unknown syntax":
        primary_message = f"lshell: unknown syntax: {command}"
    else:
        primary_message = f'lshell: forbidden {messagetype}: "{command}"'

    if ssh:
        return 1, conf

    conf["warning_counter"] -= 1
    if conf["warning_counter"] < 0:
        log.critical(primary_message)
        log.critical("lshell: session terminated: warning limit exceeded")
        sys.exit(1)

    log.critical(primary_message)
    remaining = conf["warning_counter"]
    violation_label = "violation" if remaining == 1 else "violations"
    sys.stderr.write(
        f"lshell: warning: {remaining} {violation_label}"
        " remaining before session termination\n"
    )
    log.error(f"lshell: user warned, counter: {remaining}")

    # Return 1 to indicate a warning was triggered.
    return 1, conf


def warn_unknown_syntax(command, conf, strict=None, ssh=None):
    """Warn on unknown syntax, honoring strict-mode warning counting."""
    if strict:
        return warn_count("unknown syntax", command, conf, strict=strict, ssh=ssh)

    log = conf["logpath"]
    log.warning(f'INFO: unknown syntax -> "{command}"')
    # Keep legacy UX: unknown syntax is always printed to stderr.
    sys.stderr.write(f"lshell: unknown syntax: {command}\n")
    return 1, conf


def tokenize_command(command):
    """Tokenize the command line into separate commands based on the operators"""

    try:
        lexer = shlex.shlex(command, posix=True)
        lexer.whitespace_split = True
        lexer.commenters = ""
        tokens = list(lexer)
    except ValueError:
        # Handle the exception and return an appropriate message or handle as needed
        return []
    return tokens


def expand_shell_wildcards(item):
    """Expand shell wildcards in the item and return the expanded path"""

    # Expand shell variables like $HOME
    item = os.path.expanduser(item)
    item = os.path.expandvars(item)
    item = os.path.realpath(item)  # this is a hack - needs to be reviewed
    # test if item is a directory
    expanded_items = glob.glob(item, recursive=True)
    if expanded_items:
        # Return all matches instead of just the first one
        item = expanded_items[0]

    return item


def check_path(line, conf, completion=None, ssh=None, strict=None):
    """Check if a path is entered in the line. If so, it checks if user
    are allowed to see this path. If user is not allowed, it calls
    warn_count. In case of completion, it only returns 0 or 1.
    """
    allowed_path_re = str(conf["path"][0])
    denied_path_re = str(conf["path"][1][:-1])

    line = tokenize_command(line)

    for item in line:
        tomatch = expand_shell_wildcards(item)
        if os.path.isdir(tomatch) and tomatch[-1] != "/":
            tomatch += "/"
        match_allowed = re.findall(allowed_path_re, tomatch)
        if denied_path_re:
            match_denied = re.findall(denied_path_re, tomatch)
        else:
            match_denied = None

        # if path not allowed
        # case path executed: warn, and return 1
        # case completion: return 1
        if not match_allowed or match_denied:
            if not completion:
                ret, conf = warn_count("path", tomatch, conf, strict=strict, ssh=ssh)
            return 1, conf

    if not completion:
        if not re.findall(allowed_path_re, os.getcwd() + "/"):
            ret, conf = warn_count("path", tomatch, conf, strict=strict, ssh=ssh)
            os.chdir(conf["home_path"])
            conf["promptprint"] = utils.updateprompt(os.getcwd(), conf)
            return 1, conf
    return 0, conf


def check_forbidden_chars(line, conf, strict=None, ssh=None):
    """Check if the line contains any forbidden
    characters. If so, it calls warn_count.
    """
    for item in conf["forbidden"]:
        # keep compatibility with historical behavior from check_secure:
        # allow "&&" and "||" even when single "&" or "|" are forbidden.
        if item in ["&", "|"]:
            escaped_item = re.escape(item)
            if re.search(rf"(?<!{escaped_item}){escaped_item}(?!{escaped_item})", line):
                ret, conf = warn_count("character", item, conf, strict=strict, ssh=ssh)
                return ret, conf
        elif item in line:
            ret, conf = warn_count("character", item, conf, strict=strict, ssh=ssh)
            return ret, conf
    return 0, conf


def check_secure(line, conf, strict=None, ssh=None):
    """This method is used to check the content on the typed command.
    Its purpose is to forbid the user to user to override the lshell
    command restrictions.
    The forbidden characters are placed in the 'forbidden' variable.
    Feel free to update the list. Emptying it would be quite useless..: )

    A warning counter has been added, to kick out of lshell a user if he
    is warned more than X time (X being the 'warning_counter' variable).
    """

    # store original string
    oline = line

    # strip all spaces/tabs
    line = line.strip()

    # init return code
    returncode = 0

    # This logic is kept crudely simple on purpose.
    # At most we might match the same stanza twice
    # (for e.g. "'a'", 'a') but the converse would
    # require detecting single quotation stanzas
    # nested within double quotes and vice versa
    relist = re.findall(r"[^=]\"(.+)\"", line)
    relist2 = re.findall(r"[^=]\'(.+)\'", line)
    relist = relist + relist2
    for item in relist:
        if os.path.exists(item):
            ret_check_path, conf = check_path(item, conf, strict=strict)
            returncode += ret_check_path

    # parse command line for control characters, and warn user
    if re.findall(r"[\x01-\x1F\x7F]", oline):
        ret, conf = warn_count("control char", oline, conf, strict=strict, ssh=ssh)
        return ret, conf

    for item in conf["forbidden"]:
        # allow '&&' and '||' even if singles are forbidden
        if item in ["&", "|"]:
            if re.findall(rf"[^\{item}]\{item}[^\{item}]", line):
                ret, conf = warn_count("character", item, conf, strict=strict, ssh=ssh)
                return ret, conf
        else:
            if item in line:
                ret, conf = warn_count("character", item, conf, strict=strict, ssh=ssh)
                return ret, conf

    # check if the line contains $(foo) executions, and check them
    executions = re.findall(r"\$\([^)]+[)]", line)
    for item in executions:
        # recurse on check_path
        ret_check_path, conf = check_path(item[2:-1].strip(), conf, strict=strict)
        returncode += ret_check_path

        # recurse on check_secure
        ret_check_secure, conf = check_secure(item[2:-1].strip(), conf, strict=strict)
        returncode += ret_check_secure

    # check for executions using back quotes '`'
    executions = re.findall(r"\`[^`]+[`]", line)
    for item in executions:
        ret_check_secure, conf = check_secure(item[1:-1].strip(), conf, strict=strict)
        returncode += ret_check_secure

    # check if the line contains ${foo=bar}, and check them
    curly = re.findall(r"\$\{[^}]+[}]", line)
    for item in curly:
        # split to get variable only, and remove last character "}"
        if re.findall(r"=|\+|\?|\-", item):
            variable = re.split(r"=|\+|\?|\-", item, 1)
        else:
            variable = item
        ret_check_path, conf = check_path(variable[1][:-1], conf, strict=strict)
        returncode += ret_check_path

    # if unknown commands where found, return 1 and don't execute the line
    if returncode > 0:
        return 1, conf
    # in case the $(foo) or `foo` command passed the above tests
    elif line.startswith("$(") or line.startswith("`"):
        return 0, conf

    lines = utils.split_commands(line)

    for separate_line in lines:
        # remove trailing parenthesis
        separate_line = re.sub(r"\)$", "", separate_line)
        separate_line = " ".join(separate_line.split())
        command, command_args_list, full_command = _split_command_for_auth(
            separate_line
        )

        # in case of a sudo command, check in sudo_commands list if allowed
        if command == "sudo" and command_args_list:
            # allow the -u (user) flag
            if command_args_list[0] == "-u" and command_args_list:
                if len(command_args_list) < 3:
                    ret, conf = warn_count(
                        "sudo command", oline, conf, strict=strict, ssh=ssh
                    )
                    return ret, conf
                sudocmd = command_args_list[2]
            else:
                sudocmd = command_args_list[0]
            if sudocmd not in conf["sudo_commands"] and command_args_list:
                ret, conf = warn_count(
                    "sudo command", oline, conf, strict=strict, ssh=ssh
                )
                return ret, conf

        # if over SSH, replaced allowed list with the one of overssh
        if ssh:
            conf["allowed"] = conf["overssh"]

        # # for all other commands check in allowed list
        # if command not in conf["allowed"] and command:
        #     ret, conf = warn_count("command", command, conf, strict=strict, ssh=ssh)
        #     return ret, conf

        # Check if the full command (with arguments) or just the command is allowed
        if (
            full_command not in conf["allowed"]
            and command not in conf["allowed"]
            and command
        ):
            if strict:
                ret, conf = warn_count("command", command, conf, strict=strict, ssh=ssh)
            else:
                ret, conf = warn_unknown_syntax(full_command, conf, strict=strict, ssh=ssh)
            return ret, conf

        # Check if the command contains any forbidden extensions
        if conf.get("allowed_file_extensions") and should_enforce_file_extensions(
            command
        ):
            allowed_extensions = conf["allowed_file_extensions"]
            check_extensions, disallowed_extensions = check_allowed_file_extensions(
                full_command, allowed_extensions
            )
            if check_extensions is False:
                ret, conf = warn_count(
                    f"file extension {disallowed_extensions}",
                    full_command,
                    conf,
                    strict=strict,
                    ssh=ssh,
                )
                return ret, conf

    return 0, conf


def check_allowed_file_extensions(command_line, allowed_extensions):
    """Checks if file arguments in the command line use allowed extensions."""
    # Split the command using shlex to handle quotes and escape characters
    try:
        tokens = shlex.split(command_line)
    except ValueError as exception:
        # Log error or provide user feedback on the invalid input
        print(f"lshell: error parsing command line: {exception}")
        return True, []

    if not tokens:
        return True, None

    candidates = []
    for token in tokens[1:]:
        if _is_assignment_word(token):
            continue

        # Parse option values such as `--include=*.log` as potential file globs.
        if token.startswith("-"):
            if "=" not in token:
                continue
            _, value = token.split("=", 1)
            values_to_check = [value] if value else []
        else:
            values_to_check = [token]

        for value in values_to_check:
            candidate = value.rstrip("/")
            basename = os.path.basename(candidate)

            if not basename or basename in [".", ".."]:
                continue

            extension = os.path.splitext(basename)[1]
            has_path_markers = any(
                char in value for char in ["/", "\\", "*", "?", "[", "]"]
            ) or value.startswith(("~", "."))
            is_simple_bareword = bool(re.match(r"^[A-Za-z0-9_-]+$", basename))

            candidates.append(
                {
                    "extension": extension if extension else "<none>",
                    "explicit_path_like": bool(extension) or has_path_markers,
                    "simple_bareword": is_simple_bareword and not has_path_markers,
                }
            )

    has_explicit_path_like = any(item["explicit_path_like"] for item in candidates)
    disallowed_extensions = []
    for item in candidates:
        # If explicit path-like operands are present, treat lone bare words as
        # likely literals/patterns rather than filenames.
        if has_explicit_path_like and item["simple_bareword"]:
            continue
        extension = item["extension"]
        if extension not in allowed_extensions and extension not in disallowed_extensions:
            disallowed_extensions.append(extension)

    if disallowed_extensions:
        return False, disallowed_extensions
    return True, None
