"""Command-line entrypoint for ai-prompt-runner."""

import argparse
import json
import os
import sys
import tomllib
from dataclasses import asdict
from datetime import datetime, timezone
from hashlib import sha256
from pathlib import Path

from importlib.metadata import PackageNotFoundError, version
from dotenv import load_dotenv

from ai_prompt_runner.core.error_taxonomy import normalize_runtime_error
from ai_prompt_runner.core.errors import PromptRunnerError
from ai_prompt_runner.core.models import PromptRequest
from ai_prompt_runner.core.runner import PromptRunner
from ai_prompt_runner.services.provider_factory import (
    ConfigurationError,
    ProviderSpec,
    create_provider,
    get_provider_spec,
)
from ai_prompt_runner.utils.file_io import write_json, write_markdown

# Define exit codes
EXIT_OK = 0
EXIT_RUNTIME_ERROR = 1
EXIT_USAGE_ERROR = 2

def _get_app_version() -> str:
    """Return installed package version, with a safe fallback for local runs."""
    try:
        return version("ai-prompt-runner")
    except PackageNotFoundError:
        return "0.1.0-dev"

def _non_negative_int(value: str) -> int:
    """Argparse validator: retries must be >= 0."""
    try:
        parsed = int(value)
    except ValueError as exc:
        raise argparse.ArgumentTypeError("retries must be an integer.") from exc

    if parsed < 0:
        raise argparse.ArgumentTypeError("retries must be greater than or equal to 0.")
    return parsed

def _non_blank_text(value: str) -> str:
    """Argparse validator: text input must not be empty or whitespace only."""
    normalized = value.strip()
    if not normalized:
        raise argparse.ArgumentTypeError("prompt must not be empty.")
    return normalized


def _http_url(value: str) -> str:
    """Argparse validator: endpoint must be an http/https URL."""
    normalized = value.strip()
    if not normalized:
        raise argparse.ArgumentTypeError("api-endpoint must not be empty.")
    if not (normalized.startswith("http://") or normalized.startswith("https://")):
        raise argparse.ArgumentTypeError("api-endpoint must start with http:// or https://.")
    return normalized
    
def _positive_int(value: str) -> int:
    """Argparse validator: timeout must be a strictly positive integer."""
    try:
        parsed = int(value)
    except ValueError as exc:
        raise argparse.ArgumentTypeError("timeout must be an integer.") from exc

    if parsed <= 0:
        raise argparse.ArgumentTypeError("timeout must be a positive integer.")
    return parsed


def _non_negative_float(value: str) -> float:
    """Argparse validator: temperature must be a float >= 0."""
    try:
        parsed = float(value)
    except ValueError as exc:
        raise argparse.ArgumentTypeError("temperature must be a float.") from exc

    if parsed < 0:
        raise argparse.ArgumentTypeError("temperature must be greater than or equal to 0.")
    return parsed


def _top_p_float(value: str) -> float:
    """Argparse validator: top-p must be a float in (0, 1]."""
    try:
        parsed = float(value)
    except ValueError as exc:
        raise argparse.ArgumentTypeError("top-p must be a float.") from exc

    if parsed <= 0 or parsed > 1:
        raise argparse.ArgumentTypeError("top-p must be greater than 0 and less than or equal to 1.")
    return parsed

def _prompt_file_text(value: str) -> str:
    """Argparse validator: read prompt text from a file and reject empty content."""
    try:
        content = Path(value).read_text(encoding="utf-8")
    except OSError as exc:
        raise argparse.ArgumentTypeError(f"prompt-file could not be read: {exc}") from exc
    return _non_blank_text(content)

def _resolve_prompt_text(args: argparse.Namespace) -> str:
    """Resolve prompt from CLI args first, then piped stdin."""
    if args.prompt is not None:
        return args.prompt
    if args.prompt_file is not None:
        return args.prompt_file
    if not sys.stdin.isatty():
        return _non_blank_text(sys.stdin.read())
    raise argparse.ArgumentTypeError("prompt is required (use --prompt, --prompt-file, or stdin).")


def _resolve_optional_prompt_text_for_dry_run(args: argparse.Namespace) -> str | None:
    """
    Resolve prompt text for dry-run diagnostics without consuming stdin.

    Dry-run is config/capability validation only, so prompt input is optional.
    """
    if args.prompt is not None:
        return args.prompt
    if args.prompt_file is not None:
        return args.prompt_file
    return None


