"""Opt-in, source-grounded activity experiments; no benchmark identity or verdict input.

A contract describes a reviewed hypothesis, not an asserted vulnerability. V1 is
restricted to permission-free, caller-independent activity screens. It is not a
source-code verifier or an oracle for arbitrary authorization implementations.
"""
from __future__ import annotations

from dataclasses import asdict, dataclass
import hashlib
import json
from pathlib import PurePosixPath
import re
import xml.etree.ElementTree as ET

from apk_workspace.search import WorkspaceSearch
from validator.capabilities import TOOL_METADATA
from .capabilities import resolve_manifest_target
from .models import (
    CapabilityView, EvidenceAssertion, EvidenceAssertionKind, EvidencePolarity,
    FinishValidationAction, PredicateResult, ValidationAssessment,
    ValidationAssessmentAction, ValidationFeasibility, ValidationToolAction,
)

ACTIVITY_ORACLE_VERSION = "post-freeze-activity-ui-v1"
CALLER_MODEL = "REVIEWED_CALLER_INDEPENDENT_PERMISSION_FREE"
TOOLS = ("read_resource_file", "read_source_file", "check_package_installed",
         "force_stop_package", "launch_activity", "inspect_resumed_activity", "dump_ui_hierarchy")
ANDROID = "{http://schemas.android.com/apk/res/android}"


def content_digest(text: str) -> str:
    return hashlib.sha256("\n".join(text.splitlines()).encode()).hexdigest()


@dataclass(frozen=True, slots=True)
class SourceAnchor:
    path: str
    sha256: str

    def __post_init__(self):
        if (not isinstance(self.path, str) or len(self.path) > 256
                or PurePosixPath(self.path).is_absolute()
                or any(p in {"..", "."} for p in self.path.split("/"))
                or not re.fullmatch(r"[a-f0-9]{64}", self.sha256)):
            raise ValueError("Invalid source anchor.")


@dataclass(frozen=True, slots=True)
class UiTextSelector:
    source_path: str
    resource_name: str
    text: str

    def __post_init__(self):
        if not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]{0,100}", self.resource_name):
            raise ValueError("Invalid string resource name.")
        if not isinstance(self.text, str) or not self.text.strip() or len(self.text) > 200:
            raise ValueError("UI selector text must be complete and bounded.")


@dataclass(frozen=True, slots=True)
class ActivityUiExperiment:
    apk_sha256: str
    target_component: str
    hypothesis: str
    manifest_path: str
    resource_path: str
    target_source_path: str
    anchors: tuple[SourceAnchor, ...]
    protected_screen: UiTextSelector
    caller_model: str
    guard_component: str | None = None
    guard_screen: UiTextSelector | None = None
    guard_redirect: str | None = None
    version: str = ACTIVITY_ORACLE_VERSION

    def __post_init__(self):
        object.__setattr__(self, "anchors", tuple(self.anchors))
        if self.version != ACTIVITY_ORACLE_VERSION or not re.fullmatch(r"[a-f0-9]{64}", self.apk_sha256):
            raise ValueError("Unsupported experiment version or APK digest.")
        if not self.hypothesis.strip() or len(self.hypothesis) > 1000:
            raise ValueError("A bounded access hypothesis is required.")
        if not 3 <= len(self.anchors) <= 4 or len({a.path for a in self.anchors}) != len(self.anchors):
            raise ValueError("An experiment requires three or four unique evidence anchors.")
        if any(x is not None for x in (self.guard_component, self.guard_screen, self.guard_redirect)) and not all(x is not None for x in (self.guard_component, self.guard_screen, self.guard_redirect)):
            raise ValueError("An explicit guard requires a component, selector and redirect anchor.")
        if self.guard_redirect is not None and not 1 <= len(self.guard_redirect) <= 2048:
            raise ValueError("Guard redirect anchor exceeds its bound.")
        expected = {self.manifest_path, self.resource_path, self.target_source_path, self.protected_screen.source_path}
        if self.guard_screen:
            expected.add(self.guard_screen.source_path)
        if expected != {a.path for a in self.anchors} or self.protected_screen.source_path != self.target_source_path:
            raise ValueError("Selectors must be tied to the anchored target/guard sources.")

    def to_dict(self):
        result = asdict(self)
        result["anchors"] = list(result["anchors"])
        return result

    @property
    def digest(self):
        return hashlib.sha256(json.dumps(self.to_dict(), sort_keys=True, separators=(",", ":")).encode()).hexdigest()

    @classmethod
    def from_dict(cls, payload):
        if not isinstance(payload, dict) or set(payload) != set(cls.__dataclass_fields__):
            raise ValueError("Invalid activity experiment fields.")
        data = dict(payload)
        data["anchors"] = tuple(SourceAnchor(**a) for a in data["anchors"])
        data["protected_screen"] = UiTextSelector(**data["protected_screen"])
        if data["guard_screen"] is not None:
            data["guard_screen"] = UiTextSelector(**data["guard_screen"])
        return cls(**data)

    def read_actions(self):
        return tuple(ValidationToolAction(
            "read_source_file" if a.path.startswith("sources/") else "read_resource_file",
            {"path": a.path, "start_line": 1, "end_line": 200},
            "Read the complete bounded experiment evidence anchor.",
        ) for a in self.anchors)


