import json
import os
from dataclasses import dataclass, replace
from typing import Optional

import click
from joblib import Parallel, delayed
from pynetbox.core.query import RequestError

from inventory_monitor.invmon import InventoryMonitor
from inventory_monitor.logger import logger, setup_logger
from inventory_monitor.models import AppConfig, load_config_from_env
from inventory_monitor.netbox import Netbox


@dataclass(frozen=True)
class CliOverrides:
    """CLI option values to overlay on top of env-loaded AppConfig."""

    env_file: Optional[str] = None
    snmp_version: Optional[str] = None
    snmp_community: Optional[str] = None
    snmp_user: Optional[str] = None
    snmp_auth: Optional[str] = None
    snmp_auth_method: Optional[str] = None
    snmp_privacy: Optional[str] = None
    snmp_privacy_method: Optional[str] = None
    snmp_security: Optional[str] = None
    snmp_timeout: Optional[int] = None
    snmp_retries: Optional[int] = None
    netbox_api_token: Optional[str] = None
    netbox_url: Optional[str] = None
    netbox_tags: Optional[str] = None
    netbox_tags_exclude: Optional[str] = None
    parallel_jobs: Optional[int] = None
    log_level: Optional[str] = None
    model_excludes: Optional[str] = None


def _build_config(overrides: CliOverrides) -> AppConfig:
    """Load AppConfig from env, then overlay any non-None CLI overrides."""
    env_file = overrides.env_file if overrides.env_file else ".env"
    cfg = load_config_from_env(env_file=env_file)

    # Collect v2 overrides
    v2_overrides = {}
    if overrides.snmp_community is not None:
        v2_overrides["community"] = overrides.snmp_community

    # Collect v3 overrides
    v3_overrides = {}
    if overrides.snmp_user is not None:
        v3_overrides["user"] = overrides.snmp_user
    if overrides.snmp_auth is not None:
        v3_overrides["auth_password"] = overrides.snmp_auth
    if overrides.snmp_auth_method is not None:
        v3_overrides["auth_protocol"] = overrides.snmp_auth_method
    if overrides.snmp_privacy is not None:
        v3_overrides["privacy_password"] = overrides.snmp_privacy
    if overrides.snmp_privacy_method is not None:
        v3_overrides["privacy_protocol"] = overrides.snmp_privacy_method
    if overrides.snmp_security is not None:
        v3_overrides["security_level"] = overrides.snmp_security

    # Collect top-level SNMP overrides
    snmp_overrides = {}
    if overrides.snmp_version is not None:
        try:
            versions = [
                int(v.strip()) for v in overrides.snmp_version.split(",") if v.strip()
            ]
            if not all(v in (2, 3) for v in versions):
                raise ValueError("SNMP versions must be 2 or 3")
            snmp_overrides["versions"] = versions
        except ValueError as e:
            raise click.BadParameter(f"Invalid SNMP version: {e}") from e
    if overrides.snmp_timeout is not None:
        snmp_overrides["timeout"] = overrides.snmp_timeout
    if overrides.snmp_retries is not None:
        snmp_overrides["retries"] = overrides.snmp_retries

    # Apply SNMP overrides via replace()
    new_v2 = replace(cfg.snmp.v2, **v2_overrides) if v2_overrides else cfg.snmp.v2
    new_v3 = replace(cfg.snmp.v3, **v3_overrides) if v3_overrides else cfg.snmp.v3
    new_snmp = replace(cfg.snmp, v2=new_v2, v3=new_v3, **snmp_overrides)

    # Collect NetBox overrides
    netbox_overrides = {}
    if overrides.netbox_api_token is not None:
        netbox_overrides["api_token"] = overrides.netbox_api_token
    if overrides.netbox_url is not None:
        netbox_overrides["instance_url"] = overrides.netbox_url
    if overrides.netbox_tags is not None:
        netbox_overrides["tags"] = [t.strip() for t in overrides.netbox_tags.split(",") if t.strip()]
    if overrides.netbox_tags_exclude is not None:
        netbox_overrides["tags_exclude"] = [t.strip() for t in overrides.netbox_tags_exclude.split(",") if t.strip()]

    new_netbox = replace(cfg.netbox, **netbox_overrides) if netbox_overrides else cfg.netbox

    # Collect top-level overrides
    top_overrides = {}
    if overrides.parallel_jobs is not None:
        top_overrides["parallel_jobs"] = overrides.parallel_jobs
    if overrides.log_level is not None:
        top_overrides["log_level"] = overrides.log_level.upper()
    if overrides.model_excludes is not None:
        top_overrides["model_name_excludes"] = [m.strip() for m in overrides.model_excludes.split(",") if m.strip()]

    return replace(cfg, snmp=new_snmp, netbox=new_netbox, **top_overrides)