def _effective_prompt_for_hash(prompt_text: str | None, system_prompt: str | None) -> str | None:
    """
    Build deterministic effective prompt text for request logging.

    Keep this aligned with runner prompt provenance hashing semantics.
    """
    if prompt_text is None:
        return None
    if system_prompt is None:
        return prompt_text
    return f"SYSTEM:\n{system_prompt}\n\nUSER:\n{prompt_text}"


def _prompt_hash_for_log(prompt_text: str | None, system_prompt: str | None) -> str | None:
    """Return SHA256 hash for request diagnostics without logging raw prompt text."""
    effective_prompt = _effective_prompt_for_hash(prompt_text, system_prompt)
    if effective_prompt is None:
        return None
    digest = sha256(effective_prompt.encode("utf-8"))
    return f"sha256:{digest.hexdigest()}"


def _build_run_request_log_payload(
    args: argparse.Namespace,
    prompt_text: str | None,
    effective_config: dict[str, object] | None = None,
) -> dict[str, object]:
    """Build sanitized request diagnostics payload for --log-run-dir artifacts."""
    payload: dict[str, object] = {
        "timestamp_utc": datetime.now(timezone.utc).isoformat(),
        "provider": args.provider,
        "prompt_hash": _prompt_hash_for_log(prompt_text, args.system),
        "request": {
            "prompt_provided": prompt_text is not None,
            "system_provided": args.system is not None,
            "stream": args.stream,
            "temperature": args.temperature,
            "max_tokens": args.max_tokens,
            "top_p": args.top_p,
        },
    }
    if effective_config is not None:
        payload["effective_config"] = effective_config
    return payload


def _create_run_log_dir(log_run_dir: str | None) -> Path | None:
    """
    Create per-run log directory under the provided root.

    Directory shape:
    - <root>/run-YYYYmmddTHHMMSSffffffZ/
    """
    if log_run_dir is None:
        return None
    root = Path(log_run_dir)
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
    run_dir = root / f"run-{timestamp}"
    run_dir.mkdir(parents=True, exist_ok=False)
    return run_dir


def _write_run_request_log(
    run_log_dir: Path | None,
    args: argparse.Namespace,
    prompt_text: str | None,
    effective_config: dict[str, object] | None = None,
) -> None:
    """Write sanitized request diagnostics when --log-run-dir is enabled."""
    if run_log_dir is None:
        return
    write_json(
        run_log_dir / "request.json",
        _build_run_request_log_payload(
            args=args,
            prompt_text=prompt_text,
            effective_config=effective_config,
        ),
    )


def _write_run_response_log(run_log_dir: Path | None, payload: dict) -> None:
    """Write successful normalized response payload for run diagnostics."""
    if run_log_dir is None:
        return
    write_json(run_log_dir / "response.json", payload)


def _runtime_secret_candidates(api_key: str | None) -> tuple[str, ...]:
    """Collect runtime secret values that must never be persisted in logs."""
    candidates: list[str] = []
    for value in (api_key, os.getenv("AI_API_KEY", "").strip()):
        if value:
            candidates.append(value)
    # Preserve order while removing duplicates.
    return tuple(dict.fromkeys(candidates))


def _redact_sensitive_text(text: str, secret_values: tuple[str, ...]) -> str:
    """Redact known secret values from diagnostic text payloads."""
    redacted = text
    for secret in secret_values:
        redacted = redacted.replace(secret, "***redacted***")
    return redacted


def _write_run_error_log(
    run_log_dir: Path | None,
    exc: BaseException,
    provider: str | None,
    secret_values: tuple[str, ...] = (),
) -> None:
    """Write normalized error payload for run diagnostics."""
    if run_log_dir is None:
        return
    error_payload = normalize_runtime_error(exc=exc, provider=provider).to_dict()
    if secret_values and isinstance(error_payload.get("message"), str):
        error_payload["message"] = _redact_sensitive_text(
            str(error_payload["message"]),
            secret_values=secret_values,
        )
    write_json(run_log_dir / "error.json", {"error": error_payload})

