"""
Command execution with output capture for Django management commands.
"""

import contextlib
import logging
import pathlib
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError
from dataclasses import dataclass
from io import StringIO
from pathlib import Path
from typing import Any

from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
from django.core.management import call_command
from django.core.management.base import CommandError

from .introspection import ArgumentInfo, introspect_command

logger = logging.getLogger(__name__)


def _truncate_output(text: str, head: int, tail: int) -> str:
    """
    Truncate text keeping first `head` and last `tail` lines.

    Args:
        text: Original output text.
        head: Lines to keep from the beginning. 0 = no head limit.
        tail: Lines to keep from the end. 0 = no tail.

    Returns:
        Original text if under limit, otherwise head + tail lines
        with a truncation notice between them.
    """
    if not text or (not head and not tail):
        return text

    lines = text.splitlines(keepends=True)
    total = len(lines)
    budget = (head or total) + tail

    if total <= budget:
        return text

    parts = []
    head_lines = head or total
    if head_lines:
        parts.append(''.join(lines[:head_lines]))

    notice = f'\n\n... Output truncated. Showing first {head_lines} and last {tail} of {total} lines. ...\n\n' if tail else \
             f'\n\n... Output truncated. Showing first {head_lines} of {total} lines.'

    parts.append(notice)

    if tail:
        parts.append(''.join(lines[-tail:]))

    return ''.join(parts)


def _truncate_buffers(stdout_buffer: StringIO, stderr_buffer: StringIO) -> tuple[str, str]:
    """
    Read buffers and apply output truncation to both stdout and stderr.

    Returns:
        Tuple of (stdout, stderr) with truncation applied.
    """
    head = getattr(settings, 'ADMIN_COMMANDS_OUTPUT_HEAD_LINES', 500)
    tail = getattr(settings, 'ADMIN_COMMANDS_OUTPUT_TAIL_LINES', 500)
    stdout = _truncate_output(stdout_buffer.getvalue(), head, tail)
    stderr = _truncate_output(stderr_buffer.getvalue(), head, tail)
    return stdout, stderr


@dataclass
class CommandResult:
    """Result of command execution."""
    success: bool                # True if exit code 0
    stdout: str                  # Captured stdout
    stderr: str                  # Captured stderr
    duration_ms: int             # Execution time in milliseconds
    error_message: str | None    # User-friendly error (if failed)


