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

/**
 * Write-scope adherence. When the session has claimed a task that declared affected_paths, an
 * in-workspace write outside that scope is BLOCKED — but **declarably**: the message tells the
 * agent that if the change is genuinely needed (a dependency, a caller the plan didn't foresee) it
 * should widen the task's affected_paths via task_update (which re-records the scope), then retry.
 * So legitimate work is never trapped — only forced to be explicit, turning silent scope-creep into
 * a recorded, auditable scope change. No active task or no declared scope ⇒ nothing to enforce;
 * out-of-workspace targets are PathContainmentRule's concern.
 */
class WriteScopeRule : ToolCallRule {

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

    override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
        val active = input.session.activeTask
        if (active == null || active.scope.isEmpty()) return ToolCallAssessment()

        val workspaceReal = input.probe.resolveReal(input.workspace.workspaceRoot)
        val matchers = active.scope.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 resolvedReal = input.probe.resolveReal(
                if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate),
            )
            if (!resolvedReal.startsWith(workspaceReal)) continue // out of workspace: not this gate
            val rel = workspaceReal.relativize(resolvedReal)
            val inScope = matchers.any { it.matches(rel) }

            observations += ToolCallObservation(
                ruleCode = RULE_CODE,
                facts = mapOf("path" to raw, "inScope" to inScope.toString(), "task" to active.taskId),
            )

            if (!inScope) {
                val widened = (active.scope + raw).joinToString(", ") { "\"$it\"" }
                issues += ValidationIssue(
                    code = RULE_CODE,
                    message = "Tool '${input.request.toolName}' writes '$raw', outside claimed task " +
                        "${active.taskId}'s affected_paths (${active.scope}). If this change is needed, " +
                        "widen the scope with this exact call — task_update(id=\"${active.taskId}\", " +
                        "affected_paths=[$widened], note=\"why $raw is in scope\") — then retry the write. " +
                        "The affected_paths list REPLACES the old one, so include the existing paths shown " +
                        "above. Do NOT use action=block/unblock; that does not change scope.",
                    severity = ValidationSeverity.ERROR,
                )
                disposition = maxAction(disposition, RiskAction.BLOCK)
            }
        }

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

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