"""Shared helpers for live OPNsense diagnostic scripts."""

from __future__ import annotations

import json
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass
from functools import cache
from pathlib import Path
import sys
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    import aiohttp
    from aiopnsense import OPNsenseClient


SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_ENV_FILE = SCRIPT_DIR / "aiopnsense.env"
DOCUMENTED_DEFAULT_ENV_FILE = Path("scripts/aiopnsense.env")

_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
_FALSE_VALUES = frozenset({"0", "false", "no", "off"})
_ENV_KEY_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")


class LiveConfigError(ValueError):
    """Raised when live script configuration is missing or invalid."""


@dataclass(frozen=True)
class LiveConfig:
    """Connection configuration loaded from a live script env file.

    Args:
        url: Base URL of the OPNsense instance.
        api_key: OPNsense API key used as HTTP basic auth username.
        api_secret: OPNsense API secret used as HTTP basic auth password.
        verify_ssl: Whether aiohttp should verify the server TLS certificate.
    """

    url: str
    api_key: str
    api_secret: str
    verify_ssl: bool


def reexec_with_repo_venv(script_path: Path) -> None:
    """Re-run a live script with the repo virtualenv when launched directly.

    Args:
        script_path (Path): Launched script used to locate the repository virtualenv.
    """
    if os.environ.get("AIOPNSENSE_LIVE_SCRIPT_BOOTSTRAPPED") == "1":
        return

    venv_dir = script_path.resolve().parents[1] / ".venv"
    venv_python = venv_dir / "bin" / "python"
    if not venv_python.exists() or Path(sys.prefix).resolve() == venv_dir.resolve():
        return

    os.environ["AIOPNSENSE_LIVE_SCRIPT_BOOTSTRAPPED"] = "1"
    os.execv(str(venv_python), [str(venv_python), str(script_path), *sys.argv[1:]])


def resolve_env_file_argument(env_file: Path) -> Path:
    """Resolve the documented default env path to the script-local file.

    Args:
        env_file (Path): Parsed ``--env-file`` path.

    Returns:
        Path: Absolute script-local default for the documented default, otherwise the
        user-provided path unchanged.
    """
    if env_file == DOCUMENTED_DEFAULT_ENV_FILE:
        return DEFAULT_ENV_FILE
    return env_file


def _strip_inline_comment(value: str) -> str:
    """Remove unquoted inline comments from an env file value.

    Args:
        value (str): Raw right-hand side from an env-file assignment.

    Returns:
        str: Value text with an unquoted ``#`` comment removed.
    """
    quote: str | None = None
    for index, char in enumerate(value):
        if char in {"'", '"'}:
            if quote == char:
                quote = None
            elif quote is None:
                quote = char
        elif char == "#" and quote is None and (index == 0 or value[index - 1].isspace()):
            return value[:index].rstrip()
    return value.strip()


def _unquote(value: str) -> str:
    """Remove matching single or double quotes around a value.

    Args:
        value (str): Env-file value that may have matching surrounding quotes.

    Returns:
        str: Unquoted value when quotes match, otherwise the original value.
    """
    if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
        return value[1:-1]
    return value


def load_env_file(path: Path = DEFAULT_ENV_FILE) -> dict[str, str]:
    """Load a simple ``KEY=value`` env file.

    Args:
        path (Path): Env file containing ``KEY=value`` connection settings.

    Returns:
        dict[str, str]: Mapping of env variable names to values.


    Raises:
        LiveConfigError: Raised when the file is missing or contains malformed lines.
    """
    if not path.exists():
        raise LiveConfigError(f"Env file not found: {path}")
    if not path.is_file():
        raise LiveConfigError(f"Env file is not a file: {path}")

    values: dict[str, str] = {}
    try:
        raw_lines = path.read_text(encoding="utf-8").splitlines()
    except OSError as exc:
        raise LiveConfigError(f"Unable to read env file: {path}") from exc

    for line_number, raw_line in enumerate(raw_lines, 1):
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        if "=" not in line:
            raise LiveConfigError(f"Malformed env file line {line_number}")
        key, raw_value = line.split("=", 1)
        key = key.strip()
        if not key or _ENV_KEY_PATTERN.fullmatch(key) is None:
            raise LiveConfigError(f"Malformed env file line {line_number}")
        value = _unquote(_strip_inline_comment(raw_value))
        values[key] = value
    return values


