#!/usr/bin/env python3
"""confirming-pentest-authorization — verify ROE before any scan runs.

Reads a Rules-of-Engagement YAML attestation, validates required fields
(authorizer, in_scope_targets, time_window, emergency_contact, signature_block),
checks signer identity against an allowed-authorizers file (optional), and
verifies the current time falls within the engagement window. Emits Findings
via lib/finding.py. The orchestrator routes here FIRST — any CRITICAL finding
halts engagement before later cluster skills get a chance to run.

Usage:
    python3 check_authorization.py [--roe FILE] [--allowed FILE]
                                   [--check-target HOST]
                                   [--output FILE] [--format json|jsonl|markdown]
                                   [--min-severity sev]
"""

from __future__ import annotations

import argparse
import datetime as dt
import ipaddress
import sys
from pathlib import Path
from typing import Any

# --- lib/ import -------------------------------------------------------------
_LIB_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(_LIB_ROOT))

from lib.finding import Finding, Severity  # noqa: E402
from lib import report  # noqa: E402

# --- Optional YAML import (fallback to a minimal parser if PyYAML absent) ---
try:
    import yaml  # type: ignore[import-not-found]

    _HAS_PYYAML = True
except ImportError:  # pragma: no cover
    yaml = None
    _HAS_PYYAML = False


SKILL_ID = "confirming-pentest-authorization"
CATEGORY = "engagement-authorization"

REQUIRED_FIELDS = (
    "engagement_id",
    "authorizer",
    "in_scope_targets",
    "time_window",
    "emergency_contact",
    "signature_block",
)
REQUIRED_AUTHORIZER_FIELDS = ("name", "email", "role")
REQUIRED_TIME_WINDOW_FIELDS = ("start", "end")
REQUIRED_SIGNATURE_FIELDS = ("signer", "signed_at", "signature")


# --- Minimal-YAML fallback parser -------------------------------------------


def _minimal_yaml_load(text: str) -> dict[str, Any]:
    """Very limited YAML parser for environments without PyYAML.

    Supports: top-level mapping, nested mappings (indentation-sensitive),
    lists of dicts and scalars, ISO date/time strings as strings.
    Refuses any structure it can't parse rather than guess.
    """
    if _HAS_PYYAML:
        return yaml.safe_load(text) or {}

    lines = [l for l in text.splitlines() if not l.lstrip().startswith("#")]
    root: dict[str, Any] = {}
    stack: list[tuple[int, Any]] = [(-1, root)]

    def cur() -> Any:
        return stack[-1][1]

    for raw in lines:
        if not raw.strip():
            continue
        indent = len(raw) - len(raw.lstrip())
        line = raw.strip()
        while stack and stack[-1][0] >= indent:
            stack.pop()

        if line.startswith("- "):
            item_text = line[2:].strip()
            container = cur()
            if not isinstance(container, list):
                # parent expected a list
                parent_key, parent_dict = stack[-2] if len(stack) >= 2 else (-1, root)
                # find the key for which we want to make a list — this is best-effort
                container = []
                # re-attach: convert empty value of last set key to a list
                if isinstance(parent_dict, dict):
                    for k in list(parent_dict.keys())[::-1]:
                        if parent_dict[k] in (None, "", {}):
                            parent_dict[k] = container
                            break
            if ":" in item_text and not item_text.endswith(":"):
                k, _, v = item_text.partition(":")
                container.append({k.strip(): v.strip()})
                stack.append((indent + 2, container[-1]))
            elif item_text.endswith(":"):
                obj: dict[str, Any] = {}
                container.append(obj)
                stack.append((indent + 2, obj))
            else:
                container.append(item_text)
        elif ":" in line:
            key, _, value = line.partition(":")
            key = key.strip()
            value = value.strip()
            container = cur()
            if not isinstance(container, dict):
                continue
            if value == "":
                container[key] = {}
                stack.append((indent, container[key]))
            elif value == "|":
                # block scalar — collect indented lines (not implemented richly)
                container[key] = ""
            else:
                container[key] = value.strip().strip('"').strip("'")
    return root


# --- ROE loading ------------------------------------------------------------


def load_roe(path: Path) -> tuple[dict[str, Any] | None, str | None]:
    if not path.exists():
        return None, f"ROE file not found at {path}"
    try:
        text = path.read_text(encoding="utf-8")
    except OSError as e:
        return None, f"Cannot read {path}: {e}"
    try:
        data = _minimal_yaml_load(text) if not _HAS_PYYAML else yaml.safe_load(text)
    except Exception as e:  # noqa: BLE001 — YAML errors come in many flavors
        return None, f"YAML parse error: {e}"
    if not isinstance(data, dict):
        return None, "ROE top-level structure is not a mapping"
    return data, None