def _code(text):
    # Comments are not executable evidence of a relationship.
    return re.sub(r"/\*.*?\*/|//[^\n]*", "", text, flags=re.S)


def grounding_error(experiment, contents, package):
    """Verify exact anchored evidence and the narrow declared UI relationships."""
    if experiment.caller_model != CALLER_MODEL:
        return "Caller-sensitive hypotheses require a separate caller capability."
    if any(a.path not in contents or content_digest(contents[a.path]) != a.sha256 for a in experiment.anchors):
        return "An experiment evidence anchor is missing or changed."
    try:
        manifest = ET.fromstring(contents[experiment.manifest_path])
        if manifest.get("package") != package:
            return "Manifest package differs from the execution package."
        application = manifest.find("application")
        if application is None or application.get(ANDROID + "permission"):
            return "Permission-protected application is outside this route."
        declarations = {}
        for item in application.findall("activity"):
            name = item.get(ANDROID + "name", "")
            fqcn = package + name if name.startswith(".") else (package + "." + name if "." not in name else name)
            declarations[package + "/" + fqcn] = item
        target = declarations.get(experiment.target_component)
        if target is None or target.get(ANDROID + "exported") != "true" or target.get(ANDROID + "permission") or target.get(ANDROID + "enabled") == "false":
            return "Target must be an explicitly exported, enabled, permission-free activity."
        if experiment.guard_component and (experiment.guard_component not in declarations or experiment.guard_component == experiment.target_component):
            return "Guard redirect is not a distinct manifest activity."
        resources = ET.fromstring(contents[experiment.resource_path])
        strings = {}
        for item in resources.findall("string"):
            key = item.get("name")
            if key in strings:
                return "Ambiguous string resource."
            strings[key] = "".join(item.itertext())
        for selector, component in ((experiment.protected_screen, experiment.target_component), (experiment.guard_screen, experiment.guard_component)):
            if selector is None:
                continue
            source = _code(contents[selector.source_path])
            name = component.split("/")[1].rsplit(".", 1)[-1]
            source_package = component.split("/")[1].rsplit(".", 1)[0]
            if not re.search(r"\bpackage\s+" + re.escape(source_package) + r"\s*;", source):
                return "Selector source package differs from its activity."
            if not re.search(r"\bclass\s+" + re.escape(name) + r"\b", source):
                return "Selector source does not declare its activity."
            if strings.get(selector.resource_name) != selector.text:
                return "Selector differs from its packaged string resource."
            if not re.search(r"\bsetText\s*\(\s*getString\s*\(\s*R\.string\." + re.escape(selector.resource_name) + r"\s*\)\s*\)", source):
                return "Source does not directly bind the selected resource to UI text."
            if not re.search(r"\bset(?:Text|ContentView)\s*\(", source):
                return "Source has no supported UI binding."
            if re.search(r"\b(?:getCallingUid|getCallingPid|getCallingPackage|checkCallingPermission|checkCallingOrSelfPermission|enforceCallingPermission|getReferrer)\s*\(", source):
                return "Caller-dependent code is outside the reviewed route."
        if experiment.guard_screen:
            source = _code(contents[experiment.target_source_path])
            redirect = _code(experiment.guard_redirect).strip()
            guard_class = experiment.guard_component.split("/")[1].rsplit(".", 1)[-1]
            if redirect not in source or not all(re.search(pattern, redirect) for pattern in (
                r"\bif\s*\(", r"\b" + re.escape(guard_class) + r"\.class\b",
                r"\bstartActivity\s*\(", r"\bfinish\s*\(\s*\)", r"\breturn\s*;",
            )):
                return "Guard is not anchored to an explicit conditional redirect/finish path."
    except (ET.ParseError, KeyError, TypeError):
        return "Malformed source-grounding evidence."
    return None


