"""Configuration and encrypted credential storage for managebac-fetch.

This module provides a small, typed API for storing and retrieving user
configuration for the CLI.

Goals
-----
- Store credentials outside the repository in a user config directory.
- Never write plaintext credentials to disk.
- Encrypt secrets using Fernet symmetric encryption.
- Keep the implementation simple and explicit.

Layout
------
The configuration directory contains:

- ``config.json``:
    General CLI settings and encrypted credential payload.

- ``key.bin``:
    The Fernet encryption key used to encrypt/decrypt credentials.

Security notes
--------------
This protects credentials at rest on disk against casual disclosure.
Because the encryption key is stored on the same machine, this is not a full
secret-management solution against a determined local attacker. It is still
substantially better than storing credentials in plaintext.

On Windows, the config directory defaults to:

- ``%APPDATA%\\managebac-fetch``

On other platforms:

- ``~/.config/managebac-fetch``
"""

from __future__ import annotations

import json
import os
from contextlib import suppress
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any

from cryptography.fernet import Fernet, InvalidToken

APP_NAME = "managebac-fetch"
CONFIG_FILENAME = "config.json"
KEY_FILENAME = "key.bin"


class ConfigError(RuntimeError):
    """Raised when configuration cannot be read, written, or decrypted."""


@dataclass(slots=True)
class Credentials:
    """ManageBac credentials stored by the CLI."""

    school_code: str
    username: str
    password: str

    def validate(self) -> None:
        """Validate that all credential fields are present."""
        if not self.school_code.strip():
            raise ValueError("school_code must be a non-empty string")
        if not self.username.strip():
            raise ValueError("username must be a non-empty string")
        if not self.password:
            raise ValueError("password must be a non-empty string")


@dataclass(slots=True)
class AppConfig:
    """Persistent CLI configuration."""

    output_dir: str | None = None
    workers: int = 4
    sync: bool = True
    credentials_saved: bool = False

    def validate(self) -> None:
        """Validate basic configuration invariants."""
        if self.output_dir is not None:
            normalized_output_dir = normalize_output_dir(self.output_dir)
            validate_output_dir(normalized_output_dir)
            self.output_dir = normalized_output_dir

        if self.workers < 1:
            raise ValueError("workers must be at least 1")


def get_config_dir() -> Path:
    """Return the platform-appropriate config directory."""
    if os.name == "nt":
        appdata = os.environ.get("APPDATA")
        if appdata:
            return Path(appdata) / APP_NAME

    xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
    if xdg_config_home:
        return Path(xdg_config_home) / APP_NAME

    return Path.home() / ".config" / APP_NAME


def get_config_path() -> Path:
    """Return the full path to the config file."""
    return get_config_dir() / CONFIG_FILENAME


def get_key_path() -> Path:
    """Return the full path to the encryption key file."""
    return get_config_dir() / KEY_FILENAME


def ensure_config_dir() -> Path:
    """Create the config directory if needed and return it."""
    config_dir = get_config_dir()
    config_dir.mkdir(parents=True, exist_ok=True)
    return config_dir


def normalize_output_dir(output_dir: str | os.PathLike[str]) -> str:
    """Normalize a user-provided output directory to an absolute path string."""
    raw_path = Path(output_dir).expanduser()

    if not raw_path.is_absolute():
        raw_path = raw_path.resolve()

    return str(raw_path)


def validate_output_dir(output_dir: str | os.PathLike[str]) -> Path:
    """Validate that the output directory is a usable absolute filesystem path."""
    path = Path(output_dir).expanduser()

    if not path.is_absolute():
        raise ValueError("output_dir must be an absolute path")

    if path.exists() and not path.is_dir():
        raise ValueError(f"output_dir exists but is not a directory: {path}")

    try:
        path.mkdir(parents=True, exist_ok=True)
    except OSError as exc:
        raise ValueError(
            f"output_dir could not be created or accessed: {path}"
        ) from exc

    return path


def _generate_key() -> bytes:
    """Generate a new Fernet key."""
    return Fernet.generate_key()


def _load_or_create_key() -> bytes:
    """Load the encryption key from disk, creating it if necessary."""
    ensure_config_dir()
    key_path = get_key_path()

    if key_path.exists():
        key = key_path.read_bytes()
        if not key:
            raise ConfigError(f"Encryption key file is empty: {key_path}")
        return key

    key = _generate_key()
    key_path.write_bytes(key)

    with suppress(OSError):
        os.chmod(key_path, 0o600)

    return key


def _get_cipher() -> Fernet:
    """Return a Fernet cipher instance for the local config."""
    return Fernet(_load_or_create_key())


def _read_raw_config() -> dict[str, Any]:
    """Read the raw config JSON from disk.

    Returns an empty config dictionary if the file does not yet exist.
    """
    config_path = get_config_path()
    if not config_path.exists():
        return {}

    try:
        raw_text = config_path.read_text(encoding="utf-8")
        if not raw_text.strip():
            return {}
        data = json.loads(raw_text)
    except json.JSONDecodeError as exc:
        raise ConfigError(f"Invalid JSON in config file: {config_path}") from exc
    except OSError as exc:
        raise ConfigError(f"Could not read config file: {config_path}") from exc

    if not isinstance(data, dict):
        raise ConfigError("Config file must contain a JSON object")

    return data


