package com.correx.core.toolintent.rules

import com.correx.core.events.events.ToolCallObservation
import com.correx.core.events.risk.RiskAction
import com.correx.core.toolintent.ToolCallAssessment
import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallRule
import com.correx.core.toolintent.maxAction
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity
import java.nio.file.FileSystems
import java.nio.file.Path

/**
 * Stage-scoped write manifest (role-reliability §2). When the active stage declares a
 * non-empty [ToolCallAssessmentInput.writeManifest] (workspace-relative globs), every
 * FILE_WRITE target is matched against it. A write to a path that resolves inside the
 * workspace but outside the declared set is scope creep — the dominant local-implementer
 * failure that tests do not catch — and is raised as PATH_OUTSIDE_MANIFEST → BLOCK.
 *
 * A write already inside the active task's affected_paths is allowed even if the manifest is
 * narrower: those paths are the agent-widenable, recorded authority (see [WriteScopeRule]), and
 * the stage manifest is a planner hint that defers to them. Empty manifest = unrestricted
 * (workspace containment still applies via [PathContainmentRule]). Out-of-workspace targets are
 * that rule's concern, not this one.
 * Path resolution goes through WorldProbe (symlink-safe) and the facts are recorded as
 * observations, so replay reads them back rather than re-globbing (invariant #9).
 */
class ManifestContainmentRule : ToolCallRule {

    override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
        ToolCapability.FILE_WRITE in capabilities

    override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
        if (input.writeManifest.isEmpty()) return ToolCallAssessment()

        val workspaceReal = input.probe.resolveReal(input.workspace.workspaceRoot)
        val matchers = input.writeManifest.map {
            FileSystems.getDefault().getPathMatcher("glob:$it")
        }
        // The active task's affected_paths are the agent-widenable, recorded authority (see
        // WriteScopeRule). A write already inside that scope is not manifest scope-creep — the
        // stage manifest is a narrower planner hint that must defer to it, otherwise a too-tight
        // manifest becomes an unrecoverable dead-end (the escape hatch is task_update affected_paths).
        val taskScope = input.session.activeTask?.scope.orEmpty()
        val taskMatchers = taskScope.map {
            FileSystems.getDefault().getPathMatcher("glob:${it.removePrefix("./")}")
        }

        val issues = mutableListOf<ValidationIssue>()
        val observations = mutableListOf<ToolCallObservation>()
        var disposition = RiskAction.PROCEED

        for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
            val candidate = Path.of(raw)
            val resolvedInput =
                if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate)
            val resolvedReal = input.probe.resolveReal(resolvedInput)
            val inWorkspace = resolvedReal.startsWith(workspaceReal)
            val relative = if (inWorkspace) workspaceReal.relativize(resolvedReal).toString() else raw
            val relPath = Path.of(relative)
            val inManifest = inWorkspace && matchers.any { it.matches(relPath) }
            val inTaskScope = inWorkspace && taskMatchers.any { it.matches(relPath) }

            observations += ToolCallObservation(
                ruleCode = RULE_CODE,
                facts = mapOf(
                    "path" to raw,
                    "relative" to relative,
                    "inWorkspace" to inWorkspace.toString(),
                    "inManifest" to inManifest.toString(),
                    "inTaskScope" to inTaskScope.toString(),
                ),
            )

            if (inWorkspace && !inManifest && !inTaskScope) {
                val remedy = if (taskScope.isEmpty()) {
                    " Claim a task whose affected_paths cover this path (task_update), then retry."
                } else {
                    val taskId = input.session.activeTask?.taskId
                    val widened = (taskScope + raw).joinToString(", ") { "\"$it\"" }
                    " If this path is genuinely part of the task, widen scope with this exact call — " +
                        "task_update(id=\"$taskId\", affected_paths=[$widened], note=\"why $raw is in " +
                        "scope\") — then retry the write. affected_paths REPLACES the old list, so keep " +
                        "the existing paths. Do NOT use action=block/unblock; that does not change scope."
                }
                issues += ValidationIssue(
                    code = "PATH_OUTSIDE_MANIFEST",
                    message = "Tool '${input.request.toolName}' writes '$raw' outside the stage's " +
                        "declared manifest ${input.writeManifest}.$remedy",
                    severity = ValidationSeverity.ERROR,
                )
                disposition = maxAction(disposition, RiskAction.BLOCK)
            }
        }

        return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition)
    }

    private companion object {
        const val RULE_CODE = "WRITE_MANIFEST"
    }
}