def experiment_capabilities(experiment, finding, environment, oracle_available=True):
    """Preflight reads only approved indexed bounded files; it never grants new tools."""
    reason = None
    workspace = environment.workspace
    if not oracle_available:
        reason = "Required activity UI oracle is unavailable."
    elif not environment.device_available:
        reason = "No configured device for this activity experiment."
    elif experiment.apk_sha256 != workspace.apk_sha256:
        reason = "Experiment APK digest differs from the validated workspace."
    elif experiment.hypothesis != finding.speculative_finding.validation_hypothesis:
        reason = "Experiment must address the finding's unchanged hypothesis."
    elif resolve_manifest_target(workspace, finding.speculative_finding.entry_point, finding.speculative_finding.affected_component) != experiment.target_component:
        reason = "Finding target is unresolved, ambiguous, or differs from the experiment."
    elif environment.execution_context.resolved_component != experiment.target_component:
        reason = "Execution context differs from the experiment target."
    elif experiment.manifest_path != workspace.manifest_path:
        reason = "Experiment manifest is not the packaged workspace manifest."
    else:
        try:
            search = WorkspaceSearch(workspace)
            allowed = {x.relative_path for x in search.source_files()} | {x.relative_path for x in search.resources() if x.text_readable}
            contents = {}
            for anchor in experiment.anchors:
                path = workspace.workspace_path / anchor.path
                if anchor.path not in allowed or not path.resolve().is_relative_to(workspace.workspace_path.resolve()) or path.is_symlink() or path.stat().st_size > 6144:
                    raise ValueError("Anchor is not a bounded approved workspace file.")
                content = path.read_text(encoding="utf-8")
                if len(content.splitlines()) > 200:
                    raise ValueError("Anchor exceeds the existing bounded read limit.")
                contents[anchor.path] = content
            reason = grounding_error(experiment, contents, workspace.metadata.package_name)
        except (OSError, ValueError):
            reason = "Required indexed source-grounding evidence is unavailable."
    return CapabilityView(
        tuple(TOOL_METADATA[name] for name in TOOLS),
        {} if reason is None else {"ACTIVITY_UI_ORACLE": reason},
        ValidationFeasibility.DYNAMIC_SUPPORTED if reason is None else ValidationFeasibility.REQUIRED_CAPABILITY_UNAVAILABLE,
    )


def activity_action_error(experiment, action):
    if not isinstance(action, ValidationToolAction):
        return None
    if action.tool_name == "launch_activity" and dict(action.arguments) != {"component": experiment.target_component}:
        return "Activity experiment permits only its exact contracted external launch."
    if action.tool_name.startswith("read_"):
        if not any(action.tool_name == a.tool_name and dict(action.arguments) == dict(a.arguments) for a in experiment.read_actions()):
            return "Read is outside the activity experiment evidence anchors."
    return None


