// Public type contracts for the governed workflow-handoff surface (Epic #177, Issue #186).
// All string content here is already redacted upstream; nothing performs IO, crypto, clock
// reads, or randomness. Pure data + pure validators only. The schemaVersion discriminant
// follows the same evolution rule as CONNECTED_CONTEXT_SCHEMA_VERSION (ADR-0010 D2): a
// breaking change introduces a NEW literal member rather than mutating "1". Leaf-package
// rule (ADR-0019 direction 1) means no `@oscharko-dev/keiko-*` imports may appear in this
// module — including this package's own siblings; intra-package imports use relative paths.
import { isValidScopePath } from "./connected-context.js";
// ─── Schema version ───────────────────────────────────────────────────────────
export const WORKFLOW_HANDOFF_SCHEMA_VERSION = "1";
export const DEFAULT_PATCH_SCOPE_LIMITS = {
    maxFileCount: 16,
    maxPatchBytes: 65_536,
    maxNewFiles: 4,
    elapsedMsMax: 60_000,
};
export const EXPECTED_CHECKS = [
    "verify",
    "lint",
    "typecheck",
    "tests",
    "manual",
];
export const WORKFLOW_KINDS = [
    "unit-test-generation",
    "bug-investigation",
    "verification",
];
// ─── Internal helpers ─────────────────────────────────────────────────────────
const APPROVAL_TOKEN_RE = /^[0-9a-f]{64}$/;
const CONTEXT_PACK_ID_RE = /^(pl-[0-9a-f]{16}|p-[0-9a-f]{64})$/;
// The schema discriminant comparisons below are statically true at the type level (the
// constant equals the literal field type), so we widen to `string` before comparing. This
// keeps the validator honest against runtime inputs that bypass the type system — e.g.,
// objects materialized from JSON.parse or cross-version manifests. Same posture as
// connected-context.ts schemaMismatch.
function schemaMismatch(actual, expected) {
    return actual !== expected;
}
function isFiniteNonNegativeInteger(value) {
    return typeof value === "number" && Number.isInteger(value) && value >= 0;
}
function isNonEmptyTrimmed(value) {
    return value.trim().length > 0;
}
function hasDuplicates(values) {
    return new Set(values).size !== values.length;
}
function hasIntersection(a, b) {
    if (a.length === 0 || b.length === 0) {
        return false;
    }
    const set = new Set(a);
    for (const value of b) {
        if (set.has(value)) {
            return true;
        }
    }
    return false;
}
function pushIf(reasons, condition, reason) {
    if (condition) {
        reasons.push(reason);
    }
}
function buildResult(reasons) {
    return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
}
function isRecord(value) {
    return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readArrayField(source, field, reasons) {
    const value = source[field];
    if (!Array.isArray(value)) {
        reasons.push(`patchScope.${field} must be an array`);
        return undefined;
    }
    return value;
}
function isExpectedCheck(value) {
    return typeof value === "string" && EXPECTED_CHECKS.includes(value);
}
function isWorkflowKind(value) {
    return typeof value === "string" && WORKFLOW_KINDS.includes(value);
}
// ─── Approval-token shape ─────────────────────────────────────────────────────
export function isApprovalTokenShape(token) {
    return APPROVAL_TOKEN_RE.test(token);
}
// ─── PatchScope validation ────────────────────────────────────────────────────
function validateScopePathArray(values, field, reasons) {
    const parsed = [];
    for (const value of values) {
        if (typeof value !== "string" || !isNonEmptyTrimmed(value)) {
            reasons.push(`patchScope.${field} contains empty entry`);
            return undefined;
        }
        if (!isValidScopePath(value, { mustBeRelative: true })) {
            reasons.push(`patchScope.${field} contains invalid path`);
            return undefined;
        }
        parsed.push(value);
    }
    return parsed;
}
function validatePatchScopeLimits(limits, reasons) {
    if (!isRecord(limits)) {
        reasons.push("patchScope.limits must be an object");
        return;
    }
    pushIf(reasons, !isFiniteNonNegativeInteger(limits.maxFileCount), "patchScope.limits.maxFileCount invalid");
    pushIf(reasons, !isFiniteNonNegativeInteger(limits.maxPatchBytes), "patchScope.limits.maxPatchBytes invalid");
    pushIf(reasons, !isFiniteNonNegativeInteger(limits.maxNewFiles), "patchScope.limits.maxNewFiles invalid");
    pushIf(reasons, !isFiniteNonNegativeInteger(limits.elapsedMsMax), "patchScope.limits.elapsedMsMax invalid");
}
function validateExpectedChecks(checks, reasons) {
    if (checks.length === 0) {
        reasons.push("patchScope.expectedChecks empty");
        return;
    }
    for (const check of checks) {
        if (!isExpectedCheck(check)) {
            reasons.push("patchScope.expectedChecks contains invalid value");
            return;
        }
    }
}
function validateEvidenceAtomIds(ids, reasons) {
    if (ids.length === 0) {
        reasons.push("patchScope.evidenceAtomIds empty");
        return undefined;
    }
    const parsed = [];
    for (const id of ids) {
        if (typeof id !== "string" || !isNonEmptyTrimmed(id)) {
            reasons.push("patchScope.evidenceAtomIds contains empty entry");
            return undefined;
        }
        parsed.push(id);
    }
    if (hasDuplicates(parsed)) {
        reasons.push("patchScope.evidenceAtomIds contains duplicates");
    }
    return parsed;
}
function validateUnknowns(unknowns, reasons) {
    for (const entry of unknowns) {
        if (typeof entry !== "string") {
            reasons.push("patchScope.unknowns contains non-string entry");
            return;
        }
        if (!isNonEmptyTrimmed(entry)) {
            reasons.push("patchScope.unknowns contains empty entry");
            return;
        }
    }
}
function readPatchScopeArrays(scope, reasons) {
    return {
        editablePaths: readArrayField(scope, "editablePaths", reasons),
        readOnlyPaths: readArrayField(scope, "readOnlyPaths", reasons),
        evidenceAtomIds: readArrayField(scope, "evidenceAtomIds", reasons),
        expectedChecks: readArrayField(scope, "expectedChecks", reasons),
        unknowns: readArrayField(scope, "unknowns", reasons),
    };
}
function validatePatchScopePaths(arrays, reasons) {
    const editablePaths = arrays.editablePaths
        ? validateScopePathArray(arrays.editablePaths, "editablePaths", reasons)
        : undefined;
    const readOnlyPaths = arrays.readOnlyPaths
        ? validateScopePathArray(arrays.readOnlyPaths, "readOnlyPaths", reasons)
        : undefined;
    if (editablePaths) {
        pushIf(reasons, hasDuplicates(editablePaths), "patchScope.editablePaths contains duplicates");
    }
    if (readOnlyPaths) {
        pushIf(reasons, hasDuplicates(readOnlyPaths), "patchScope.readOnlyPaths contains duplicates");
    }
    if (editablePaths && readOnlyPaths) {
        pushIf(reasons, hasIntersection(editablePaths, readOnlyPaths), "patchScope.editablePaths overlaps readOnlyPaths");
    }
}
function validatePatchScopeArrays(arrays, reasons) {
    validatePatchScopePaths(arrays, reasons);
    if (arrays.evidenceAtomIds) {
        validateEvidenceAtomIds(arrays.evidenceAtomIds, reasons);
    }
    if (arrays.expectedChecks) {
        validateExpectedChecks(arrays.expectedChecks, reasons);
    }
    if (arrays.unknowns) {
        validateUnknowns(arrays.unknowns, reasons);
    }
}
export function validatePatchScope(scope) {
    const reasons = [];
    if (!isRecord(scope)) {
        return { ok: false, reasons: ["patchScope must be an object"] };
    }
    pushIf(reasons, schemaMismatch(scope.schemaVersion, WORKFLOW_HANDOFF_SCHEMA_VERSION), "patchScope.schemaVersion mismatch");
    validatePatchScopeArrays(readPatchScopeArrays(scope, reasons), reasons);
    validatePatchScopeLimits(scope.limits, reasons);
    return buildResult(reasons);
}
// ─── WorkflowHandoffRequest validation ────────────────────────────────────────
export function validateWorkflowHandoffRequest(request) {
    const reasons = [];
    if (!isRecord(request)) {
        return { ok: false, reasons: ["request must be an object"] };
    }
    pushIf(reasons, schemaMismatch(request.schemaVersion, WORKFLOW_HANDOFF_SCHEMA_VERSION), "request.schemaVersion mismatch");
    if (typeof request.contextPackStableId !== "string" ||
        !isNonEmptyTrimmed(request.contextPackStableId)) {
        reasons.push("request.contextPackStableId empty");
    }
    else if (!CONTEXT_PACK_ID_RE.test(request.contextPackStableId)) {
        reasons.push("request.contextPackStableId malformed");
    }
    pushIf(reasons, !isWorkflowKind(request.workflowKind), "request.workflowKind invalid");
    pushIf(reasons, !isFiniteNonNegativeInteger(request.requestedAtMs), "request.requestedAtMs invalid");
    pushIf(reasons, typeof request.userApprovalToken !== "string" ||
        !isApprovalTokenShape(request.userApprovalToken), "request.userApprovalToken malformed");
    const scopeResult = validatePatchScope(request.patchScope);
    if (!scopeResult.ok) {
        for (const reason of scopeResult.reasons) {
            reasons.push(`request.${reason}`);
        }
    }
    return buildResult(reasons);
}
// ─── checkPatchAgainstScope ───────────────────────────────────────────────────
function violationOutsideSet(path) {
    return {
        kind: "outside-editable-set",
        path,
        observed: undefined,
        limit: undefined,
        message: `path "${path}" is not in patchScope.editablePaths`,
    };
}
function violationBound(kind, observed, limit, field) {
    return {
        kind,
        path: undefined,
        observed,
        limit,
        message: `${field}: observed ${observed.toString()} exceeds limit ${limit.toString()}`,
    };
}
function violationNoChecks() {
    return {
        kind: "no-expected-checks",
        path: undefined,
        observed: undefined,
        limit: undefined,
        message: "patchScope.expectedChecks is empty",
    };
}
function violationInvalidScope(reason) {
    return {
        kind: "invalid-patch-scope",
        path: undefined,
        observed: undefined,
        limit: undefined,
        message: `patchScope invalid: ${reason}`,
    };
}
function violationInvalidEntry(path, reason) {
    return {
        kind: "invalid-patch-entry",
        path,
        observed: undefined,
        limit: undefined,
        message: path === undefined ? `patch entry invalid: ${reason}` : `path "${path}" invalid: ${reason}`,
    };
}
function readProposedPath(entry, violations) {
    const path = typeof entry.path === "string" ? entry.path : undefined;
    const valid = path !== undefined &&
        isNonEmptyTrimmed(path) &&
        isValidScopePath(path, { mustBeRelative: true });
    if (!valid) {
        violations.push(violationInvalidEntry(path, "path must be a valid relative scope path"));
        return undefined;
    }
    return path;
}
function readProposedPatchBytes(entry, path, violations) {
    const patchBytes = entry.patchBytes;
    if (typeof patchBytes !== "number" || !Number.isFinite(patchBytes) || patchBytes < 0) {
        violations.push(violationInvalidEntry(path, "patchBytes must be a finite non-negative number"));
        return undefined;
    }
    return patchBytes;
}
function readProposedNewFile(entry, path, violations) {
    const newFile = entry.newFile;
    if (typeof newFile !== "boolean") {
        violations.push(violationInvalidEntry(path, "newFile must be a boolean"));
        return undefined;
    }
    return newFile;
}
function normalizeProposedEntry(entry, violations) {
    if (!isRecord(entry)) {
        violations.push(violationInvalidEntry(undefined, "entry must be an object"));
        return undefined;
    }
    const path = readProposedPath(entry, violations);
    const patchBytes = readProposedPatchBytes(entry, path, violations);
    const newFile = readProposedNewFile(entry, path, violations);
    if (path === undefined || patchBytes === undefined || newFile === undefined) {
        return undefined;
    }
    return { path, patchBytes, newFile };
}
function collectProposedEntries(proposed, violations) {
    const valid = [];
    for (const entry of proposed) {
        const normalized = normalizeProposedEntry(entry, violations);
        if (normalized) {
            valid.push(normalized);
        }
    }
    return valid;
}
function collectOutsideSet(scope, proposed, violations) {
    const editable = new Set(scope.editablePaths);
    for (const entry of proposed) {
        if (!editable.has(entry.path)) {
            violations.push(violationOutsideSet(entry.path));
        }
    }
}
function collectAggregateBounds(scope, proposed, violations) {
    if (proposed.length > scope.limits.maxFileCount) {
        violations.push(violationBound("exceeds-max-file-count", proposed.length, scope.limits.maxFileCount, "fileCount"));
    }
    let totalBytes = 0;
    let newFiles = 0;
    for (const entry of proposed) {
        totalBytes += entry.patchBytes;
        if (entry.newFile) {
            newFiles += 1;
        }
    }
    if (totalBytes > scope.limits.maxPatchBytes) {
        violations.push(violationBound("exceeds-max-patch-bytes", totalBytes, scope.limits.maxPatchBytes, "patchBytes"));
    }
    if (newFiles > scope.limits.maxNewFiles) {
        violations.push(violationBound("exceeds-max-new-files", newFiles, scope.limits.maxNewFiles, "newFiles"));
    }
}
export function checkPatchAgainstScope(scope, proposed) {
    const violations = [];
    const scopeResult = validatePatchScope(scope);
    let hasBlockingScopeViolation = false;
    if (!scopeResult.ok) {
        for (const reason of scopeResult.reasons) {
            if (reason === "patchScope.expectedChecks empty") {
                violations.push(violationNoChecks());
            }
            else {
                hasBlockingScopeViolation = true;
                violations.push(violationInvalidScope(reason));
            }
        }
    }
    if (hasBlockingScopeViolation) {
        return { ok: false, violations };
    }
    if (!Array.isArray(proposed)) {
        violations.push(violationInvalidEntry(undefined, "proposed patch list must be an array"));
        return { ok: false, violations };
    }
    const validProposed = collectProposedEntries(proposed, violations);
    collectOutsideSet(scope, validProposed, violations);
    collectAggregateBounds(scope, validProposed, violations);
    return violations.length === 0 ? { ok: true } : { ok: false, violations };
}