def execute_command(command_name: str, arguments: dict[str, Any]) -> CommandResult:
    """
    Run a Django management command with captured output.

    Executes the command synchronously in the current process,
    capturing stdout and stderr. Measures execution duration and
    catches all exceptions to ensure a CommandResult is always returned.

    Args:
        command_name: Name of the management command to execute
        arguments: Dict of argument name → value from form cleaned_data

    Returns:
        CommandResult with execution status, captured output, and timing.
        Never raises exceptions - all errors are captured in the result.

    Example:
        result = execute_command('migrate', {'app_label': 'myapp', 'verbosity': 2})
        if result.success:
            print(result.stdout)
        else:
            print(result.error_message)
    """
    # FAIL FAST: Validate command exists
    try:
        command_args = introspect_command(command_name)
    except KeyError as e:
        return CommandResult(
            success=False,
            stdout='',
            stderr='',
            duration_ms=0,
            error_message=str(e),
        )

    timeout = getattr(settings, 'ADMIN_COMMANDS_TIMEOUT', 30)

    # Capture stdout/stderr
    stdout_buffer = StringIO()
    stderr_buffer = StringIO()

    # Measure execution time
    start_time = time.monotonic()

    try:
        # Redirect sys.stdout/sys.stderr too — argparse writes help/version
        # directly to sys.stdout, bypassing Django's command.stdout.
        with contextlib.redirect_stdout(stdout_buffer), \
             contextlib.redirect_stderr(stderr_buffer), \
             _temporary_uploaded_files(arguments, command_args.arguments):
            # Convert form data to call_command arguments
            # (must happen AFTER _temporary_uploaded_files modifies arguments dict)
            args, kwargs = _convert_form_data_to_args(arguments, command_args.arguments)

            if timeout:
                _call_command_with_timeout(
                    command_name, args, kwargs,
                    stdout_buffer, stderr_buffer, timeout,
                )
            else:
                call_command(
                    command_name,
                    *args,
                    stdout=stdout_buffer,
                    stderr=stderr_buffer,
                    **kwargs,
                )

        duration_ms = int((time.monotonic() - start_time) * 1000)
        stdout, stderr = _truncate_buffers(stdout_buffer, stderr_buffer)

        return CommandResult(
            success=True,
            stdout=stdout,
            stderr=stderr,
            duration_ms=duration_ms,
            error_message=None,
        )

    except _CommandTimeout as e:
        duration_ms = int((time.monotonic() - start_time) * 1000)

        logger.error(
            'Command %s timed out after %s seconds',
            command_name,
            e.timeout,
        )

        stdout, stderr = _truncate_buffers(stdout_buffer, stderr_buffer)

        return CommandResult(
            success=False,
            stdout=stdout,
            stderr=stderr,
            duration_ms=duration_ms,
            error_message=f'Command timed out after {e.timeout} seconds.',
        )

    except CommandError as e:
        duration_ms = int((time.monotonic() - start_time) * 1000)

        logger.error(
            'Command %s failed with CommandError: %s',
            command_name,
            str(e),
        )

        stdout, stderr = _truncate_buffers(stdout_buffer, stderr_buffer)

        return CommandResult(
            success=False,
            stdout=stdout,
            stderr=stderr,
            duration_ms=duration_ms,
            error_message=f'Command failed: {str(e)}',
        )

    except SystemExit as e:
        duration_ms = int((time.monotonic() - start_time) * 1000)

        # argparse calls sys.exit() for --help/--version actions.
        # Exit code 0 means normal termination (help/version), treat as success.
        stdout, stderr = _truncate_buffers(stdout_buffer, stderr_buffer)

        if e.code == 0:
            return CommandResult(
                success=True,
                stdout=stdout,
                stderr=stderr,
                duration_ms=duration_ms,
                error_message=None,
            )

        logger.error(
            'Command %s exited with code %s',
            command_name,
            e.code,
        )

        return CommandResult(
            success=False,
            stdout=stdout,
            stderr=stderr,
            duration_ms=duration_ms,
            error_message=f'Command exited with code {e.code}',
        )

    except Exception as e:
        duration_ms = int((time.monotonic() - start_time) * 1000)

        logger.exception(
            'Command %s failed with unexpected exception: %s',
            command_name,
            str(e),
        )

        stdout, stderr = _truncate_buffers(stdout_buffer, stderr_buffer)

        return CommandResult(
            success=False,
            stdout=stdout,
            stderr=stderr or str(e),
            duration_ms=duration_ms,
            error_message=f'Unexpected error: {str(e)}',
        )


class _CommandTimeout(Exception):
    """Raised when command execution exceeds the configured timeout."""

    def __init__(self, timeout: int | float):
        self.timeout = timeout


def _call_command_with_timeout(
    command_name: str,
    args: list[Any],
    kwargs: dict[str, Any],
    stdout_buffer: StringIO,
    stderr_buffer: StringIO,
    timeout: int | float,
) -> None:
    """
    Execute call_command in a thread with timeout.

    Raises:
        _CommandTimeout: If execution exceeds timeout seconds.
        CommandError, SystemExit, Exception: Re-raised from the command thread.
    """
    pool = ThreadPoolExecutor(max_workers=1)
    future = pool.submit(
        call_command,
        command_name,
        *args,
        stdout=stdout_buffer,
        stderr=stderr_buffer,
        **kwargs,
    )
    # Detach pool immediately — do NOT use context manager, because
    # __exit__ calls shutdown(wait=True) which blocks until the thread finishes,
    # defeating the entire purpose of timeout.
    pool.shutdown(wait=False)
    try:
        future.result(timeout=timeout)
    except TimeoutError:
        raise _CommandTimeout(timeout)