def activity_predicates(experiment, observations):
    """An ordered, complete experiment is the only decisive predicate producer."""
    obs = tuple(o for o in observations if o.source == "STAGE_O_TOOL")
    expected = [a.tool_name for a in experiment.read_actions()] + ["check_package_installed", "force_stop_package", "launch_activity", "inspect_resumed_activity", "dump_ui_hierarchy", "inspect_resumed_activity"]
    if len(obs) != len(expected) or any(o.originating_tool != tool or not o.success or o.truncated or o.data.get("activity_experiment_sha256") != experiment.digest for o, tool in zip(obs, expected)):
        return ()
    n = len(experiment.anchors)
    contents = {}
    for anchor, observation in zip(experiment.anchors, obs[:n]):
        if observation.data.get("path") != anchor.path or observation.data.get("start_line") != 1 or not isinstance(observation.data.get("content"), str):
            return ()
        contents[anchor.path] = observation.data["content"]
    package = experiment.target_component.split("/")[0]
    if grounding_error(experiment, contents, package):
        return ()
    installed, stopped, launched, resumed, ui, confirmed = obs[n:]
    if installed.data.get("installed") is not True or stopped.data.get("completed") is not True:
        return ()
    if dict(launched.data.get("executed_arguments", {})) != {"component": experiment.target_component} or launched.data.get("component") != experiment.target_component:
        return ()
    actual = resumed.data.get("resumed_activity")
    if confirmed.data.get("resumed_activity") != actual:
        return ()
    if not isinstance(actual, str) or "/" not in actual:
        return ()
    observed_package, name = actual.split("/", 1)
    actual = observed_package + "/" + (observed_package + name if name.startswith(".") else name)
    nodes = ui.data.get("nodes", ())
    def present(selector):
        return selector is not None and any(node.get("package") == package and node.get("text") == selector.text for node in nodes)
    protected, guarded = present(experiment.protected_screen), present(experiment.guard_screen)
    ids = tuple(o.observation_id for o in obs)
    def result(polarity):
        return PredicateResult("PRED-ACTIVITY-UI-" + polarity.value, "ACTIVITY_UI_SECURITY_EFFECT", polarity, True, True, ids, "Ordered external launch and independent application UI establish the contracted effect.")
    if protected and guarded:
        return (result(EvidencePolarity.SUPPORTS), result(EvidencePolarity.CONTRADICTS))
    if actual == experiment.target_component and protected:
        return (result(EvidencePolarity.SUPPORTS),)
    if actual == experiment.guard_component and guarded:
        return (result(EvidencePolarity.CONTRADICTS),)
    return ()


class ActivityValidationProvider:
    """Deterministic bounded strategy using ordinary policy-checked Stage Q actions."""
    provider_type = "ACTIVITY_UI_STRATEGY"

    def __init__(self, experiment):
        self.experiment = experiment
        self.actions = experiment.read_actions() + (
            ValidationToolAction("check_package_installed", {}, "Establish package presence."),
            ValidationToolAction("force_stop_package", {}, "Clear process-local authorization before the external attempt."),
            ValidationToolAction("launch_activity", {"component": experiment.target_component}, "Invoke the contracted external activity."),
            ValidationToolAction("inspect_resumed_activity", {}, "Independently observe the resumed activity."),
            ValidationToolAction("dump_ui_hierarchy", {}, "Independently observe the contracted screen effect."),
            ValidationToolAction("inspect_resumed_activity", {}, "Confirm the activity remained stable across the UI observation."),
        )

    def metadata_snapshot(self):
        return {"provider": "activity_ui_strategy", "oracle_version": ACTIVITY_ORACLE_VERSION, "activity_experiment": self.experiment.to_dict(), "activity_experiment_sha256": self.experiment.digest, "input_tokens": 0, "output_tokens": 0, "estimated_cost_usd": "0"}

    def next_action(self, context):
        if context.observations and (not context.observations[-1].success or context.observations[-1].truncated):
            return FinishValidationAction("Experiment evidence is incomplete.", "Stop without inferring a negative result.")
        completed = len(context.observations)
        if completed < len(self.actions):
            return self.actions[completed]
        if context.assessments:
            return FinishValidationAction("Bounded experiment completed.", "Submit evidence to the trusted evaluator.")
        predicates = activity_predicates(self.experiment, context.observations)
        if len(predicates) != 1:
            return ValidationAssessmentAction(ValidationAssessment.INSUFFICIENT_EVIDENCE, (), (), "No single decisive security effect.", "Retain evidential uncertainty.")
        supports = predicates[0].polarity is EvidencePolarity.SUPPORTS
        ui = context.observations[-2]
        selector = self.experiment.protected_screen if supports else self.experiment.guard_screen
        index = next(i for i, node in enumerate(ui.data["nodes"]) if node.get("package") == self.experiment.target_component.split("/")[0] and node.get("text") == selector.text)
        return ValidationAssessmentAction(
            ValidationAssessment.SUPPORTED if supports else ValidationAssessment.CONTRADICTED,
            tuple(o.observation_id for o in context.observations),
            (EvidenceAssertion(EvidenceAssertionKind.VALUE_EQUALS, ui.observation_id, f"nodes.{index}.text", selector.text),),
            "Observed the contracted application screen after the controlled launch.",
            "Advisory assessment; only the dedicated oracle can be decisive.",
        )
