#!/usr/bin/env python3
"""Secure asyncio network automation template.

This script demonstrates practical patterns for safe concurrent network-style
workflows without hard-coded secrets or environment-specific endpoints.

Features:
- Input validation for hosts and optional targets file
- Bounded concurrency with a semaphore
- Per-task and overall timeouts
- Explicit cancellation handling
- Structured, sanitized result collection
- Dry-run friendly behavior with no destructive default actions

Use cases:
- Inventory polling
- Status checks
- API or socket-based read-only operations
- Change-window verification workflows

The default remote action is a placeholder coroutine that simulates a network
request. Replace `perform_remote_check()` with your own async client logic.
"""

from __future__ import annotations

import argparse
import asyncio
import ipaddress
import json
import re
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Iterable, Optional


HOSTNAME_RE = re.compile(
    r"^(?=.{1,253}$)([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$"
)


@dataclass
class TaskResult:
    target: str
    ok: bool
    error_type: Optional[str] = None
    error_message: Optional[str] = None
    data: Optional[dict[str, Any]] = None


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Secure asyncio network automation template with bounded concurrency and validation."
    )
    parser.add_argument(
        "targets",
        nargs="*",
        help="One or more target hostnames or IP addresses. If omitted, use --targets-file.",
    )
    parser.add_argument(
        "--targets-file",
        type=Path,
        help="Optional path to a file containing one target per line.",
    )
    parser.add_argument(
        "--concurrency",
        type=int,
        default=5,
        help="Maximum number of concurrent tasks. Default: 5.",
    )
    parser.add_argument(
        "--per-task-timeout",
        type=float,
        default=5.0,
        help="Timeout in seconds for each target. Default: 5.0.",
    )
    parser.add_argument(
        "--overall-timeout",
        type=float,
        default=60.0,
        help="Timeout in seconds for the full run. Default: 60.0.",
    )
    parser.add_argument(
        "--output",
        type=Path,
        help="Optional output file for JSON results. If omitted, results print to stdout.",
    )
    parser.add_argument(
        "--simulate-delay",
        type=float,
        default=0.5,
        help="Simulated network delay for the placeholder remote check. Default: 0.5.",
    )
    return parser.parse_args()


def load_targets(args: argparse.Namespace) -> list[str]:
    targets: list[str] = []

    if args.targets:
        targets.extend(args.targets)

    if args.targets_file:
        if not args.targets_file.exists():
            raise FileNotFoundError(f"Targets file not found: {args.targets_file}")
        if not args.targets_file.is_file():
            raise ValueError(f"Targets file is not a file: {args.targets_file}")
        for line in args.targets_file.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if line and not line.startswith("#"):
                targets.append(line)

    if not targets:
        raise ValueError("No targets provided. Supply positional targets or --targets-file.")

    normalized = []
    seen = set()
    for target in targets:
        value = validate_target(target)
        if value not in seen:
            seen.add(value)
            normalized.append(value)
    return normalized


def validate_target(target: str) -> str:
    value = target.strip()
    if not value:
        raise ValueError("Target values must not be empty.")

    try:
        ipaddress.ip_address(value)
        return value
    except ValueError:
        pass

    if not HOSTNAME_RE.match(value):
        raise ValueError(f"Invalid hostname or IP address: {target!r}")

    return value


async def perform_remote_check(target: str, simulated_delay: float) -> dict[str, Any]:
    """Placeholder async check.

    Replace this with a read-only network operation using your preferred async
    library. Keep the same safety boundaries:
    - validate input before use
    - bound execution with a timeout
    - return structured output
    - avoid shell interpolation and hard-coded secrets
    """
    await asyncio.sleep(simulated_delay)
    return {
        "target": target,
        "status": "ok",
        "details": "placeholder result; replace with real network logic",
    }