def _convert_form_data_to_args(
    arguments: dict[str, Any],
    arg_infos: list[ArgumentInfo],
) -> tuple[list[Any], dict[str, Any]]:
    """
    Convert form cleaned_data to positional args and keyword args for call_command.

    Handles different argparse action types:
    - Positional arguments → positional args list (ordered)
    - Boolean store_true → only include if True
    - Boolean store_false → only include if False
    - nargs with space-separated values → split into list
    - Optional arguments → keyword args dict

    Args:
        arguments: Dict from form.cleaned_data (arg name → value)
        arg_infos: List of ArgumentInfo from introspection

    Returns:
        Tuple of (positional_args_list, keyword_args_dict)
        Ready to pass to call_command(*args, **kwargs)

    Example:
        args, kwargs = _convert_form_data_to_args(
            {'app_label': 'myapp', 'noinput': True, 'verbosity': 2},
            [ArgumentInfo(...), ...]
        )
        # args = ['myapp']
        # kwargs = {'noinput': True, 'verbosity': 2}
    """
    arg_info_map = {info.dest: info for info in arg_infos}

    positional_args = _collect_positional_args(arguments, arg_infos)
    keyword_args = _collect_keyword_args(arguments, arg_info_map)

    return positional_args, keyword_args


def _collect_positional_args(
    arguments: dict[str, Any],
    arg_infos: list[ArgumentInfo],
) -> list[Any]:
    """
    Collect positional arguments in correct order.

    Sorts positional arguments by position field to ensure correct ordering
    regardless of form data dict iteration order.

    Args:
        arguments: Dict from form.cleaned_data
        arg_infos: List of ArgumentInfo from introspection

    Returns:
        List of positional argument values in correct order
    """
    positional_args = []

    # Sort positional arguments by position
    positional_infos = [info for info in arg_infos if info.is_positional]
    positional_infos.sort(key=lambda info: info.position or 0)

    for info in positional_infos:
        value = arguments.get(info.dest)

        # Skip None/empty values for optional positional args
        if value is None or value == '':
            if info.required:
                logger.warning(
                    'Required positional argument %s is missing',
                    info.name,
                )
            continue

        positional_args.append(value)

    return positional_args


def _collect_keyword_args(
    arguments: dict[str, Any],
    arg_info_map: dict[str, ArgumentInfo],
) -> dict[str, Any]:
    """
    Collect keyword arguments with type conversions.

    Handles boolean flags, nargs, and regular arguments.
    Skips positional arguments and empty/None values.

    Args:
        arguments: Dict from form.cleaned_data
        arg_info_map: Mapping of dest → ArgumentInfo

    Returns:
        Dict of keyword arguments ready for call_command
    """
    keyword_args = {}

    for dest, value in arguments.items():
        arg_info = arg_info_map.get(dest)

        # Skip if not found in arg_infos (shouldn't happen with proper form)
        if not arg_info:
            continue

        # Skip positional arguments (already handled)
        if arg_info.is_positional:
            continue

        # Skip None/empty values for optional arguments
        if value is None or value == '':
            continue

        # Convert argument value based on action/nargs
        converted_value = _convert_argument_value(value, arg_info)

        # Add to kwargs if not None (None means skip)
        if converted_value is not None:
            keyword_args[dest] = converted_value

    return keyword_args