def _load_config_file(path_value: str) -> dict:
    """Load and validate an optional TOML config file."""
    try:
        with open(path_value, "rb") as fh:
            data = tomllib.load(fh)
    except OSError as exc:
        raise argparse.ArgumentTypeError(f"config could not be read: {exc}") from exc
    except tomllib.TOMLDecodeError as exc:
        raise argparse.ArgumentTypeError(f"config is not valid TOML: {exc}") from exc

    if not isinstance(data, dict):
        raise argparse.ArgumentTypeError("config root must be a TOML table.")

    section = data.get("ai_prompt_runner", {})
    if not isinstance(section, dict):
        raise argparse.ArgumentTypeError("config section [ai_prompt_runner] must be a table.")
    
    return section


def _merge_runtime_config(args: argparse.Namespace) -> argparse.Namespace:
    """Merge CLI, env, and TOML config with precedence CLI > env > config."""
    config = args.config or {}

    if "api_key" in config:
        raise argparse.ArgumentTypeError("config key 'api_key' is not supported; use AI_API_KEY or --api-key.")

    if not isinstance(config, dict):
        raise argparse.ArgumentTypeError("config must resolve to a mapping.")

    allowed_keys = {
        "provider",
        "api_endpoint",
        "api_model",
        "temperature",
        "max_tokens",
        "top_p",
        "timeout",
        "retries",
        "out_json",
        "out_md",
        "log_run_dir",
    }
    unknown_keys = sorted(set(config.keys()) - allowed_keys)
    if unknown_keys:
        raise argparse.ArgumentTypeError(f"unsupported config keys: {unknown_keys}")

    def _pick_no_env(cli_value, config_key: str, default):
        if cli_value is not None:
            return cli_value
        if config_key in config:
            return config[config_key]
        return default

    def _pick_with_env(cli_value, env_name: str, config_key: str, default=None):
        if cli_value is not None:
            return cli_value
        env_value = os.getenv(env_name, "").strip()
        if env_value:
            return env_value
        if config_key in config:
            return config[config_key]
        return default

    args.provider = _pick_no_env(getattr(args, "provider", None), "provider", "http")
    args.api_endpoint = _pick_with_env(getattr(args, "api_endpoint", None), "AI_API_ENDPOINT", "api_endpoint", None)
    args.api_model = _pick_with_env(getattr(args, "api_model", None), "AI_API_MODEL", "api_model", None)
    args.temperature = _pick_no_env(getattr(args, "temperature", None), "temperature", None)
    args.max_tokens = _pick_no_env(getattr(args, "max_tokens", None), "max_tokens", None)
    args.top_p = _pick_no_env(getattr(args, "top_p", None), "top_p", None)
    args.timeout = _pick_no_env(getattr(args, "timeout", None), "timeout", 30)
    args.retries = _pick_no_env(getattr(args, "retries", None), "retries", 0)
    args.out_json = _pick_no_env(getattr(args, "out_json", None), "out_json", "outputs/response.json")
    args.out_md = _pick_no_env(getattr(args, "out_md", None), "out_md", "outputs/response.md")
    args.log_run_dir = _pick_no_env(getattr(args, "log_run_dir", None), "log_run_dir", None)

    # Validate TOML-provided values with the same CLI validators where applicable.
    if "api_endpoint" in config and args.api_endpoint is not None:
        args.api_endpoint = _http_url(str(args.api_endpoint))
    if "api_model" in config and args.api_model is not None:
        args.api_model = str(args.api_model).strip() or None
    if "temperature" in config and args.temperature is not None:
        args.temperature = _non_negative_float(str(args.temperature))
    if "max_tokens" in config and args.max_tokens is not None:
        args.max_tokens = _positive_int(str(args.max_tokens))
    if "top_p" in config and args.top_p is not None:
        args.top_p = _top_p_float(str(args.top_p))
    if "timeout" in config:
        args.timeout = _positive_int(str(args.timeout))
    if "retries" in config:
        args.retries = _non_negative_int(str(args.retries))
    if "provider" in config:
        args.provider = str(args.provider).strip() or "http"
    if "out_json" in config:
        args.out_json = str(args.out_json)
    if "out_md" in config:
        args.out_md = str(args.out_md)
    if "log_run_dir" in config and args.log_run_dir is not None:
        args.log_run_dir = str(args.log_run_dir).strip() or None

    return args


