"""Local Action Session registry (0.1.21) — time-boxed capability bundles."""

from __future__ import annotations

import hashlib
import json
import os
import secrets
from dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

from iuve_connect_agents.shared.action_session_policy import (
    PLAN_BOUND_MIN_TTL_SECONDS,
    command_always_requires_confirm,
    permission_preset_allows_risk_skip,
    plan_step_matches_session,
    session_confirmation_allows_skip,
)
from iuve_connect_agents.shared.trusted_autonomous_policy import (
    TRUSTED_AUTONOMOUS_PROFILE,
    trusted_autonomous_skip_confirmation,
)

SCHEMA_VERSION = "0.1.21"
DEFAULT_STATE_DIR = Path.home() / ".config" / "iuve-connect-agent"
SESSIONS_FILENAME = "action-sessions.json"

# UI permission preset names — not Action Session preset keys.
_PERMISSION_PRESET_NAMES = frozenset({"minimal", "standard", "full", "expert"})

ConfirmationMode = str  # first_only | every_command | never_in_scope

WEB_CHAT_DOMAIN_ALLOWLIST: tuple[str, ...] = (
    "chatgpt.com",
    "chat.openai.com",
    "openai.com",
    "claude.ai",
    "gemini.google.com",
    "perplexity.ai",
    "copilot.microsoft.com",
    "bing.com",
    "poe.com",
    "you.com",
)

ACTION_SESSION_PRESET_KEYS: tuple[str, ...] = (
    "files",
    "web",
    "apps",
    "desktop",
    "network",
    "coding",
    "maintenance",
)

ACTION_SESSION_PRESET_ALIASES: dict[str, str] = {
    "document": "files",
    "browser": "web",
}

ACTION_SESSION_PRESET_GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
    ("common", ("files", "web", "apps", "network")),
    ("advanced", ("desktop", "coding", "maintenance")),
)


@dataclass
class ActionSession:
    session_id: str
    title: str
    capabilities: list[str]
    scope: dict[str, Any]
    expires_at: str
    confirmation_mode: ConfirmationMode = "first_only"
    created_at: str = ""
    granted_by: str = "local_user"
    audit_session_id: str = ""

    def to_dict(self) -> dict[str, Any]:
        return asdict(self)

    @classmethod
    def from_dict(cls, raw: dict[str, Any]) -> ActionSession:
        return cls(
            session_id=str(raw.get("session_id") or ""),
            title=str(raw.get("title") or ""),
            capabilities=[str(c) for c in (raw.get("capabilities") or []) if str(c).strip()],
            scope=dict(raw.get("scope") or {}),
            expires_at=str(raw.get("expires_at") or ""),
            confirmation_mode=str(raw.get("confirmation_mode") or "first_only"),
            created_at=str(raw.get("created_at") or ""),
            granted_by=str(raw.get("granted_by") or "local_user"),
            audit_session_id=str(raw.get("audit_session_id") or raw.get("session_id") or ""),
        )


