# Copyright (c) 2018 CloudZero - ALL RIGHTS RESERVED - PROPRIETARY AND CONFIDENTIAL
# Unauthorized copying of this file and/or project, via any medium is strictly prohibited.
# Direct all questions to legal@cloudzero.com
#

"""
Main settings module for CZ-OPS CLI.

This module contains the Settings class and related Pydantic models for managing
CLI configuration and preferences.
"""

import contextlib
import json
import os
import tempfile
from pathlib import Path
from typing import Any

import yaml
from pydantic import Field, PositiveInt, ValidationError, field_validator
from pydantic.fields import FieldInfo
from pydantic_core import PydanticCustomError
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict

from src.settings.constants import (
    _CZOPS_SETTINGS_FILENAME,
    _LEGACY_SETTINGS_FILENAME,
    CLI_VERSION,
    CURRENT_SETTINGS_VERSION,
    get_minimum_required_version,
)
from src.settings.migrations import (
    SettingsMigrationError,
    SettingsTooOldError,
    backup_and_reset_settings,
    load_and_migrate_settings,
)
from src.utils.data import remove_nones
from src.utils.time import isodatetimenow


def get_formated_error_messages(error_object: ValidationError) -> list:
    """
    Format the error messages from a Pydantic ValidationError
    """
    error_messages = []
    missing_fields = set()
    for e in error_object.errors():
        if e["type"] == "missing":
            missing_fields.update(set(e["loc"]))
        else:
            error_messages.append(f"{e['msg']}")
    if missing_fields:
        error_messages.append(f"{len(missing_fields)} Missing configuration elements: {', '.join(missing_fields)}")
    return error_messages


class YamlConfigSettingsSource(PydanticBaseSettingsSource):
    """
    Load legacy CZOPS CLI settings from the default location (~/.cloudzero/ops_config.yaml)
    """

    def __init__(self, settings_cls: type[BaseSettings]):
        super().__init__(settings_cls)
        self.encoding = self.config.get("env_file_encoding")

    def _clean_legacy_v2_endpoint(self, endpoint: str | None) -> str | None:
        """Clean legacy v2 endpoint by removing v2/ suffix."""
        if not endpoint:
            return None
        # Remove various forms of v2 suffix
        if endpoint.endswith("/v2/"):
            endpoint = endpoint[:-4]
        elif endpoint.endswith("v2/") or endpoint.endswith("/v2"):
            endpoint = endpoint[:-3]
        # Ensure endpoint ends with /
        if not endpoint.endswith("/"):
            endpoint += "/"
        return endpoint

    def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
        return None, "NA", False

    def __call__(self, *args, **kwargs):
        # If a new settings file exists, no need to migrate old yaml settings
        if Path(_CZOPS_SETTINGS_FILENAME).expanduser().exists():
            return {}

        # If no legacy settings file exists, return nothing
        if not Path(_LEGACY_SETTINGS_FILENAME).expanduser().exists():
            return {}

        local_settings = yaml.safe_load(Path(_LEGACY_SETTINGS_FILENAME).expanduser().read_text(self.encoding))
        if local_settings.get("version", 0) <= 21:
            final_settings = {
                "version": CURRENT_SETTINGS_VERSION,
                "source": str(Path(_LEGACY_SETTINGS_FILENAME).expanduser()),
                "telemetry_enabled": True,  # New fields for v25+
                "auto_update_check": True,  # New fields for v26+
                "session_data": {},  # Renamed from session in v26
            }
        else:
            final_settings = {}  # new settings, no need to migrate old ones

        return remove_nones(final_settings)


class JsonConfigSettingsSource(PydanticBaseSettingsSource):
    """
    Load CZOPS CLI settings from the default location (~/.cloudzero/czops.json)
    """

    json_settings: dict[str, Any] = {}
    encoding: str = "utf-8"

    def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
        field_value = self.json_settings.get(field_name)
        return field_value, field_name, False

    def prepare_field_value(self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool) -> Any:
        return value

    def __call__(self) -> dict[str, Any]:
        d: dict[str, Any] = {}
        for field_name, field in self.settings_cls.model_fields.items():
            field_value, field_key, value_is_complex = self.get_field_value(field, field_name)
            field_value = self.prepare_field_value(field_name, field, field_value, value_is_complex)
            if field_value is not None:
                d[field_key] = field_value
        return d

    def __init__(self, settings_cls: type[BaseSettings]):
        super().__init__(settings_cls)
        self.encoding = self.config.get("env_file_encoding")

        try:
            # Try to load and potentially migrate settings
            migrated_settings = load_and_migrate_settings()
            if migrated_settings:
                self.json_settings = migrated_settings
            else:
                # No migration needed, load normally
                self.json_settings = json.loads(Path(_CZOPS_SETTINGS_FILENAME).expanduser().read_text(self.encoding))

            self.json_settings["source"] = str(Path(_CZOPS_SETTINGS_FILENAME).expanduser())
        except FileNotFoundError:
            self.json_settings: dict[str, Any] = {}
        except SettingsTooOldError as e:
            # Handle settings too old error by backing up and resetting
            print(f"\n❌ Settings Error: {str(e)}")
            print("\nYour settings are too old to migrate automatically.")
            backup_path = backup_and_reset_settings("Settings too old to migrate")
            if backup_path:
                print(f"\nA backup of your old settings was created at: {backup_path}")
                print("The outdated settings file has been removed. A fresh configuration will be created.")
            else:
                print("\nNo existing settings file was found to back up.")
            # Initialize with empty settings to allow fresh configuration
            self.json_settings: dict[str, Any] = {}
        except SettingsMigrationError:
            # Let other migration errors propagate - they'll be caught by the main app
            raise