async def run_one(target: str, semaphore: asyncio.Semaphore, per_task_timeout: float, simulated_delay: float) -> TaskResult:
    async with semaphore:
        try:
            payload = await asyncio.wait_for(
                perform_remote_check(target, simulated_delay),
                timeout=per_task_timeout,
            )
            validated = validate_output(payload, target)
            return TaskResult(target=target, ok=True, data=validated)
        except asyncio.TimeoutError:
            return TaskResult(
                target=target,
                ok=False,
                error_type="timeout",
                error_message=f"Task exceeded per-task timeout of {per_task_timeout:.2f}s",
            )
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            return TaskResult(
                target=target,
                ok=False,
                error_type=type(exc).__name__,
                error_message=str(exc),
            )


def validate_output(payload: Any, target: str) -> dict[str, Any]:
    if not isinstance(payload, dict):
        raise ValueError(f"Unexpected output type for {target}: expected dict")

    required_keys = {"target", "status", "details"}
    missing = required_keys.difference(payload.keys())
    if missing:
        raise ValueError(f"Missing keys for {target}: {sorted(missing)}")

    if payload.get("target") != target:
        raise ValueError(f"Output target mismatch for {target}")

    if payload.get("status") not in {"ok", "warn", "fail"}:
        raise ValueError(f"Invalid status value for {target}")

    details = payload.get("details")
    if not isinstance(details, str) or len(details) > 500:
        raise ValueError(f"Invalid details field for {target}")

    return {
        "target": payload["target"],
        "status": payload["status"],
        "details": details,
    }


async def run_batch(targets: Iterable[str], concurrency: int, per_task_timeout: float, simulated_delay: float) -> list[TaskResult]:
    semaphore = asyncio.Semaphore(concurrency)
    tasks = [
        asyncio.create_task(run_one(target, semaphore, per_task_timeout, simulated_delay))
        for target in targets
    ]

    results: list[TaskResult] = []
    try:
        for task in asyncio.as_completed(tasks):
            results.append(await task)
    except asyncio.CancelledError:
        for task in tasks:
            task.cancel()
        await asyncio.gather(*tasks, return_exceptions=True)
        raise
    finally:
        pending = [task for task in tasks if not task.done()]
        for task in pending:
            task.cancel()
        if pending:
            await asyncio.gather(*pending, return_exceptions=True)

    return results


def summarize(results: list[TaskResult]) -> dict[str, Any]:
    ok_count = sum(1 for r in results if r.ok)
    fail_count = len(results) - ok_count
    return {
        "summary": {
            "total": len(results),
            "ok": ok_count,
            "failed": fail_count,
        },
        "results": [asdict(r) for r in results],
    }


def write_output(document: dict[str, Any], output: Optional[Path]) -> None:
    text = json.dumps(document, indent=2, sort_keys=True)
    if output:
        output.write_text(text + "\n", encoding="utf-8")
    else:
        print(text)


async def async_main() -> int:
    args = parse_args()

    if args.concurrency < 1:
        raise ValueError("--concurrency must be at least 1")
    if args.per_task_timeout <= 0:
        raise ValueError("--per-task-timeout must be greater than 0")
    if args.overall_timeout <= 0:
        raise ValueError("--overall-timeout must be greater than 0")
    if args.simulate_delay < 0:
        raise ValueError("--simulate-delay must be 0 or greater")

    targets = load_targets(args)

    try:
        results = await asyncio.wait_for(
            run_batch(
                targets=targets,
                concurrency=args.concurrency,
                per_task_timeout=args.per_task_timeout,
                simulated_delay=args.simulate_delay,
            ),
            timeout=args.overall_timeout,
        )
    except asyncio.TimeoutError:
        document = {
            "summary": {
                "total": len(targets),
                "ok": 0,
                "failed": len(targets),
                "error": f"Overall timeout exceeded: {args.overall_timeout:.2f}s",
            },
            "results": [],
        }
        write_output(document, args.output)
        return 2
    except asyncio.CancelledError:
        raise

    document = summarize(results)
    write_output(document, args.output)

    return 0 if all(result.ok for result in results) else 1


def main() -> int:
    try:
        return asyncio.run(async_main())
    except KeyboardInterrupt:
        print("Interrupted by user", file=sys.stderr)
        return 130
    except Exception as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())