@dataclass
class ActionSessionStore:
    """Persists active/revoked action sessions under owner-only JSON (0600)."""

    state_dir: Path = field(default_factory=lambda: DEFAULT_STATE_DIR)

    def __post_init__(self) -> None:
        self._path = (self.state_dir / SESSIONS_FILENAME).expanduser()
        self._path.parent.mkdir(parents=True, exist_ok=True)

    @property
    def path(self) -> Path:
        return self._path

    def create(
        self,
        *,
        title: str,
        capabilities: list[str],
        scope: dict[str, Any],
        ttl_seconds: int,
        confirmation_mode: ConfirmationMode = "first_only",
        session_id: str | None = None,
        bound_to_plan_id: str | None = None,
        permission_preset: str | None = None,
    ) -> ActionSession:
        now = datetime.now(timezone.utc)
        sid = str(session_id or f"sess_{secrets.token_hex(6)}")
        session_scope = dict(scope or {})
        plan_binding = str(bound_to_plan_id or session_scope.pop("bound_to_plan_id", "") or "").strip()
        if plan_binding:
            session_scope["bound_to_plan_id"] = plan_binding
        preset = str(permission_preset or session_scope.get("permission_preset") or "").strip().lower()
        if preset:
            session_scope["permission_preset"] = preset
        effective_ttl = int(ttl_seconds)
        if plan_binding:
            effective_ttl = max(effective_ttl, PLAN_BOUND_MIN_TTL_SECONDS)
        session = ActionSession(
            session_id=sid,
            title=str(title or "Action Session").strip() or "Action Session",
            capabilities=[str(c).strip() for c in capabilities if str(c).strip()],
            scope=session_scope,
            expires_at=(now + timedelta(seconds=max(60, effective_ttl))).isoformat(),
            confirmation_mode=str(confirmation_mode or "first_only"),
            created_at=now.isoformat(),
            granted_by="local_user",
            audit_session_id=sid,
        )
        data = self._load_raw()
        sessions = [s for s in data.get("sessions", []) if isinstance(s, dict)]
        sessions.append(session.to_dict())
        self._save({"schema_version": SCHEMA_VERSION, "sessions": sessions})
        return session

    def list_active(self) -> list[ActionSession]:
        now = datetime.now(timezone.utc)
        active: list[ActionSession] = []
        changed = False
        sessions_raw: list[dict[str, Any]] = []
        for raw in self._load_raw().get("sessions", []):
            if not isinstance(raw, dict):
                continue
            session = ActionSession.from_dict(raw)
            if str(raw.get("status") or "active") == "revoked":
                sessions_raw.append(raw)
                continue
            if self._is_expired(session, now=now):
                raw = dict(raw)
                raw["status"] = "expired"
                sessions_raw.append(raw)
                changed = True
                continue
            sessions_raw.append(raw)
            active.append(session)
        if changed:
            self._save({"schema_version": SCHEMA_VERSION, "sessions": sessions_raw})
        return active

    def revoke(self, session_id: str) -> bool:
        sid = str(session_id or "").strip()
        if not sid:
            return False
        data = self._load_raw()
        sessions = data.get("sessions", [])
        found = False
        updated: list[dict[str, Any]] = []
        for raw in sessions:
            if not isinstance(raw, dict):
                continue
            if str(raw.get("session_id") or "") == sid and str(raw.get("status") or "active") == "active":
                entry = dict(raw)
                entry["status"] = "revoked"
                entry["revoked_at"] = datetime.now(timezone.utc).isoformat()
                updated.append(entry)
                found = True
            else:
                updated.append(raw)
        if found:
            self._save({"schema_version": SCHEMA_VERSION, "sessions": updated})
        return found

    def find_active_bound_to_plan(self, plan_id: str) -> ActionSession | None:
        target = str(plan_id or "").strip()
        if not target:
            return None
        for session in self.list_active():
            if str(session.scope.get("bound_to_plan_id") or "").strip() == target:
                return session
        return None

    def revoke_bound_to_plan(self, plan_id: str) -> bool:
        session = self.find_active_bound_to_plan(plan_id)
        if session is None:
            return False
        return self.revoke(session.session_id)

    def revoke_all(self) -> int:
        data = self._load_raw()
        count = 0
        updated: list[dict[str, Any]] = []
        now = datetime.now(timezone.utc).isoformat()
        for raw in data.get("sessions", []):
            if not isinstance(raw, dict):
                continue
            if str(raw.get("status") or "active") != "active":
                updated.append(raw)
                continue
            entry = dict(raw)
            entry["status"] = "revoked"
            entry["revoked_at"] = now
            updated.append(entry)
            count += 1
        self._save({"schema_version": SCHEMA_VERSION, "sessions": updated})
        return count

    def active_sessions_digest(self) -> str:
        """Short stable digest of active session ids + expiry for heartbeat metadata."""
        active = self.list_active()
        if not active:
            return "none"
        parts = sorted(f"{s.session_id}:{s.expires_at}" for s in active)
        digest = hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
        return digest[:16]

    def active_session_count(self) -> int:
        return len(self.list_active())

    def find_covering_session(
        self,
        *,
        command_type: str,
        canonical_capability: str,
        envelope_scope: dict[str, Any],
        payload: dict[str, Any],
        plan_id: str = "",
    ) -> ActionSession | None:
        step_plan_id = str(plan_id or payload.get("plan_id") or "").strip()
        for session in self.list_active():
            session_scope = session.scope or {}
            bound_plan = str(session_scope.get("bound_to_plan_id") or "").strip()
            if bound_plan and not plan_step_matches_session(session_scope, plan_id=step_plan_id):
                continue
            caps = set(session.capabilities)
            recovery_screen_capture = (
                str(command_type or "").strip().lower() == "capture_screenshot"
                and bool(payload.get("recovery_attempt"))
                and bool(payload.get("screen_vision_granted"))
            )
            if canonical_capability not in caps:
                if not (
                    recovery_screen_capture
                    and caps.intersection({"BROWSER_OPEN_URL", "MANAGE_DESKTOP_APPS"})
                ):
                    continue
            if not self._command_scope_in_session(
                command_type=command_type,
                envelope_scope=envelope_scope,
                payload=payload,
                session_scope=session.scope,
            ):
                continue
            return session
        return None

    def is_command_in_session_scope(
        self,
        *,
        command_type: str,
        canonical_capability: str,
        envelope_scope: dict[str, Any],
        payload: dict[str, Any] | None = None,
    ) -> bool:
        return self.find_covering_session(
            command_type=command_type,
            canonical_capability=canonical_capability,
            envelope_scope=envelope_scope,
            payload=payload or {},
        ) is not None

    def should_skip_confirmation(
        self,
        *,
        session: ActionSession,
        risk_level: str,
        command_type: str,
        canonical_capability: str,
        envelope_scope: dict[str, Any],
        payload: dict[str, Any],
        trust_state: str = "",
    ) -> bool:
        if command_always_requires_confirm(command_type, payload):
            return False

        session_scope = session.scope or {}
        plan_bound = bool(str(session_scope.get("bound_to_plan_id") or "").strip())
        step_plan_id = str(payload.get("plan_id") or "").strip()
        if plan_bound and not plan_step_matches_session(session_scope, plan_id=step_plan_id):
            return False

        in_scope = self.is_command_in_session_scope(
            command_type=command_type,
            canonical_capability=canonical_capability,
            envelope_scope=envelope_scope,
            payload=payload,
        )
        confirmation_mode = str(session.confirmation_mode or "first_only")

        from iuve_connect_agents.shared.goal_authorized_execution import (
            goal_authorization_skip_confirmation,
        )

        if goal_authorization_skip_confirmation(
            command_type=command_type,
            payload=payload,
            session_scope=session_scope,
            trust_state=trust_state,
            confirmation_mode=confirmation_mode,
            plan_bound=plan_bound,
            risk_level=risk_level,
            in_session_scope=in_scope,
        ):
            return True

        if trusted_autonomous_skip_confirmation(
            command_type=command_type,
            payload=payload,
            session_scope=session_scope,
            trust_state=trust_state,
            confirmation_mode=confirmation_mode,
            plan_bound=plan_bound,
        ) and in_scope:
            return True

        if (
            command_type == "capture_screenshot"
            and risk_level == "medium"
            and payload.get("recovery_attempt")
            and payload.get("screen_vision_granted")
        ):
            from iuve_connect_agents.shared.screen_vision_gate import read_screen_vision_grant_active

            if read_screen_vision_grant_active():
                preset = str(session_scope.get("preset_key") or "").strip().lower()
                if preset == "web":
                    return True

        if not self.is_command_in_session_scope(
            command_type=command_type,
            canonical_capability=canonical_capability,
            envelope_scope=envelope_scope,
            payload=payload,
        ):
            return False

        permission_preset = str(session_scope.get("permission_preset") or "standard").strip().lower()
        if not permission_preset_allows_risk_skip(
            permission_preset,
            risk_level=risk_level,
            command_type=command_type,
            plan_bound=plan_bound,
        ):
            return False

        return session_confirmation_allows_skip(
            str(session.confirmation_mode or "first_only"),
            plan_bound=plan_bound,
        )

    @staticmethod
    def _default_workspace(*, workspace_path: str | None = None) -> str:
        home = Path.home()
        return str(workspace_path or home / "IuVeWorkspace")

    @staticmethod
    def plan_bound_session_spec(
        preset_key: str | None,
        *,
        workspace_path: str | None = None,
        permission_preset: str = "standard",
        preset_keys: list[str] | None = None,
    ) -> dict[str, Any]:
        merged = [str(k).strip().lower() for k in (preset_keys or []) if str(k).strip()]
        key = str(preset_key or "").strip().lower()
        if key in _PERMISSION_PRESET_NAMES:
            key = ""
        if len(merged) > 1:
            spec = ActionSessionStore.merge_plan_bound_session_spec(
                merged,
                workspace_path=workspace_path,
                permission_preset=permission_preset,
            )
        elif key:
            spec = dict(ActionSessionStore.get_preset(key, workspace_path=workspace_path))
            spec["confirmation_mode"] = "never_in_scope"
            spec["ttl_seconds"] = max(int(spec.get("ttl_seconds") or 0), PLAN_BOUND_MIN_TTL_SECONDS)
            scope = dict(spec.get("scope") or {})
            scope["permission_preset"] = str(permission_preset or "standard").strip().lower()
            spec["scope"] = scope
            caps = [str(c) for c in (spec.get("capabilities") or []) if str(c).strip()]
            spec["capabilities"] = caps
            ActionSessionStore._finalize_plan_bound_spec(spec, permission_preset=permission_preset)
            return spec
        else:
            spec = {
                "title": "Task session",
                "capabilities": ["READ_ONLY_SYSTEM_INFO", "BROWSER_OPEN_URL"],
                "scope": {"preset_key": "task"},
                "ttl_seconds": PLAN_BOUND_MIN_TTL_SECONDS,
                "confirmation_mode": "first_only",
            }
        spec["confirmation_mode"] = "never_in_scope"
        spec["ttl_seconds"] = max(int(spec.get("ttl_seconds") or 0), PLAN_BOUND_MIN_TTL_SECONDS)
        scope = dict(spec.get("scope") or {})
        scope["permission_preset"] = str(permission_preset or "standard").strip().lower()
        spec["scope"] = scope
        caps = [str(c) for c in (spec.get("capabilities") or []) if str(c).strip()]
        spec["capabilities"] = caps
        ActionSessionStore._finalize_plan_bound_spec(spec, permission_preset=permission_preset)
        return spec

    @staticmethod
    def merge_plan_bound_session_spec(
        preset_keys: list[str],
        *,
        workspace_path: str | None = None,
        permission_preset: str = "standard",
    ) -> dict[str, Any]:
        keys = [str(k).strip().lower() for k in preset_keys if str(k).strip()]
        if not keys:
            return ActionSessionStore.plan_bound_session_spec(
                None,
                workspace_path=workspace_path,
                permission_preset=permission_preset,
            )
        if len(keys) == 1:
            return ActionSessionStore.plan_bound_session_spec(
                keys[0],
                workspace_path=workspace_path,
                permission_preset=permission_preset,
            )

        caps: list[str] = []
        scope: dict[str, Any] = {"preset_keys": sorted(set(keys))}
        ttl_seconds = PLAN_BOUND_MIN_TTL_SECONDS
        titles: list[str] = []
        for key in keys:
            part = dict(ActionSessionStore.get_preset(key, workspace_path=workspace_path))
            titles.append(str(part.get("title") or key))
            ttl_seconds = max(ttl_seconds, int(part.get("ttl_seconds") or 0))
            for cap in part.get("capabilities") or []:
                cap_s = str(cap).strip()
                if cap_s and cap_s not in caps:
                    caps.append(cap_s)
            part_scope = dict(part.get("scope") or {})
            for field in (
                "workspace_paths",
                "domain_allowlist",
                "keyboard_domain_allowlist",
                "input_modes",
                "allowed_network_checks",
                "allowed_services",
                "allowed_packages",
                "extensions",
            ):
                incoming = part_scope.get(field)
                if not isinstance(incoming, list) or not incoming:
                    continue
                existing = scope.get(field)
                if not isinstance(existing, list):
                    scope[field] = list(incoming)
                    continue
                for item in incoming:
                    if item not in existing:
                        existing.append(item)
            for flag in ("desktop_apps", "keyboard_input", "maintenance_dry_run"):
                if part_scope.get(flag):
                    scope[flag] = True
            preset_marker = str(part_scope.get("preset_key") or key).strip().lower()
            if preset_marker:
                scope.setdefault("preset_key", preset_marker)

        spec = {
            "title": " + ".join(titles[:3]) if titles else "Task session",
            "capabilities": caps,
            "scope": scope,
            "ttl_seconds": max(ttl_seconds, PLAN_BOUND_MIN_TTL_SECONDS),
            "confirmation_mode": "never_in_scope",
        }
        spec["scope"]["permission_preset"] = str(permission_preset or "standard").strip().lower()
        ActionSessionStore._finalize_plan_bound_spec(spec, permission_preset=permission_preset)
        return spec

    @staticmethod
    def _finalize_plan_bound_spec(spec: dict[str, Any], *, permission_preset: str) -> None:
        scope = dict(spec.get("scope") or {})
        preset = str(permission_preset or scope.get("permission_preset") or "standard").strip().lower()
        scope["permission_preset"] = preset
        preset_key = str(scope.get("preset_key") or "").strip().lower()
        preset_keys = scope.get("preset_keys")
        merged_keys = (
            {str(k).strip().lower() for k in preset_keys if str(k).strip()}
            if isinstance(preset_keys, list)
            else set()
        )
        if preset_key == "web" or "web" in merged_keys:
            scope["keyboard_input"] = True
            input_modes = scope.get("input_modes")
            if not isinstance(input_modes, list):
                input_modes = []
            if "keyboard" not in [str(m).lower() for m in input_modes]:
                input_modes.append("keyboard")
            scope["input_modes"] = input_modes
        spec["scope"] = scope
        caps = [str(c) for c in (spec.get("capabilities") or []) if str(c).strip()]
        if "SCREEN_CAPTURE" not in caps:
            caps.append("SCREEN_CAPTURE")
        if scope.get("keyboard_input") and "MANAGE_DESKTOP_APPS" not in caps:
            caps.append("MANAGE_DESKTOP_APPS")
        spec["capabilities"] = caps

    @staticmethod
    def get_preset(key: str, *, workspace_path: str | None = None) -> dict[str, Any]:
        resolved = ACTION_SESSION_PRESET_ALIASES.get(str(key or "").strip().lower(), str(key or "").strip().lower())
        builders: dict[str, Any] = {
            "files": ActionSessionStore.preset_files,
            "web": ActionSessionStore.preset_web,
            "apps": ActionSessionStore.preset_apps,
            "desktop": ActionSessionStore.preset_desktop,
            "network": ActionSessionStore.preset_network,
            "coding": ActionSessionStore.preset_coding,
            "maintenance": ActionSessionStore.preset_maintenance,
        }
        builder = builders.get(resolved)
        if builder is None:
            raise ValueError(f"unknown action session preset: {key}")
        if resolved in {"files", "coding"}:
            return dict(builder(workspace_path=workspace_path))
        return dict(builder())

    @staticmethod
    def preset_files(*, workspace_path: str | None = None) -> dict[str, Any]:
        workspace = ActionSessionStore._default_workspace(workspace_path=workspace_path)
        return {
            "title": "Work with files",
            "capabilities": [
                "READ_FILES_ALLOWED_PATHS",
                "WRITE_FILES_ALLOWED_PATHS",
                "PROJECT_WORKSPACE_ACCESS",
            ],
            "scope": {
                "workspace_paths": [workspace],
                "preset_key": "files",
            },
            "ttl_seconds": 2 * 3600,
            "confirmation_mode": "first_only",
        }

    @staticmethod
    def preset_web() -> dict[str, Any]:
        return {
            "title": "Websites & ChatGPT",
            "capabilities": ["BROWSER_OPEN_URL", "MANAGE_DESKTOP_APPS"],
            "scope": {
                "domain_allowlist": ["*"],
                "keyboard_input": True,
                "keyboard_domain_allowlist": list(WEB_CHAT_DOMAIN_ALLOWLIST),
                "preset_key": "web",
            },
            "ttl_seconds": 3600,
            "confirmation_mode": "never_in_scope",
        }

    @staticmethod
    def preset_apps() -> dict[str, Any]:
        return {
            "title": "Apps on PC",
            "capabilities": ["MANAGE_DESKTOP_APPS"],
            "scope": {
                "desktop_apps": True,
                "preset_key": "apps",
            },
            "ttl_seconds": 3600,
            "confirmation_mode": "first_only",
        }

    @staticmethod
    def preset_desktop() -> dict[str, Any]:
        return {
            "title": "Mouse & keyboard",
            "capabilities": ["MANAGE_DESKTOP_APPS"],
            "scope": {
                "input_modes": ["mouse", "keyboard"],
                "preset_key": "desktop",
            },
            "ttl_seconds": 30 * 60,
            "confirmation_mode": "first_only",
        }

    @staticmethod
    def preset_network() -> dict[str, Any]:
        return {
            "title": "Network check",
            "capabilities": ["READ_ONLY_SYSTEM_INFO"],
            "scope": {
                "allowed_network_checks": ["ping", "dns", "traceroute", "curl", "mtr", "http"],
                "preset_key": "network",
            },
            "ttl_seconds": 30 * 60,
            "confirmation_mode": "never_in_scope",
        }

    @staticmethod
    def preset_coding(*, workspace_path: str | None = None) -> dict[str, Any]:
        workspace = ActionSessionStore._default_workspace(workspace_path=workspace_path)
        return {
            "title": "Code & Cursor",
            "capabilities": [
                "READ_FILES_ALLOWED_PATHS",
                "WRITE_FILES_ALLOWED_PATHS",
                "PROJECT_WORKSPACE_ACCESS",
                "MANAGE_DESKTOP_APPS",
            ],
            "scope": {
                "workspace_paths": [workspace],
                "desktop_apps": True,
                "input_modes": ["keyboard"],
                "preset_key": "coding",
            },
            "ttl_seconds": 2 * 3600,
            "confirmation_mode": "first_only",
        }

    @staticmethod
    def preset_maintenance() -> dict[str, Any]:
        return {
            "title": "PC maintenance",
            "capabilities": ["INSTALL_PACKAGES"],
            "scope": {
                "maintenance_dry_run": True,
                "preset_key": "maintenance",
            },
            "ttl_seconds": 30 * 60,
            "confirmation_mode": "first_only",
        }

    @staticmethod
    def preset_document_editing(*, workspace_path: str | None = None) -> dict[str, Any]:
        """Legacy alias for ``preset_files``."""
        return ActionSessionStore.preset_files(workspace_path=workspace_path)

    @staticmethod
    def preset_browser_research() -> dict[str, Any]:
        """Legacy alias for ``preset_web``."""
        return ActionSessionStore.preset_web()

    def _command_scope_in_session(
        self,
        *,
        command_type: str,
        envelope_scope: dict[str, Any],
        payload: dict[str, Any],
        session_scope: dict[str, Any],
    ) -> bool:
        ctype = str(command_type or "").strip().lower()

        if ctype in {"read_file_in_workspace", "write_file_in_workspace", "open_document_edit", "open_folder"}:
            if ctype == "open_document_edit" and session_scope.get("desktop_apps"):
                return True
            if ctype == "open_folder":
                session_paths = session_scope.get("workspace_paths")
                cmd_paths = envelope_scope.get("workspace_paths")
                if not isinstance(session_paths, list) or not session_paths:
                    return bool(session_scope.get("bound_to_plan_id"))
                if not isinstance(cmd_paths, list) or not cmd_paths:
                    return bool(session_scope.get("bound_to_plan_id"))
                target = ActionSessionStore._resolve_workspace_target(payload, cmd_paths)
                if target is None:
                    return False
                for root in session_paths:
                    root_real = os.path.realpath(str(root))
                    root_prefix = root_real if root_real.endswith(os.sep) else root_real + os.sep
                    if target == root_real or target.startswith(root_prefix):
                        return True
                return False
            session_paths = session_scope.get("workspace_paths")
            cmd_paths = envelope_scope.get("workspace_paths")
            if not isinstance(session_paths, list) or not session_paths:
                return False
            if not isinstance(cmd_paths, list) or not cmd_paths:
                return False
            target = ActionSessionStore._resolve_workspace_target(payload, cmd_paths)
            if target is None:
                return False
            for root in session_paths:
                root_real = os.path.realpath(str(root))
                root_prefix = root_real if root_real.endswith(os.sep) else root_real + os.sep
                if target == root_real or target.startswith(root_prefix):
                    extensions = session_scope.get("extensions")
                    if isinstance(extensions, list) and extensions:
                        ext = Path(target).suffix.lower()
                        allowed = {str(e).lower() if str(e).startswith(".") else f".{str(e).lower()}" for e in extensions}
                        if ext and ext not in allowed:
                            return False
                    return True
            return False

        if ctype in {"browser_open_url", "open_url", "web_fetch_bounded", "summarize_content"}:
            if str(session_scope.get("bound_to_plan_id") or "").strip():
                return True
            if ctype in {"web_fetch_bounded", "summarize_content"}:
                session_domains = session_scope.get("domain_allowlist")
                if session_scope.get("bound_to_plan_id"):
                    return True
                if not isinstance(session_domains, list) or not session_domains:
                    return bool(session_scope.get("keyboard_input"))
                return True
            session_domains = session_scope.get("domain_allowlist")
            url = str(payload.get("url") or "").strip()
            if not url:
                return True
            if not isinstance(session_domains, list) or not session_domains:
                return True
            return ActionSessionStore._url_allowed(url, session_domains)

        if ctype == "application_launch":
            if session_scope.get("desktop_apps"):
                return True
            if str(session_scope.get("bound_to_plan_id") or "").strip():
                return True
            return not session_scope

        if ctype == "keyboard_type_text":
            if str(session_scope.get("bound_to_plan_id") or "").strip():
                return True
            input_modes = session_scope.get("input_modes")
            if isinstance(input_modes, list) and "keyboard" in [str(m).lower() for m in input_modes]:
                return True
            if session_scope.get("keyboard_input"):
                target_app = str(
                    payload.get("target_app") or payload.get("target_window") or "active"
                ).strip().lower()
                if target_app in {"cursor", "code", "vscode"} and session_scope.get("desktop_apps"):
                    return True
                url = str(payload.get("url") or "").strip()
                kb_domains = session_scope.get("keyboard_domain_allowlist")
                if url and isinstance(kb_domains, list) and kb_domains:
                    return ActionSessionStore._url_allowed(url, kb_domains)
                if target_app in {"browser", "active", ""}:
                    return True
            return False

        if ctype == "mouse_move_relative":
            input_modes = session_scope.get("input_modes")
            if isinstance(input_modes, list) and "mouse" in [str(m).lower() for m in input_modes]:
                return True
            return False

        if ctype == "service_restart":
            allowed_services = session_scope.get("allowed_services")
            service_name = str(payload.get("service_name") or "").strip()
            if isinstance(allowed_services, list) and allowed_services:
                return service_name in allowed_services
            return False

        if ctype == "package_install_dry_run":
            if session_scope.get("maintenance_dry_run"):
                return True
            allowed_packages = session_scope.get("allowed_packages")
            package_name = str(payload.get("package_name") or "").strip()
            if isinstance(allowed_packages, list) and allowed_packages:
                return package_name in allowed_packages
            return False

        if ctype == "network_diagnostics":
            allowed_checks = session_scope.get("allowed_network_checks")
            check_target = str(payload.get("target") or payload.get("host") or "").strip()
            if isinstance(allowed_checks, list) and allowed_checks:
                return not check_target or check_target in allowed_checks
            return False

        if ctype == "capture_screenshot":
            if session_scope.get("bound_to_plan_id"):
                return True
            if payload.get("recovery_attempt") and payload.get("screen_vision_granted"):
                preset = str(session_scope.get("preset_key") or "").strip().lower()
                if preset == "web" or session_scope.get("keyboard_input"):
                    return True
            preset = str(session_scope.get("preset_key") or "").strip().lower()
            if preset in {"desktop", "coding", "web"}:
                return True
            return False

        if ctype == "verify_screen_contains_text":
            if session_scope.get("bound_to_plan_id"):
                return True
            preset = str(session_scope.get("preset_key") or "").strip().lower()
            return preset in {"desktop", "coding", "web"} or bool(session_scope.get("keyboard_input"))

        web_plan_types = {
            "insert_exact_text",
            "generate_content",
            "describe_active_window",
            "verify_web_draft",
            "verify_published",
            "open_web_composer",
        }
        if ctype in web_plan_types:
            if str(session_scope.get("bound_to_plan_id") or "").strip():
                return True
            preset = str(session_scope.get("preset_key") or "").strip().lower()
            if preset == "web" or session_scope.get("keyboard_input"):
                return True
            return False

        low_scope_types = {
            "get_status",
            "get_diagnostics",
            "collect_logs",
            "check_update",
            "open_local_console_notification_only",
        }
        if ctype in low_scope_types:
            return not session_scope
        if str(session_scope.get("bound_to_plan_id") or "").strip():
            return True
        return not session_scope

    @staticmethod
    def _resolve_workspace_target(payload: dict[str, Any], workspace_paths: list[Any]) -> str | None:
        relative_path = str(payload.get("relative_path") or "").strip()
        absolute_path = str(payload.get("absolute_path") or payload.get("path") or "").strip()
        roots = [os.path.realpath(str(root)) for root in workspace_paths if str(root).strip()]
        if not roots:
            return None
        if absolute_path:
            candidate = os.path.realpath(absolute_path)
        elif relative_path:
            candidate = os.path.realpath(os.path.join(roots[0], relative_path))
        else:
            return None
        for root in roots:
            root_prefix = root if root.endswith(os.sep) else root + os.sep
            if candidate == root or candidate.startswith(root_prefix):
                return candidate
        return None

    @staticmethod
    def _url_allowed(url: str, domain_allowlist: list[Any]) -> bool:
        parsed = urlparse(url)
        if parsed.scheme not in {"http", "https"}:
            return False
        host = (parsed.hostname or "").lower()
        if not host:
            return False
        for entry in domain_allowlist:
            pattern = str(entry or "").strip().lower()
            if not pattern:
                continue
            if pattern == "*":
                return True
            if pattern.startswith("*."):
                suffix = pattern[2:]
                if host == suffix or host.endswith(f".{suffix}"):
                    return True
            elif host == pattern:
                return True
        return False

    @staticmethod
    def _is_expired(session: ActionSession, *, now: datetime | None = None) -> bool:
        now = now or datetime.now(timezone.utc)
        raw = str(session.expires_at or "").strip()
        if not raw:
            return True
        try:
            expires = datetime.fromisoformat(raw.replace("Z", "+00:00"))
            if expires.tzinfo is None:
                expires = expires.replace(tzinfo=timezone.utc)
            return expires <= now
        except Exception:
            return True

    def _load_raw(self) -> dict[str, Any]:
        if not self._path.exists():
            return {"schema_version": SCHEMA_VERSION, "sessions": []}
        try:
            data = json.loads(self._path.read_text(encoding="utf-8"))
            if isinstance(data, dict):
                return data
        except Exception:
            pass
        return {"schema_version": SCHEMA_VERSION, "sessions": []}

    def _save(self, payload: dict[str, Any]) -> None:
        self._path.parent.mkdir(parents=True, exist_ok=True)
        self._path.write_text(
            json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True),
            encoding="utf-8",
        )
        try:
            os.chmod(self._path, 0o600)
        except OSError:
            pass