def _requested_capabilities(args: argparse.Namespace) -> dict[str, bool]:
    """Return capability requests implied by current CLI arguments."""
    return {
        "stream": bool(args.stream),
        "system": args.system is not None,
        "temperature": args.temperature is not None,
        "top_p": args.top_p is not None,
        "max_tokens": args.max_tokens is not None,
    }


def _evaluate_provider_capabilities(
    provider_spec: ProviderSpec,
    args: argparse.Namespace,
) -> tuple[list[str], list[str]]:
    """
    Evaluate requested CLI options against provider capabilities.

    Returns:
    - warnings (permissive mode informational messages)
    - errors (strict mode blocking messages)
    """
    warnings: list[str] = []
    errors: list[str] = []

    requested = _requested_capabilities(args)

    for capability_name, is_requested in requested.items():
        if not is_requested:
            continue

        capability_state = getattr(provider_spec.capabilities, capability_name)
        if capability_state == "supported":
            continue

        message = (
            f"provider '{provider_spec.provider_id}' reports capability "
            f"'{capability_name}' as {capability_state}."
        )

        if args.strict_capabilities:
            errors.append(message)
            continue

        warnings.append(
            f"{message} Continuing in permissive mode; behavior may fallback or "
            "provider may ignore this option."
        )

    return warnings, errors


def _provider_runtime_snapshot(provider, args: argparse.Namespace) -> dict[str, object]:
    """
    Build a sanitized runtime snapshot from provider config when available.

    API key is always masked in this diagnostic payload.
    """
    config = getattr(provider, "config", None)
    endpoint = getattr(config, "endpoint", args.api_endpoint)
    model = getattr(config, "model", args.api_model)
    timeout_seconds = getattr(config, "timeout_seconds", args.timeout)
    max_retries = getattr(config, "max_retries", args.retries)
    raw_api_key = getattr(config, "api_key", None)

    return {
        "endpoint": endpoint,
        "api_key": "***set***" if bool(raw_api_key) else "not set",
        "model": model,
        "timeout_seconds": timeout_seconds,
        "max_retries": max_retries,
    }


def _build_effective_config_payload(
    provider_spec: ProviderSpec,
    provider,
    args: argparse.Namespace,
    warnings: list[str],
    errors: list[str],
    prompt_text: str | None,
) -> dict[str, object]:
    """Return JSON-serializable effective configuration diagnostics."""
    return {
        "provider": {
            "name": provider_spec.provider_id,
            **_provider_runtime_snapshot(provider, args),
        },
        "request": {
            "prompt_provided": prompt_text is not None,
            "system_provided": args.system is not None,
            "stream": args.stream,
            "temperature": args.temperature,
            "max_tokens": args.max_tokens,
            "top_p": args.top_p,
        },
        "capabilities": asdict(provider_spec.capabilities),
        "capability_validation": {
            "strict_mode": args.strict_capabilities,
            "warnings": warnings,
            "errors": errors,
        },
    }


# Build safe preview values for --help without leaking secrets.
def _env_preview() -> tuple[str, str, str]:
    """Return safe preview of environment configuration for help text."""
    endpoint = os.getenv("AI_API_ENDPOINT", "").strip() or "not set"
    model = os.getenv("AI_API_MODEL", "").strip() or "not set"
    key = "***set***" if os.getenv("AI_API_KEY", "").strip() else "not set"
    return endpoint, model, key