def _convert_argument_value(value: Any, arg_info: ArgumentInfo) -> Any | None:
    """
    Convert single argument value based on action type.

    Handles boolean flags, nargs patterns, and regular arguments.
    Returns None to indicate the argument should be skipped.

    Args:
        value: Raw value from form
        arg_info: Argument metadata

    Returns:
        Converted value, or None to skip this argument
    """
    # Handle boolean flags
    if arg_info.action == 'store_true':
        # Only include if True
        return True if value is True else None

    if arg_info.action == 'store_false':
        # Only include if False
        return False if value is False else None

    # Handle nargs (space-separated values)
    if arg_info.nargs in ('+', '*', '?') and isinstance(value, str):
        split_values = value.split()

        # For '?', empty string means no value provided
        if arg_info.nargs == '?' and not split_values:
            return None

        return split_values

    # Handle numeric nargs (exactly N values)
    if isinstance(arg_info.nargs, int) and isinstance(value, str):
        split_values = value.split()

        # Validate expected count
        if len(split_values) != arg_info.nargs:
            logger.warning(
                'Argument %s expects exactly %d values, got %d',
                arg_info.name,
                arg_info.nargs,
                len(split_values),
            )

        return split_values

    # Handle regular arguments
    return value


def _is_path_type_argument(arg_info: ArgumentInfo) -> bool:
    """
    Check if argument type is a pathlib Path variant.

    Args:
        arg_info: Argument metadata

    Returns:
        True if type is Path/PosixPath/WindowsPath/PurePath
    """
    if arg_info.type is None:
        return False

    try:
        return issubclass(arg_info.type, pathlib.PurePath)
    except TypeError:
        # issubclass() raises TypeError for non-class types
        return False


@contextlib.contextmanager
def _temporary_uploaded_files(
    cleaned_data: dict[str, Any],
    arg_infos: list[ArgumentInfo],
):
    """
    Save uploaded files to /tmp and replace with paths in cleaned_data.

    Context manager that:
    1. Identifies Path-type arguments in cleaned_data
    2. Saves uploaded files to /tmp with unique names
    3. Replaces UploadedFile objects with temp file path strings
    4. Yields for command execution
    5. Deletes temp files in finally block (guaranteed cleanup)

    Args:
        cleaned_data: Form cleaned_data dict (modified in-place)
        arg_infos: List of ArgumentInfo from introspection

    Yields:
        Dict mapping arg dest → temp Path (for logging/debugging)

    Example:
        with _temporary_uploaded_files(cleaned_data, arg_infos) as temp_paths:
            call_command(name, **cleaned_data)
        # temp files deleted here
    """
    temp_paths: dict[str, Path] = {}

    try:
        # Process each argument
        for arg_info in arg_infos:
            # Skip non-Path arguments
            if not _is_path_type_argument(arg_info):
                continue

            value = cleaned_data.get(arg_info.dest)

            if value is None:
                # Optional Path argument with nothing provided
                continue

            # Text path mode: value is already a string, pass as-is
            if isinstance(value, str):
                continue

            # File upload mode: save to temp file
            if not isinstance(value, UploadedFile):
                logger.warning(
                    'Unexpected type %s for Path argument %s, skipping',
                    type(value).__name__,
                    arg_info.dest,
                )
                continue

            # Create temp file with unique name (NamedTemporaryFile guarantees
            # uniqueness at OS level, unlike deprecated mktemp which has race conditions)
            suffix = f'_{value.name}' if value.name else ''
            with tempfile.NamedTemporaryFile(
                suffix=suffix, delete=False,
            ) as tmp:
                # Stream in chunks to avoid loading entire file into memory
                for chunk in value.chunks():
                    tmp.write(chunk)
                temp_file = Path(tmp.name)

            # Store temp path for cleanup
            temp_paths[arg_info.dest] = temp_file

            # MODIFY cleaned_data in-place: replace UploadedFile with path string
            cleaned_data[arg_info.dest] = str(temp_file)

        # Yield temp paths (command execution happens here)
        yield temp_paths

    finally:
        # ALWAYS cleanup temp files (even if command fails)
        for dest, temp_path in temp_paths.items():
            try:
                temp_path.unlink(missing_ok=True)
            except Exception as e:
                # Log but don't fail on cleanup errors
                logger.warning(
                    'Failed to delete temp file %s for argument %s: %s',
                    temp_path,
                    dest,
                    str(e),
                )
