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.
 *
 * 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")
        }

        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 inManifest = inWorkspace && matchers.any { it.matches(Path.of(relative)) }

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

            if (inWorkspace && !inManifest) {
                issues += ValidationIssue(
                    code = "PATH_OUTSIDE_MANIFEST",
                    message = "Tool '${input.request.toolName}' writes '$raw' outside the stage's " +
                        "declared manifest ${input.writeManifest}",
                    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"
    }
}