def collect_device_inventory(netbox, config, monitor, device_name=None):
    """Collect inventory data for devices matching the filter."""
    if device_name:
        try:
            devices = list(netbox.api.dcim.devices.filter(name=device_name))
        except RequestError as e:
            logger.error(f"NetBox API error while fetching device '{device_name}': {e}")
            return {}

        if not devices:
            logger.error(f"Device {device_name} not found in NetBox.")
            return {}

        logger.info(f"Processing single device: {device_name}")
        dev, inventory = monitor.process_device(devices[0])
        return {dev: inventory}
    else:
        netbox_filter = {
            "status": "active",
            "has_primary_ip": True,
        }
        if config.netbox.tags:
            netbox_filter["tag"] = config.netbox.tags
        if config.netbox.tags_exclude:
            netbox_filter["tag__n"] = config.netbox.tags_exclude

        try:
            device_list = list(netbox.api.dcim.devices.filter(**netbox_filter))
        except RequestError as e:
            logger.error(f"NetBox API error while fetching devices: {e}")
            return {}

        if not device_list:
            logger.warning("No devices matched the NetBox filter.")
            return {}

        processed_devices_inventory_list = Parallel(
            n_jobs=config.parallel_jobs,
            verbose=10,
            backend="multiprocessing",
        )(
            delayed(monitor.process_device)(i)
            for i in device_list
        )

        devices_snmp_inventory_dict = {}
        for dev, inventory in processed_devices_inventory_list:
            devices_snmp_inventory_dict[dev] = inventory

        return devices_snmp_inventory_dict


def process_device_inventory(netbox, monitor, devices_snmp_inventory_dict):
    """Process collected inventory data and update Netbox."""
    for nb_device, items in devices_snmp_inventory_dict.items():
        if items["inventory"]:
            sn_inventory_dict = monitor.prepare_device_sn_inventory(
                items["inventory"]
            )

            failures = 0
            for _, snmp_item in sn_inventory_dict.items():
                result = netbox.create_or_update_probe(nb_device, snmp_item)
                if result != 0:
                    failures += 1

            # Store success status for accurate reporting in final summary
            items["update_success"] = (failures == 0)
            items["failure_count"] = failures

            platform = nb_device.platform.name if nb_device.platform else ""
            manufacturer = (
                nb_device.device_type.manufacturer.name
                if nb_device.device_type and nb_device.device_type.manufacturer
                else ""
            )

            if failures:
                logger.warning(
                    f"{nb_device.name}: {failures} probe(s) failed to update, "
                    f"platform: {platform}, manufacturer: {manufacturer}"
                )
            else:
                logger.success(
                    f"Inventory processed for device: {nb_device.name}, "
                    f"platform: {platform}, manufacturer: {manufacturer}"
                )
        else:
            platform = nb_device.platform.name if nb_device.platform else ""
            manufacturer = (
                nb_device.device_type.manufacturer.name
                if nb_device.device_type and nb_device.device_type.manufacturer
                else ""
            )
            logger.error(
                f"No inventory found for device: {nb_device.name}, "
                f"platform: {platform}, manufacturer: {manufacturer}"
            )
            logger.error(
                f"Error: {items.get('error', '')}, "
                f"Error message: {items.get('error_message', '')}"
            )