def get_env_value(values: Mapping[str, str], suffix: str, *, required: bool = True) -> str | None:
    """Return a canonical ``AIOPNSENSE_*`` value with ``OPNSENSE_*`` fallback.

    Args:
        values (Mapping[str, str]): Parsed env-file settings.
        suffix (str): Setting suffix after the ``AIOPNSENSE_`` prefix.
        required (bool): Whether a missing or blank setting raises an error.

    Returns:
        str | None: Configured value or ``None`` when missing and not required.


    Raises:
        LiveConfigError: Raised when a required value is missing.
    """
    canonical_key = f"AIOPNSENSE_{suffix}"
    fallback_key = f"OPNSENSE_{suffix}"
    if canonical_key in values:
        canonical_value = values[canonical_key]
        if required and not canonical_value.strip():
            raise LiveConfigError(f"Missing required env value: {canonical_key}")
        return canonical_value
    fallback_value = values.get(fallback_key)
    if fallback_value is None:
        if required:
            raise LiveConfigError(f"Missing required env value: {canonical_key}")
        return None
    if required and not fallback_value.strip():
        raise LiveConfigError(f"Missing required env value: {canonical_key}")
    return fallback_value


def parse_bool(value: str, name: str) -> bool:
    """Parse a documented boolean env value.

    Args:
        value (str): Raw boolean setting value.
        name (str): Setting name included in validation errors.

    Returns:
        bool: Parsed boolean.


    Raises:
        LiveConfigError: Raised when the value is not a documented boolean spelling.
    """
    normalized = value.strip().lower()
    if normalized in _TRUE_VALUES:
        return True
    if normalized in _FALSE_VALUES:
        return False
    raise LiveConfigError(f"{name} must be one of: 1, true, yes, on, 0, false, no, off")


def load_live_config(path: Path = DEFAULT_ENV_FILE) -> LiveConfig:
    """Load live OPNsense connection configuration.

    Args:
        path (Path): Env file containing live connection settings.

    Returns:
        LiveConfig: Configuration with URL, credentials, and TLS verification setting.
    """
    values = load_env_file(path)
    verify_ssl_raw = get_env_value(values, "VERIFY_SSL", required=False)
    if verify_ssl_raw is None:
        verify_ssl_raw = "true"
    return LiveConfig(
        url=str(get_env_value(values, "URL")),
        api_key=str(get_env_value(values, "API_KEY")),
        api_secret=str(get_env_value(values, "API_SECRET")),
        verify_ssl=parse_bool(verify_ssl_raw, name="AIOPNSENSE_VERIFY_SSL"),
    )


def format_json(payload: Any) -> str:
    """Return stable pretty JSON for terminal and file output.

    Args:
        payload (Any): Data to serialize as indented JSON.

    Returns:
        str: Pretty JSON string ending with a newline.
    """
    return json.dumps(payload, indent=2, sort_keys=True, default=str) + "\n"


def write_output(payload: Any, output_path: Path | None = None) -> None:
    """Print JSON output and optionally mirror it to a file.

    Args:
        payload (Any): Data to print as formatted JSON.
        output_path (Path | None): Optional file that receives the same JSON output.
    """
    rendered = format_json(payload)
    print(rendered, end="")
    if output_path is not None:
        output_path.write_text(rendered, encoding="utf-8")


@cache
def _get_client_class() -> type["OPNsenseClient"]:
    """Return the lazily imported OPNsense client class.

    Returns:
        type['OPNsenseClient']: Lazily imported client class used by live scripts.
    """
    from aiopnsense import OPNsenseClient

    return OPNsenseClient


def create_client(config: LiveConfig, session: aiohttp.ClientSession) -> "OPNsenseClient":
    """Create an OPNsense client for live scripts.

    Args:
        config (LiveConfig): Connection URL, credentials, and TLS settings.
        session (aiohttp.ClientSession): HTTP session used for client requests.

    Returns:
        OPNsenseClient: Configured OPNsense client.
    """
    client_class = _get_client_class()
    return client_class(
        url=config.url,
        username=config.api_key,
        password=config.api_secret,
        session=session,
        opts={"verify_ssl": config.verify_ssl},
        throw_errors=True,
    )