def build_parser() -> argparse.ArgumentParser:
    """Build and return the CLI argument parser."""

    endpoint_preview, model_preview, key_preview = _env_preview()

    parser = argparse.ArgumentParser(
        description=(
            "Send a prompt to an AI API and print normalized JSON output.\n"
            "Prompt input sources (priority): --prompt, --prompt-file, then piped stdin.\n"
            "Configuration can be passed via CLI args or AI_API_* env vars."
        ),
        epilog=(
            "Examples:\n"
            "  ai-prompt-runner --prompt \"Hello\" --provider http\n"
            "  ai-prompt-runner --prompt-file prompts/hello.txt --provider http\n"
            "  echo \"Hello\" | ai-prompt-runner --provider http\n"
            "\n"
            "Exit codes:\n"
            f"  {EXIT_OK}  Success\n"
            f"  {EXIT_RUNTIME_ERROR}  Runtime error (configuration/provider/output)\n"
            f"  {EXIT_USAGE_ERROR}  Usage/validation error\n"
        ),
        formatter_class=argparse.RawTextHelpFormatter,
    )
    prompt_group = parser.add_mutually_exclusive_group(required=False)
    prompt_group.add_argument("--prompt", type=_non_blank_text, help="Prompt text to send (mutually exclusive with --prompt-file).")
    prompt_group.add_argument("--prompt-file", type=_prompt_file_text, help="Path to a UTF-8 text file containing the prompt (mutually exclusive with --prompt).")
    parser.add_argument("--system", type=_non_blank_text, help="Optional one-shot system instruction applied before the prompt.")
    parser.add_argument("--provider", default=None, help="Provider name (currently: http).")
    parser.add_argument("--config", type=_load_config_file, help="Path to a TOML config file (optional; CLI overrides env and config).")
    parser.add_argument("--api-endpoint", type=_http_url, help=f"AI API endpoint URL (env AI_API_ENDPOINT: {endpoint_preview}).")
    parser.add_argument("--api-key", help=f"AI API key (env AI_API_KEY: {key_preview}). Prefer env var in production.")
    parser.add_argument("--api-model", help=f"AI model name (env AI_API_MODEL: {model_preview}).")
    parser.add_argument("--version", action="version", version=f"%(prog)s {_get_app_version()}")
    parser.add_argument("--out-json", default=None, help="JSON output path.")
    parser.add_argument("--out-md", default=None, help="Markdown output path.")
    parser.add_argument("--log-run-dir", default=None, help="Optional directory root for per-run request/response/error artifacts.")
    parser.add_argument("--stream", action="store_true", help="Stream response chunks to stdout when supported by the provider; final JSON/Markdown outputs are still written after completion.")
    parser.add_argument("--strict-capabilities", action="store_true", help="Fail when requested options are unsupported or unknown for the selected provider.")
    parser.add_argument("--dry-run", action="store_true", help="Validate configuration/capabilities and exit without provider execution.")
    parser.add_argument("--print-effective-config", action="store_true", help="Print resolved runtime configuration (with masked secrets).")
    parser.add_argument("--temperature", type=_non_negative_float, default=None, help="Optional generation temperature (float >= 0).")
    parser.add_argument("--max-tokens", type=_positive_int, default=None, help="Optional max token budget for completion (integer > 0).")
    parser.add_argument("--top-p", type=_top_p_float, default=None, help="Optional nucleus sampling value (0 < top-p <= 1).")
    parser.add_argument("--timeout", type=_positive_int, default=None, help="HTTP timeout in seconds (must be > 0).")
    parser.add_argument("--retries", type=_non_negative_int, default=None, help="Maximum retry attempts on network errors (must be >= 0).")
    return parser


