"""The Supervisor's deliberately small plan language and its deterministic compiler."""

from __future__ import annotations

from dataclasses import dataclass
from enum import StrEnum
from urllib.parse import unquote, urlsplit

from .memory import RunSnapshot, RunStatus


class TaskKind(StrEnum):
    DISCOVER = "discover"
    ENUMERATE = "enumerate"
    TEST = "test"
    EXPLOIT = "exploit"
    VERIFY = "verify"
    RECOVER = "recover"


class TaskStatus(StrEnum):
    BLOCKED = "blocked"
    READY = "ready"
    ACTIVE = "active"
    DONE = "done"
    FAILED = "failed"


@dataclass(frozen=True)
class TaskProposal:
    id: str
    kind: TaskKind
    target: str
    objective: str
    done_when: str
    basis_ids: tuple[str, ...] = ()
    depends_on: tuple[str, ...] = ()


@dataclass(frozen=True)
class TaskRecord:
    id: str
    kind: TaskKind
    target: str
    objective: str
    done_when: str
    basis_ids: tuple[str, ...]
    depends_on: tuple[str, ...]
    status: TaskStatus
    created_revision: int


@dataclass(frozen=True)
class SupervisorDecision:
    base_revision: int
    new_tasks: tuple[TaskProposal, ...]
    next_task_id: str | None
    finish: bool
    summary: str
    finish_basis_ids: tuple[str, ...] = ()


@dataclass(frozen=True)
class ValidPlan:
    run_id: str
    base_revision: int
    new_tasks: tuple[TaskProposal, ...]
    next_task_id: str | None
    finish: bool
    summary: str
    finish_basis_ids: tuple[str, ...]


class PlanValidationError(ValueError):
    pass


_DEFAULT_PORT = {"http": 80, "https": 443}
_TEST_EXPLOIT_PHRASES = (
    "capture the flag",
    "dump credential",
    "exfiltrat",
    "gain a shell",
    "obtain the flag",
    "read the flag",
    "retrieve the flag",
    "reverse shell",
)


def _canonical_url_target(
    raw: str,
) -> tuple[tuple[str, str, int], tuple[str, ...]] | None:
    if raw != raw.strip() or "\\" in raw:
        return None
    if any(ord(character) < 0x20 or ord(character) == 0x7F for character in raw):
        return None

    try:
        parsed = urlsplit(raw)
        scheme = parsed.scheme.casefold()
        hostname = parsed.hostname
        if scheme not in _DEFAULT_PORT or hostname is None:
            return None
        if parsed.username is not None or parsed.password is not None or parsed.fragment:
            return None
        port = parsed.port if parsed.port is not None else _DEFAULT_PORT[scheme]
    except (UnicodeError, ValueError):
        return None

    path = parsed.path or "/"
    try:
        for _ in range(4):
            decoded = unquote(path, errors="strict")
            if decoded == path:
                break
            path = decoded
        if unquote(path, errors="strict") != path:
            return None
    except UnicodeDecodeError:
        return None

    if (
        not path.startswith("/")
        or "%" in path
        or "\\" in path
        or any(ord(character) < 0x20 or ord(character) == 0x7F for character in path)
    ):
        return None
    segments = tuple(segment for segment in path.split("/") if segment)
    if any(segment.split(";", 1)[0] in {".", ".."} for segment in segments):
        return None
    return (scheme, hostname.casefold(), port), segments


def _url_contains(allowed_target: str, candidate_target: str) -> bool:
    allowed = _canonical_url_target(allowed_target)
    candidate = _canonical_url_target(candidate_target)
    if allowed is None or candidate is None:
        return False
    allowed_origin, allowed_path = allowed
    candidate_origin, candidate_path = candidate
    return (
        allowed_origin == candidate_origin and candidate_path[: len(allowed_path)] == allowed_path
    )


def _target_is_allowed(target: str, allowed_targets: tuple[str, ...]) -> bool:
    for allowed_target in allowed_targets:
        if _url_contains(allowed_target, target):
            return True
        if target == allowed_target and not target.casefold().startswith(("http://", "https://")):
            return True
    return False