def _write_raw_config(data: dict[str, Any]) -> None:
    """Write the raw config JSON to disk."""
    ensure_config_dir()
    config_path = get_config_path()

    try:
        config_path.write_text(
            json.dumps(data, indent=2, sort_keys=True) + "\n",
            encoding="utf-8",
        )
    except OSError as exc:
        raise ConfigError(f"Could not write config file: {config_path}") from exc


def load_app_config() -> AppConfig:
    """Load persistent non-secret CLI settings."""
    raw = _read_raw_config()

    config_data = raw.get("app", {})
    if config_data is None:
        config_data = {}

    if not isinstance(config_data, dict):
        raise ConfigError("'app' section in config must be an object")

    try:
        raw_output_dir = config_data.get("output_dir")
        normalized_output_dir = (
            normalize_output_dir(raw_output_dir) if raw_output_dir is not None else None
        )

        config = AppConfig(
            output_dir=normalized_output_dir,
            workers=int(config_data.get("workers", 4)),
            sync=bool(config_data.get("sync", True)),
            credentials_saved=bool(config_data.get("credentials_saved", False)),
        )
    except (TypeError, ValueError) as exc:
        raise ConfigError("Invalid values in app configuration") from exc

    config.validate()
    return config


def save_app_config(config: AppConfig) -> None:
    """Persist non-secret CLI settings."""
    config.validate()

    raw = _read_raw_config()
    raw["app"] = asdict(config)
    _write_raw_config(raw)


def has_saved_credentials() -> bool:
    """Return True if encrypted credentials are present in config."""
    raw = _read_raw_config()
    credentials_blob = raw.get("credentials")
    return isinstance(credentials_blob, str) and bool(credentials_blob.strip())


def save_credentials(credentials: Credentials) -> None:
    """Encrypt and persist ManageBac credentials."""
    credentials.validate()

    cipher = _get_cipher()
    payload = json.dumps(asdict(credentials), separators=(",", ":")).encode("utf-8")
    encrypted = cipher.encrypt(payload).decode("utf-8")

    raw = _read_raw_config()
    raw["credentials"] = encrypted

    app_config = load_app_config()
    app_config.credentials_saved = True
    raw["app"] = asdict(app_config)

    _write_raw_config(raw)


def load_credentials() -> Credentials:
    """Load and decrypt saved ManageBac credentials."""
    raw = _read_raw_config()
    encrypted = raw.get("credentials")

    if not isinstance(encrypted, str) or not encrypted.strip():
        raise ConfigError(
            "No saved credentials found. Run the configure command first."
        )

    cipher = _get_cipher()

    try:
        decrypted = cipher.decrypt(encrypted.encode("utf-8"))
    except InvalidToken as exc:
        raise ConfigError(
            "Could not decrypt saved credentials. Your key or config may be corrupted."
        ) from exc

    try:
        payload = json.loads(decrypted.decode("utf-8"))
    except json.JSONDecodeError as exc:
        raise ConfigError("Saved credential payload is invalid JSON") from exc

    if not isinstance(payload, dict):
        raise ConfigError("Saved credential payload must be an object")

    credentials = Credentials(
        school_code=str(payload.get("school_code", "")),
        username=str(payload.get("username", "")),
        password=str(payload.get("password", "")),
    )
    credentials.validate()
    return credentials


def clear_credentials() -> None:
    """Remove saved encrypted credentials from config."""
    raw = _read_raw_config()
    raw.pop("credentials", None)

    app_config = load_app_config()
    app_config.credentials_saved = False
    raw["app"] = asdict(app_config)

    _write_raw_config(raw)


def clear() -> None:
    """Delete all saved configuration and key files."""
    config_dir = get_config_dir()
    config_path = get_config_path()
    key_path = get_key_path()

    if config_path.exists():
        config_path.unlink()
    if key_path.exists():
        key_path.unlink()

    with suppress(OSError):
        if config_dir.exists() and not any(config_dir.iterdir()):
            config_dir.rmdir()


def configure(
    *,
    school_code: str,
    username: str,
    password: str,
    output_dir: str | None = None,
    workers: int = 4,
    sync: bool = True,
) -> None:
    """Create or replace the stored CLI configuration."""
    credentials = Credentials(
        school_code=school_code,
        username=username,
        password=password,
    )
    credentials.validate()

    normalized_output_dir = (
        normalize_output_dir(output_dir) if output_dir is not None else None
    )

    app_config = AppConfig(
        output_dir=normalized_output_dir,
        workers=workers,
        sync=sync,
        credentials_saved=True,
    )
    app_config.validate()

    ensure_config_dir()
    _load_or_create_key()
    save_credentials(credentials)
    save_app_config(app_config)


def get_config_summary() -> dict[str, Any]:
    """Return a safe summary of current configuration for display to users."""
    config = load_app_config()
    credentials_present = has_saved_credentials()

    school_code: str | None = None
    username: str | None = None
    if credentials_present:
        try:
            creds = load_credentials()
            school_code = creds.school_code
            username = creds.username
        except ConfigError:
            school_code = None
            username = None

    return {
        "config_dir": str(get_config_dir()),
        "config_path": str(get_config_path()),
        "key_path": str(get_key_path()),
        "credentials_saved": credentials_present,
        "school_code": school_code,
        "username": username,
        "output_dir": config.output_dir,
        "workers": config.workers,
        "sync": config.sync,
    }