def load_allowed(path: Path) -> list[str]:
    if not path.exists():
        return []
    return [
        line.strip()
        for line in path.read_text(encoding="utf-8").splitlines()
        if line.strip() and not line.startswith("#")
    ]


# --- Field validation -------------------------------------------------------


def _info(title: str, target: str, detail: str) -> Finding:
    return Finding(
        skill_id=SKILL_ID,
        title=title,
        severity=Severity.INFO,
        target=target,
        detail=detail,
        remediation="No action required.",
    )


def _critical(title: str, target: str, detail: str, remediation: str) -> Finding:
    return Finding(
        skill_id=SKILL_ID,
        title=title,
        severity=Severity.CRITICAL,
        target=target,
        detail=detail,
        remediation=remediation,
    )


def _high(title: str, target: str, detail: str, remediation: str) -> Finding:
    return Finding(
        skill_id=SKILL_ID,
        title=title,
        severity=Severity.HIGH,
        target=target,
        detail=detail,
        remediation=remediation,
    )


def _parse_iso(s: str) -> dt.datetime | None:
    try:
        # accept "Z" suffix and offset forms
        s2 = s.replace("Z", "+00:00")
        return dt.datetime.fromisoformat(s2)
    except (ValueError, TypeError):
        return None


def validate_roe(data: dict[str, Any], target_label: str, allowed: list[str]) -> list[Finding]:
    findings: list[Finding] = []

    # Required top-level fields
    for field in REQUIRED_FIELDS:
        if field not in data or data.get(field) in (None, "", [], {}):
            findings.append(
                _critical(
                    f"ROE missing required field: {field}",
                    target_label,
                    f"Field `{field}` is required and was missing or empty.",
                    f"Add `{field}` to the ROE and re-sign.",
                )
            )

    authorizer = data.get("authorizer") or {}
    if isinstance(authorizer, dict):
        for sub in REQUIRED_AUTHORIZER_FIELDS:
            if sub not in authorizer or not authorizer.get(sub):
                findings.append(
                    _critical(
                        f"authorizer.{sub} missing",
                        target_label,
                        f"The authorizer block is missing `{sub}`.",
                        f"Add `authorizer.{sub}` to the ROE and re-sign.",
                    )
                )

    tw = data.get("time_window") or {}
    if isinstance(tw, dict):
        for sub in REQUIRED_TIME_WINDOW_FIELDS:
            if sub not in tw:
                findings.append(
                    _critical(
                        f"time_window.{sub} missing",
                        target_label,
                        f"`time_window.{sub}` is required.",
                        f"Add `time_window.{sub}` (ISO-8601 timestamp) and re-sign.",
                    )
                )
        start = _parse_iso(str(tw.get("start", "")))
        end = _parse_iso(str(tw.get("end", "")))
        now = dt.datetime.now(dt.timezone.utc)
        if start and now < start:
            findings.append(
                _high(
                    "engagement time window has not started",
                    target_label,
                    f"Current time {now.isoformat()} precedes window start {start.isoformat()}.",
                    "Wait until the start time, or have the authorizer re-sign with an earlier start.",
                )
            )
        if end and now > end:
            findings.append(
                _high(
                    "engagement time window has expired",
                    target_label,
                    f"Current time {now.isoformat()} is past window end {end.isoformat()}.",
                    "Halt testing immediately. Request a new ROE with an extended end date.",
                )
            )

    sig = data.get("signature_block") or {}
    if isinstance(sig, dict):
        for sub in REQUIRED_SIGNATURE_FIELDS:
            if sub not in sig or not sig.get(sub):
                findings.append(
                    _critical(
                        f"signature_block.{sub} missing",
                        target_label,
                        f"The signature_block is missing `{sub}`.",
                        "Re-sign the ROE with all signature fields present.",
                    )
                )
        signer = str(sig.get("signer", "")).strip().lower()
        if allowed and signer and signer not in [a.lower() for a in allowed]:
            findings.append(
                _critical(
                    "signer not in allowed-authorizers list",
                    target_label,
                    f"signer `{signer}` is not present in the allowed-authorizers "
                    f"file. Engagement cannot be authorized by this signer.",
                    "Either add the signer to the allowed list (only do this "
                    "for verified authorizers!) or have an allowed signer "
                    "re-sign the ROE.",
                )
            )

        signed_at = _parse_iso(str(sig.get("signed_at", "")))
        if signed_at:
            age_days = (dt.datetime.now(dt.timezone.utc) - signed_at).days
            if age_days > 30:
                findings.append(
                    Finding(
                        skill_id=SKILL_ID,
                        title="ROE is stale (>30 days since signature)",
                        severity=Severity.MEDIUM,
                        target=target_label,
                        detail=f"ROE was signed {age_days} days ago.",
                        remediation="Request a refreshed ROE signature.",
                    )
                )

    in_scope = data.get("in_scope_targets") or []
    if isinstance(in_scope, list) and len(in_scope) == 0:
        findings.append(
            _high(
                "in_scope_targets list is empty",
                target_label,
                "in_scope_targets must list at least one target.",
                "Add explicit in-scope targets and re-sign.",
            )
        )

    return findings