def compile_plan(decision: SupervisorDecision, snapshot: RunSnapshot) -> ValidPlan:
    """Validate a complete decision without mutating canonical state."""
    if snapshot.status is not RunStatus.RUNNING:
        raise PlanValidationError("run is not active")
    if decision.base_revision != snapshot.revision:
        raise PlanValidationError("base revision is stale")
    if decision.finish == (decision.next_task_id is not None):
        raise PlanValidationError("choose exactly one of next_task_id or finish")
    if not decision.summary.strip():
        raise PlanValidationError("summary must not be empty")
    if len(decision.summary) > 2_000:
        raise PlanValidationError("decision summary exceeds 2000 characters")
    if len(decision.new_tasks) > 1:
        raise PlanValidationError("a decision may propose at most one new task")
    if decision.new_tasks and decision.next_task_id != decision.new_tasks[0].id:
        raise PlanValidationError("a new task must be selected immediately")

    existing = {task.id: task for task in snapshot.tasks}
    observations_by_id = {observation.id: observation for observation in snapshot.observations}
    observation_ids = set(observations_by_id)
    proposed: dict[str, TaskProposal] = {}
    for task in decision.new_tasks:
        if not task.id.strip() or task.id in existing or task.id in proposed:
            raise PlanValidationError(f"task id is empty or duplicated: {task.id!r}")
        if len(task.id) > 128:
            raise PlanValidationError(f"task {task.id[:32]!r} id exceeds 128 characters")
        if len(task.target) > 2_048:
            raise PlanValidationError(f"task {task.id!r} target exceeds 2048 characters")
        if len(task.objective) > 2_000:
            raise PlanValidationError(f"task {task.id!r} objective exceeds 2000 characters")
        if len(task.done_when) > 1_000:
            raise PlanValidationError(f"task {task.id!r} done_when exceeds 1000 characters")
        if len(task.basis_ids) > 8:
            raise PlanValidationError(f"task {task.id!r} may cite at most 8 basis observations")
        if len(task.depends_on) > 8:
            raise PlanValidationError(f"task {task.id!r} may have at most 8 dependencies")
        if not _target_is_allowed(task.target, snapshot.allowed_targets):
            raise PlanValidationError(
                "task target is outside scope; copy one allowed target exactly without adding "
                f"a scheme, port, vhost, URL, or path: {task.target!r}"
            )
        if not task.objective.strip() or not task.done_when.strip():
            raise PlanValidationError(f"task {task.id!r} is not executable")
        task_scope = f"{task.objective}\n{task.done_when}".casefold()
        if task.kind is TaskKind.TEST and any(
            phrase in task_scope for phrase in _TEST_EXPLOIT_PHRASES
        ):
            raise PlanValidationError(f"task {task.id!r} TEST task crosses into exploitation")
        if len(task.basis_ids) != len(set(task.basis_ids)):
            raise PlanValidationError(f"task {task.id!r} has duplicate basis observations")
        unknown_basis = set(task.basis_ids) - observation_ids
        if unknown_basis:
            raise PlanValidationError(
                f"task {task.id!r} has unknown basis observations: {sorted(unknown_basis)}"
            )
        if task.kind is not TaskKind.RECOVER:
            basis_producers = {
                observations_by_id[observation_id].task_id for observation_id in task.basis_ids
            }
            missing_dependencies = basis_producers - set(task.depends_on)
            if missing_dependencies:
                raise PlanValidationError(
                    f"task {task.id!r} has basis-producing tasks missing from depends_on: "
                    f"{sorted(missing_dependencies)}"
                )
        if task.kind is TaskKind.EXPLOIT:
            test_observations = [
                observation
                for observation in snapshot.observations
                if (producer := existing.get(observation.task_id)) is not None
                and producer.kind is TaskKind.TEST
                and producer.status is TaskStatus.DONE
                and producer.target == task.target
            ]
            if not test_observations:
                raise PlanValidationError(
                    f"task {task.id!r} exploit requires a completed test basis"
                )
            latest_test = max(
                test_observations,
                key=lambda observation: (observation.created_revision, observation.id),
            )
            if latest_test.id not in task.basis_ids:
                raise PlanValidationError(
                    f"task {task.id!r} must cite latest completed test observation "
                    f"{latest_test.id!r}"
                )
        proposed[task.id] = task

    known_ids = set(existing) | set(proposed)
    for task in proposed.values():
        if task.id in task.depends_on:
            raise PlanValidationError(f"task {task.id!r} depends on itself")
        unknown = set(task.depends_on) - known_ids
        if unknown:
            raise PlanValidationError(
                f"task {task.id!r} has unknown dependencies: {sorted(unknown)}"
            )

    dependencies: dict[str, tuple[str, ...]] = {
        task_id: task.depends_on for task_id, task in existing.items()
    }
    dependencies.update({task_id: task.depends_on for task_id, task in proposed.items()})
    visiting: set[str] = set()
    visited: set[str] = set()

    def visit(task_id: str) -> None:
        if task_id in visiting:
            raise PlanValidationError(f"dependency cycle includes task {task_id!r}")
        if task_id in visited:
            return
        visiting.add(task_id)
        for dependency_id in dependencies[task_id]:
            visit(dependency_id)
        visiting.remove(task_id)
        visited.add(task_id)

    for task_id in dependencies:
        visit(task_id)

    if decision.finish:
        open_tasks = [
            task.id
            for task in snapshot.tasks
            if task.status in {TaskStatus.BLOCKED, TaskStatus.READY, TaskStatus.ACTIVE}
        ]
        if decision.new_tasks or open_tasks:
            raise PlanValidationError("cannot finish while open tasks remain")
        if not decision.finish_basis_ids:
            raise PlanValidationError("finish requires canonical basis observations")
        if len(decision.finish_basis_ids) > 4:
            raise PlanValidationError("finish may cite at most 4 canonical basis observations")
        if len(decision.finish_basis_ids) != len(set(decision.finish_basis_ids)):
            raise PlanValidationError("finish basis observations must be unique")
        unknown_finish_basis = set(decision.finish_basis_ids) - observation_ids
        if unknown_finish_basis:
            raise PlanValidationError(
                f"decision has unknown finish basis observations: {sorted(unknown_finish_basis)}"
            )
        unfinished_finish_basis = [
            observation_id
            for observation_id in decision.finish_basis_ids
            if existing[observations_by_id[observation_id].task_id].status is not TaskStatus.DONE
        ]
        if unfinished_finish_basis:
            raise PlanValidationError(
                f"finish basis must come from completed tasks: {sorted(unfinished_finish_basis)}"
            )
    elif decision.finish_basis_ids:
        raise PlanValidationError("finish basis must be empty while continuing the run")

    if decision.next_task_id is not None:
        selected = proposed.get(decision.next_task_id) or existing.get(decision.next_task_id)
        if selected is None:
            raise PlanValidationError(f"selected task does not exist: {decision.next_task_id!r}")
        if isinstance(selected, TaskRecord) and selected.status is not TaskStatus.READY:
            raise PlanValidationError(f"selected task is not ready: {selected.id!r}")
        dependency_ids = selected.depends_on
        unfinished = [
            dependency_id
            for dependency_id in dependency_ids
            if dependency_id not in existing
            or existing[dependency_id].status is not TaskStatus.DONE
        ]
        if unfinished:
            raise PlanValidationError(f"selected task has unfinished dependencies: {unfinished}")

    return ValidPlan(
        run_id=snapshot.run_id,
        base_revision=decision.base_revision,
        new_tasks=decision.new_tasks,
        next_task_id=decision.next_task_id,
        finish=decision.finish,
        summary=decision.summary.strip(),
        finish_basis_ids=decision.finish_basis_ids,
    )


__all__ = [
    "PlanValidationError",
    "SupervisorDecision",
    "TaskKind",
    "TaskProposal",
    "TaskRecord",
    "TaskStatus",
    "ValidPlan",
    "compile_plan",
]