@click.command()
@click.option("--env-file", default=None, type=click.Path(exists=True), help="Path to environment file (default: .env)")
@click.option("-d", "--device", help="Process inventory for a specific device by name")
@click.option("--snmp-version", default=None, help="Comma-separated SNMP versions to try (e.g. '3,2')")
@click.option("--snmp-community", default=None, help="SNMPv2 community string (WARNING: visible in process listings; use SNMP_COMMUNITY env var instead)")
@click.option("--snmp-user", default=None, help="SNMPv3 username")
@click.option("--snmp-auth", default=None, help="SNMPv3 auth password (WARNING: visible in process listings; use SNMP_AUTH env var instead)")
@click.option("--snmp-auth-method", default=None, help="SNMPv3 auth protocol (MD5/SHA)")
@click.option("--snmp-privacy", default=None, help="SNMPv3 privacy password (WARNING: visible in process listings; use SNMP_PRIVACY env var instead)")
@click.option("--snmp-privacy-method", default=None, help="SNMPv3 privacy protocol (DES/AES)")
@click.option("--snmp-security", default=None, help="SNMPv3 security level")
@click.option("--snmp-timeout", default=None, type=click.IntRange(min=1), help="SNMP timeout in seconds (default: 1)")
@click.option("--snmp-retries", default=None, type=click.IntRange(min=1), help="SNMP retry count (default: 1)")
@click.option("--netbox-api-token", default=None, help="NetBox API token (WARNING: visible in process listings; use NETBOX_API_TOKEN env var instead)")
@click.option("--netbox-url", default=None, help="NetBox instance URL")
@click.option("--netbox-tags", default=None, help="Comma-separated tags to filter devices")
@click.option("--netbox-tags-exclude", default=None, help="Comma-separated tags to exclude devices")
@click.option("--parallel-jobs", default=None, type=click.IntRange(min=1), help="Number of parallel processing jobs")
@click.option("--log-level", default=None, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False), help="Logging level")
@click.option("--model-excludes", default=None, help="Comma-separated model names to exclude from inventory")
@click.option("--debug-dump", default=None, type=click.Path(), help="Dump raw SNMP data to JSON file")
def main(
    env_file,
    device,
    snmp_version,
    snmp_community,
    snmp_user,
    snmp_auth,
    snmp_auth_method,
    snmp_privacy,
    snmp_privacy_method,
    snmp_security,
    snmp_timeout,
    snmp_retries,
    netbox_api_token,
    netbox_url,
    netbox_tags,
    netbox_tags_exclude,
    parallel_jobs,
    log_level,
    model_excludes,
    debug_dump,
):
    """Inventory monitor script to collect and store device inventory information.

    If no device is specified, processes all devices matching the NetBox filter.
    """
    config = _build_config(CliOverrides(
        env_file=env_file,
        snmp_version=snmp_version,
        snmp_community=snmp_community,
        snmp_user=snmp_user,
        snmp_auth=snmp_auth,
        snmp_auth_method=snmp_auth_method,
        snmp_privacy=snmp_privacy,
        snmp_privacy_method=snmp_privacy_method,
        snmp_security=snmp_security,
        snmp_timeout=snmp_timeout,
        snmp_retries=snmp_retries,
        netbox_api_token=netbox_api_token,
        netbox_url=netbox_url,
        netbox_tags=netbox_tags,
        netbox_tags_exclude=netbox_tags_exclude,
        parallel_jobs=parallel_jobs,
        log_level=log_level,
        model_excludes=model_excludes,
    ))

    # Reconfigure logger with resolved log level from .env + CLI
    setup_logger(config.log_level)

    netbox = Netbox(config=config.netbox)
    monitor = InventoryMonitor(config=config)

    # Collect inventory data (either for all devices or just one)
    devices_snmp_inventory_dict = collect_device_inventory(netbox, config, monitor, device)

    # Optionally backup collected data
    if debug_dump is not None:
        try:
            # Resolve paths and validate
            dump_path = os.path.realpath(debug_dump)
            cwd = os.path.realpath(os.getcwd())

            # Use commonpath for robust path validation (handles edge cases better than startswith)
            try:
                common = os.path.commonpath([cwd, dump_path])
                if common != cwd:
                    logger.error(f"Debug dump path must be within the working directory: {cwd}")
                    dump_path = None
            except ValueError:
                # Different drives on Windows, or other path incompatibility
                logger.error(f"Debug dump path must be within the working directory: {cwd}")
                dump_path = None

            # Additional validations
            if dump_path and os.path.exists(dump_path) and os.path.isdir(dump_path):
                logger.error(f"Debug dump path cannot be a directory: {dump_path}")
                dump_path = None
            elif dump_path and not dump_path.endswith(".json"):
                logger.error(f"Debug dump path must have a .json extension: {dump_path}")
                dump_path = None

            # Write file if all validations passed
            if dump_path:
                serializable = {
                    (dev.name if hasattr(dev, "name") else str(dev)): inventory
                    for dev, inventory in devices_snmp_inventory_dict.items()
                }
                # Use 'x' mode (exclusive creation) to prevent race conditions
                try:
                    with open(dump_path, "x") as f:
                        json.dump(serializable, f, indent=2, default=str)
                    logger.info(f"Debug data dumped to {dump_path}")
                except FileExistsError:
                    logger.error(f"Debug dump file already exists: {dump_path}")
        except (IOError, OSError, TypeError) as e:
            logger.error(f"Failed to write debug dump: {e}")

    # Process inventory and update Netbox
    if not devices_snmp_inventory_dict:
        logger.warning("No inventory data collected, nothing to process.")
    else:
        process_device_inventory(netbox, monitor, devices_snmp_inventory_dict)
        total = len(devices_snmp_inventory_dict)
        succeeded = sum(
            1 for items in devices_snmp_inventory_dict.values()
            if items.get("inventory") and items.get("update_success", False)
        )
        failed = total - succeeded
        logger.info(
            f"Processing complete: {total} devices total, "
            f"{succeeded} succeeded, {failed} failed"
        )


if __name__ == "__main__":
    main()