class ApiV4Settings(BaseSettings):
    """
    API v4 Settings for the CZOPS CLI
    """

    # Allow overrides via environment variables with prefix CZOPS_V4_
    # e.g., CZOPS_V4_ENDPOINT, CZOPS_V4_AUTH__CLIENT_ID, CZOPS_V4_AUTH__OKTA_DOMAIN
    # Nested env keys use double underscores by default in pydantic-settings
    model_config = SettingsConfigDict(env_prefix="CZOPS_V4_", env_nested_delimiter="__", extra="ignore")

    endpoint: str = "https://5dlbdxtri1.execute-api.us-east-1.amazonaws.com/live/czops/"
    auth: dict[str, str] = {
        "CLIENT_ID": "0oa1mpa8eqizsCscq358",
        "OKTA_DOMAIN": "cloudzero.okta.com",
        "LOGIN_REDIRECT_URI": "http://127.0.0.1:8042/callback",
    }


class Settings(BaseSettings):
    """
    All Settings for the CZOPS CLI
    """

    model_config = SettingsConfigDict(env_file_encoding="utf-8")

    version: PositiveInt = CURRENT_SETTINGS_VERSION
    source: str = "default"
    last_update: str | None = None
    hidden_features: bool = False
    api_v4: ApiV4Settings = Field(default_factory=ApiV4Settings)
    # Legacy field for backward compatibility (renamed to session_data in v26)
    session: dict[str, Any] | None = None
    # New fields added in migrations
    telemetry_enabled: bool = True  # Added in v25
    auto_update_check: bool = True  # Added in v26
    session_data: dict[str, Any] = {}  # Added in v26 (renamed from session)

    @field_validator("version")
    @classmethod
    def valid_version(cls, v: int):
        """Validate that the settings version is within the acceptable range."""
        min_required = get_minimum_required_version(CLI_VERSION)

        if v < min_required:
            raise PydanticCustomError(
                "outdated_version",
                "Settings version {v} is too old for CLI {cli_version}. Minimum required: {min_required}",
                {"v": v, "cli_version": CLI_VERSION, "min_required": min_required},
            )

        if v > CURRENT_SETTINGS_VERSION:
            raise PydanticCustomError(
                "future_version",
                "Settings version {v} is newer than this CLI supports. Maximum supported: {max_version}",
                {"v": v, "max_version": CURRENT_SETTINGS_VERSION},
            )

        return v

    def __init__(self, **data):
        """Initialize settings with migration support for session -> session_data."""
        # Handle session -> session_data migration for backward compatibility
        if "session" in data and data["session"] is not None and "session_data" not in data:
            data["session_data"] = data.pop("session")
        elif "session" in data and data["session"] is None:
            data.pop("session", None)

        # Initialize nested settings objects, preserving any loaded values
        if "api_v4" not in data:
            data["api_v4"] = ApiV4Settings()
        elif isinstance(data["api_v4"], dict):
            data["api_v4"] = ApiV4Settings(**data["api_v4"])

        super().__init__(**data)

    def save(self):
        """
        Save the settings to the local config file
        """
        self.last_update = isodatetimenow()

        # Create directory if it doesn't exist
        directory = Path(_CZOPS_SETTINGS_FILENAME).expanduser().parent
        directory.mkdir(parents=True, exist_ok=True)

        # Prepare data for saving
        save_data = json.loads(self.model_dump_json())

        # Remove legacy session field when saving
        save_data.pop("session", None)

        # Ensure version is current when saving
        save_data["version"] = CURRENT_SETTINGS_VERSION

        # Write atomically: serialize to a uniquely-named temp file in the same directory, then
        # rename it over the target. A failure or interruption mid-write can't truncate/corrupt
        # the existing config, and a randomized temp name is safe against concurrent saves.
        target = Path(_CZOPS_SETTINGS_FILENAME).expanduser()
        fd, tmp_name = tempfile.mkstemp(dir=target.parent, prefix=f"{target.name}.", suffix=".tmp")
        try:
            with os.fdopen(fd, "w") as f:
                json.dump(save_data, f, indent=4)
            os.replace(tmp_name, target)
        except BaseException:
            # Don't leave a stray temp file behind if the write/rename fails; the temp may
            # already be gone (e.g. after a successful replace), so ignore a missing-file error.
            with contextlib.suppress(OSError):
                os.unlink(tmp_name)
            raise
        return True

    @property
    def userinfo(self) -> dict[str, Any] | None:
        """
        Get cached user information from Okta.

        Returns:
            dict: User information if available and valid, None otherwise
        """
        try:
            # Import here to avoid circular imports
            from src.auth.simple_auth import get_authenticator

            authenticator = get_authenticator()
            return authenticator.get_cached_userinfo()
        except Exception:
            return None

    @property
    def email(self) -> str | None:
        """
        Get user email from cached userinfo.

        Returns:
            str | None: User email from JWT userinfo or None if not available
        """
        userinfo = self.userinfo
        if userinfo and userinfo.get("email"):
            return userinfo["email"]
        return None

    @classmethod
    def settings_customise_sources(
        cls,
        settings_cls: type[BaseSettings],
        init_settings: PydanticBaseSettingsSource,
        env_settings: PydanticBaseSettingsSource,
        dotenv_settings: PydanticBaseSettingsSource,
        file_secret_settings: PydanticBaseSettingsSource,
    ) -> tuple[PydanticBaseSettingsSource, ...]:
        # Ensure that environment variables override values from JSON/YAML files.
        # Source precedence is defined by order: earlier sources take priority.
        return (
            init_settings,
            env_settings,
            JsonConfigSettingsSource(settings_cls),
            YamlConfigSettingsSource(settings_cls),
            file_secret_settings,
        )