# --- Target-in-scope check --------------------------------------------------


def _normalize_targets(in_scope: list[dict[str, Any] | str]) -> list[str]:
    norm: list[str] = []
    for t in in_scope:
        if isinstance(t, dict):
            if "host" in t:
                norm.append(str(t["host"]).strip().lower())
            elif "cidr" in t:
                norm.append(str(t["cidr"]).strip())
        elif isinstance(t, str):
            norm.append(t.strip().lower())
    return norm


def target_in_scope(target: str, in_scope: list[str]) -> bool:
    target_n = target.strip().lower()
    # exact hostname match
    if target_n in in_scope:
        return True
    # CIDR membership
    try:
        target_ip = ipaddress.ip_address(target_n)
        for entry in in_scope:
            try:
                net = ipaddress.ip_network(entry, strict=False)
                if target_ip in net:
                    return True
            except (ValueError, TypeError):
                continue
    except ValueError:
        pass
    # Suffix match for subdomain wildcards (e.g. "*.acme.example")
    for entry in in_scope:
        if entry.startswith("*.") and target_n.endswith(entry[2:]):
            return True
    return False


def check_targets(targets: list[str], in_scope_raw: list[dict[str, Any] | str], target_label: str) -> list[Finding]:
    in_scope = _normalize_targets(in_scope_raw)
    out: list[Finding] = []
    for t in targets:
        if target_in_scope(t, in_scope):
            out.append(
                _info(
                    f"target {t} is in scope",
                    target_label,
                    f"`{t}` matched an in-scope entry in the ROE.",
                )
            )
        else:
            out.append(
                _critical(
                    f"target {t} is NOT in scope",
                    target_label,
                    f"`{t}` does not match any in-scope entry. Probing this target is not authorized.",
                    f"Either confirm the target IS in scope (request an ROE "
                    f"amendment) or remove `{t}` from the test plan.",
                )
            )
    return out


# --- CLI ---------------------------------------------------------------------


def _build_arg_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    p.add_argument("--roe", default="roe.yaml", help="Path to ROE YAML file")
    p.add_argument("--allowed", default=".allowed-authorizers")
    p.add_argument(
        "--check-target",
        action="append",
        default=[],
        help="Verify target is in-scope (repeatable)",
    )
    p.add_argument("--output", default=None)
    p.add_argument("--format", default="markdown", choices=["json", "jsonl", "markdown"])
    p.add_argument(
        "--min-severity",
        default="info",
        choices=["info", "low", "medium", "high", "critical"],
    )
    return p


def _filter_min_severity(findings: list[Finding], min_sev: str) -> list[Finding]:
    floor = Severity(min_sev).numeric
    return [f for f in findings if f.severity.numeric >= floor]


def main(argv: list[str] | None = None) -> int:
    args = _build_arg_parser().parse_args(argv)
    roe_path = Path(args.roe).resolve()
    allowed_path = Path(args.allowed).resolve()

    data, err = load_roe(roe_path)
    if data is None:
        f = _critical(
            "ROE could not be loaded",
            str(roe_path),
            err or "unknown error",
            f"Create or fix the ROE at {roe_path} and re-run.",
        )
        report.emit([f], args.output, args.format, scan_target=str(roe_path))
        return 1

    allowed = load_allowed(allowed_path)
    findings = validate_roe(data, str(roe_path), allowed)

    if args.check_target:
        findings.extend(
            check_targets(
                args.check_target,
                data.get("in_scope_targets") or [],
                str(roe_path),
            )
        )

    if not findings:
        findings = [
            _info(
                "engagement is authorized",
                str(roe_path),
                "All required fields present, signer in allowlist, time window "
                "is active, and all checked targets are in scope.",
            )
        ]
    findings = _filter_min_severity(findings, args.min_severity)
    report.emit(findings, args.output, args.format, scan_target=str(roe_path))
    return report.exit_code(findings)


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