def main(argv: list[str] | None = None) -> int:
    """CLI entrypoint returning process exit code."""

    load_dotenv()  # Load .env before building the parser so dynamic help reflects env values.
    parser = build_parser()  # Build CLI definition (arguments, help text, version flag).
    args = parser.parse_args(argv)  # Parse runtime arguments into a namespace.
    try:
        args = _merge_runtime_config(args)
    except argparse.ArgumentTypeError as exc:
        parser.error(str(exc))

    # Resolve prompt text unless dry-run mode is requested.
    if args.dry_run:
        prompt_text = _resolve_optional_prompt_text_for_dry_run(args)
    else:
        try:
            prompt_text = _resolve_prompt_text(args)
        except argparse.ArgumentTypeError as exc:
            parser.error(str(exc))

    secret_values = _runtime_secret_candidates(args.api_key)

    try:
        run_log_dir = _create_run_log_dir(args.log_run_dir)
        _write_run_request_log(
            run_log_dir=run_log_dir,
            args=args,
            prompt_text=prompt_text,
        )
    except OSError as exc:
        print(f"Error: run log directory is not writable: {exc}", file=sys.stderr)
        return EXIT_RUNTIME_ERROR

    # Validate requested capabilities before provider instantiation.
    try:
        provider_spec = get_provider_spec(args.provider)
    except ConfigurationError as exc:
        try:
            _write_run_error_log(
                run_log_dir=run_log_dir,
                exc=exc,
                provider=args.provider,
                secret_values=secret_values,
            )
        except OSError:
            pass
        print(f"Error: {exc}", file=sys.stderr)
        return EXIT_RUNTIME_ERROR

    warnings, errors = _evaluate_provider_capabilities(provider_spec, args)
    for warning in warnings:
        print(f"Warning: {warning}", file=sys.stderr)

    if errors:
        try:
            _write_run_error_log(
                run_log_dir=run_log_dir,
                exc=ConfigurationError("; ".join(errors)),
                provider=args.provider,
                secret_values=secret_values,
            )
        except OSError:
            pass
        for error in errors:
            print(f"Error: capability check failed: {error}", file=sys.stderr)
        return EXIT_RUNTIME_ERROR
    
    # Wire infrastructure (provider) to application logic (runner).
    try:
        provider = create_provider(
            provider_name=args.provider,
            api_endpoint=args.api_endpoint,
            api_key=args.api_key,
            api_model=args.api_model,
            timeout_seconds=args.timeout,
            max_retries=args.retries,
        )
    except ConfigurationError as exc:
        try:
            _write_run_error_log(
                run_log_dir=run_log_dir,
                exc=exc,
                provider=args.provider,
                secret_values=secret_values,
            )
        except OSError:
            pass
        print(f"Error: {exc}", file=sys.stderr)
        return EXIT_RUNTIME_ERROR

    effective_config = _build_effective_config_payload(
        provider_spec=provider_spec,
        provider=provider,
        args=args,
        warnings=warnings,
        errors=errors,
        prompt_text=prompt_text,
    )

    if args.print_effective_config:
        print(json.dumps(effective_config, indent=2, ensure_ascii=False), file=sys.stderr)

    try:
        _write_run_request_log(
            run_log_dir=run_log_dir,
            args=args,
            prompt_text=prompt_text,
            effective_config=effective_config,
        )
    except OSError as exc:
        print(f"Error: run log directory is not writable: {exc}", file=sys.stderr)
        return EXIT_RUNTIME_ERROR

    if args.dry_run:
        dry_run_payload = {
            "mode": "dry-run",
            "status": "ok",
            "effective_config": effective_config,
        }
        try:
            _write_run_response_log(
                run_log_dir=run_log_dir,
                payload=dry_run_payload,
            )
        except OSError as exc:
            print(f"Error: run log directory is not writable: {exc}", file=sys.stderr)
            return EXIT_RUNTIME_ERROR
        print(
            json.dumps(
                dry_run_payload,
                indent=2,
                ensure_ascii=False,
            )
        )
        return EXIT_OK

    runner = PromptRunner(provider=provider)

    def _print_stream_chunk(chunk: str) -> None:
        """Render stream chunks progressively without buffering delays."""
        print(chunk, end="", flush=True)

    try:
        payload = runner.run(
            PromptRequest(
                prompt_text=prompt_text or "",
                provider=args.provider,
                system_prompt=args.system,
                temperature=args.temperature,
                max_tokens=args.max_tokens,
                top_p=args.top_p,
                stream=args.stream,
            )
            ,
            on_stream_chunk=_print_stream_chunk if args.stream else None,
        )

        # Keep streamed token output readable and separate from final JSON payload.
        if args.stream:
            print()

        write_json(Path(args.out_json), payload)
        write_markdown(Path(args.out_md), payload)
    except PromptRunnerError as exc:
        try:
            _write_run_error_log(
                run_log_dir=run_log_dir,
                exc=exc,
                provider=args.provider,
                secret_values=secret_values,
            )
        except OSError:
            pass
        print(f"Error: {exc}", file=sys.stderr)
        return EXIT_RUNTIME_ERROR

    try:
        _write_run_response_log(run_log_dir=run_log_dir, payload=payload)
    except OSError as exc:
        print(f"Error: run log directory is not writable: {exc}", file=sys.stderr)
        return EXIT_RUNTIME_ERROR

    print(json.dumps(payload, indent=2, ensure_ascii=False))
    return EXIT_OK


if __name__ == "__main__":
    sys.exit(main())
