"""Which final-report strings a translator may rewrite, and how to apply them.

The data.json is authored in English and is the SSOT for every downstream
consumer. The human HTML is the one artifact that renders in the reader's
language, so a translation arrives as a sidecar keyed by JSON Pointer and is
overlaid at render time. Nothing here mutates the SSOT.

Classification is by leaf key name, and the split is not "prose vs. term" —
the translator is told to leave a term in English when English reads better.
The line drawn here is **load-bearing vs. displayed**: a value the renderer
feeds into a CSS class, a label-table lookup, or an anchor id cannot change
without breaking the page, so it never reaches the translator. Everything a
reader merely reads is offered, and the translator decides.

When a key name carries load-bearing values in even one place, it counts as
structural everywhere. Missing a translation leaves English on the page;
translating an anchor id breaks the link.
"""
from __future__ import annotations

import re
from typing import Any, Iterator, Mapping, NamedTuple

# Values the renderer reads as text and nothing else.
PROSE_KEYS = frozenset({
    "acceptance",
    "addedWork",
    "alternativesConsidered",
    "answer",
    "approach",
    "approvalDisposition",
    "approvalEvidence",
    "behavior",
    "blastRadius",
    "blockReason",
    "blockedReason",
    "body",
    "briefEvidence",
    "carryIn",
    "causeSummary",
    "change",
    "check",
    "claim",
    "condition",
    "confirmingSignal",
    "conformanceExemption",
    "consequences",
    "constraint",
    "context",
    "coreReason",
    "decision",
    "declinedFixRecommendations",
    "description",
    "details",
    "directionChange",
    "disagreement",
    "discrepancy",
    "disproveWith",
    "environmentSetup",
    "escalationTrigger",
    "evidence",
    "evidenceRequired",
    "exemptionReaffirm",
    "exemptionReason",
    "exitContract",
    "exitContractSummary",
    "expected",
    "expectedBehaviorAfter",
    "expectedForm",
    "expectedOutcome",
    "expectedResult",
    "failureHandling",
    "finalConclusion",
    "headline",
    "howToStart",
    "hypothesis",
    "item",
    "justification",
    "label",
    "mitigation",
    "motivation",
    "name",
    "need",
    "nextStep",
    "noVariationRationale",
    "notApplicableReason",
    "note",
    "observableFailure",
    "observed",
    "option",
    "outcome",
    "outputSummary",
    "performed",
    "planItem",
    "position",
    "problem",
    "question",
    "rationale",
    "readyWhen",
    "reason",
    "rejectedSummary",
    "rejectingSignal",
    "requiredDecision",
    "requiredWork",
    "requirement",
    "resolution",
    "responsibility",
    "rolloutCost",
    "routingRecommendation",
    "selfFixNote",
    "stageTitle",
    "stageValidation",
    "statement",
    "suggestedAction",
    "summary",
    "supersededStatement",
    "symptom",
    "systemInterpretation",
    "testCaseBoundary",
    "testCaseFailure",
    "testCaseSuccess",
    "testCoverageCost",
    "text",
    "title",
    "topic",
    "trigger",
    "triggerSignal",
    "unblockCondition",
    "userChoice",
    "userInput",
    "userText",
    "verification",
    "verificationMethod",
    "verificationSignal",
    "workingAssumption",
})

# Values something other than the reader depends on: a CSS class, a label-table
# key, an anchor id, a path the reader clicks, a command they paste, or a quote
# that must stay verbatim to be evidence at all.
STRUCTURAL_KEYS = frozenset({
    "action",             # implementation.py: _FILE_ACTIONS lookup key
    "activityId",
    "activityIds",
    "adapter",
    "affectedId",
    "affectedTarget",
    "area",               # slug-shaped ('scan-coverage')
    "artifact",
    "baseBranch",
    "baseRefSha",
    "baseSha",
    "boundary",
    "branchName",
    "canonical",
    "capturedHeadSha",
    "carriedForwardFromSeq",
    "carryJson",
    "checkRefs",
    "clarificationId",
    "classification",
    "claudeCode",
    "collectorBranch",
    "command",
    "commandOrObservation",
    "commandOrTest",
    "commands",
    "commitListQuote",
    "complexity",
    "componentId",
    "confidence",
    "conformanceTests",
    "coveredBy",
    "createdAt",
    "cwd",
    "cycle",
    "dataStore",
    "date",
    "dependsOn",
    "diffSummaryQuote",
    "direction",
    "disposition",
    "dispatches",
    "executorWorktreePath",
    "evidenceRefs",
    "exitCode",
    "existingPrUrl",
    "externalSystem",
    "featureId",
    "field",
    "file",
    "files",
    "followUpPhase",
    "fromComponentId",
    "fromTaskKey",
    "fullSha",
    "gitDiffStat",
    "gitStatusShort",
    "h1c",
    "h2",
    "id",
    "impact",             # 'high' / 'medium' alongside prose
    "impactKind",
    "implementationBaseRef",
    "implementationOption",
    "independentValidationRerun",
    "injectedAs",
    "input",
    "interfaceKind",
    "interfaces",
    "itemId",
    "kind",               # feature_analysis.py: _FLOW_KINDS lookup key
    "leadModel",
    "leadingCauseId",
    "lens",               # enum_label("lens")
    "lines",
    "linkedWork",
    "match",
    "model",
    "newTaskId",
    "number",
    "okstraVersion",
    "output",
    "outputTail",
    "owner",
    "ownerComponentId",
    "path",
    "planFile",
    "planItemIds",
    "planStep",
    "prepItemId",
    "project",
    "projectId",
    "raw",
    "rawStat",
    "readBoundary",
    "readOnlyCommandLog",
    "recommendedDisposition",
    "relatedIds",
    "relation",
    "replacedInTest",
    "replacedWith",
    "reportPath",
    "resultPath",
    "requestKind",
    "requestPath",
    "requestVerbatim",    # the user's own words, quoted as evidence
    "requestedValue",
    "reversibility",
    "risk",               # tone-{{ row.risk }} class; an enum on ProjectHotspot
    "role",
    "rollbackCommand",
    "runManifest",
    "runSeq",
    "scope",              # 'PF-001' alongside prose
    "scopeImpact",        # clarification reach tokens the gate matches on
    "sections",
    "shortSha",
    "signature",
    "singleTicket",
    "skippedWorkers",
    "sliceValue",
    "slug",
    "source",             # enum_label("rejectionSource") and evidence ids
    "sourceCommit",
    "sourceFile",
    "sourceImpactId",
    "sourceSection",
    "status",             # CSS class + enum_label("coverage")
    "styleLintTypecheck",
    "subject",            # commit subject, must stay verbatim
    "symbol",
    "symptomVerbatim",
    "target",
    "targetReport",
    "taskGroup",
    "taskId",
    "taskKey",
    "taskType",
    "tddEvidence",
    "tddExemption",
    "terminal",
    "toComponentId",
    "toTaskKey",
    "url",
    "verdictTokenQuote",
    "verifier",
    "version",
    "worker",
    "workerRole",
    "worktreePath",
    "writeBoundary",
})


class UnclassifiedKeys(ValueError):
    """A schema string field belongs to neither set."""


# A key name says what a field is *for*; these say what one value actually is.
# `evidence` is the largest prose bucket in a real report and also holds bare
# `src/server.ts:33` citations — the key cannot separate them, the value can.
_MACHINE_CHARS = re.compile(r"^[A-Za-z0-9_.:@/\\#-]+$")
_LOWER_TOKEN = re.compile(r"^[a-z][a-z0-9]*([-_][a-z0-9]+)*$")
_FILE_EXTENSION = re.compile(r"\.[A-Za-z0-9]{1,6}$")
_LINE_NUMBER = re.compile(r":\d+$")


def is_translatable_value(text: str) -> bool:
    """Whether one value is prose rather than a citation or a token.

    Anything with whitespace is prose. A single run of ASCII path characters
    is not, once it carries a directory separator, a file extension, or a line
    number — and neither is a bare lowercase token, which is how an enum reads.
    """
    value = text.strip()
    if not value:
        return False
    if any(char.isspace() for char in value):
        return True
    if not _MACHINE_CHARS.match(value):
        return True
    if "/" in value or "\\" in value:
        return False
    if _FILE_EXTENSION.search(value) or _LINE_NUMBER.search(value):
        return False
    return _LOWER_TOKEN.match(value) is None


def escape_token(token: str) -> str:
    """Escape one JSON Pointer reference token (RFC 6901)."""
    return token.replace("~", "~0").replace("/", "~1")


def unescape_token(token: str) -> str:
    return token.replace("~1", "/").replace("~0", "~")


def _walk(node: Any, pointer: str, key: str) -> Iterator[tuple[str, str]]:
    if isinstance(node, Mapping):
        for child_key, value in node.items():
            yield from _walk(value, f"{pointer}/{escape_token(str(child_key))}", str(child_key))
    elif isinstance(node, (list, tuple)):
        for index, value in enumerate(node):
            # An array element inherits the key that named the array, so
            # `evidence: [...]` translates each entry.
            yield from _walk(value, f"{pointer}/{index}", key)
    elif isinstance(node, str) and key in PROSE_KEYS and is_translatable_value(node):
        yield pointer, node


def extract(data: Mapping[str, Any]) -> dict[str, str]:
    """Map every translatable pointer to the English text at it.

    The translator fills in this map rather than authoring pointers, so a
    sidecar cannot cite a path the document does not have.
    """
    return dict(_walk(data, "", ""))


class OverlayReport(NamedTuple):
    applied: int
    # Pointers the extractor offered that the sidecar left untranslated. They
    # render in English — a partial translation is a readable page, not a
    # failure — but a silent fallback is how a half-empty sidecar ships
    # unnoticed, so the count is reported.
    untranslated: tuple[str, ...]
    # Pointers the sidecar carries that do not resolve to a string here. A
    # sidecar written against a different report.
    unresolved: tuple[str, ...]


def _resolve_parent(data: Any, pointer: str) -> tuple[Any, str | int] | None:
    tokens = [unescape_token(t) for t in pointer.split("/")[1:]]
    if not tokens:
        return None
    node = data
    for token in tokens[:-1]:
        if isinstance(node, Mapping) and token in node:
            node = node[token]
        elif isinstance(node, list) and token.isdigit() and int(token) < len(node):
            node = node[int(token)]
        else:
            return None
    last = tokens[-1]
    if isinstance(node, Mapping) and isinstance(node.get(last), str):
        return node, last
    if isinstance(node, list) and last.isdigit() and int(last) < len(node):
        return (node, int(last)) if isinstance(node[int(last)], str) else None
    return None


def overlay(
    data: Mapping[str, Any], strings: Mapping[str, str]
) -> tuple[dict[str, Any], OverlayReport]:
    """Return a deep copy of ``data`` with the sidecar's strings substituted."""
    import copy

    out = copy.deepcopy(dict(data))
    applied = 0
    unresolved: list[str] = []
    for pointer, text in strings.items():
        target = _resolve_parent(out, pointer)
        if target is None or not isinstance(text, str) or not text:
            unresolved.append(pointer)
            continue
        parent, key = target
        parent[key] = text
        applied += 1
    untranslated = tuple(sorted(set(extract(data)) - set(strings)))
    return out, OverlayReport(applied, untranslated, tuple(sorted(unresolved)))


# Above this share of Hangul, the prose was authored in Korean rather than
# quoting some. Measured across real reports: Korean-authored ones sit at
# 35-38%, English ones that quote a Korean brief or a worker's Korean phrase
# reach 8% at most. The gap is wide enough that no threshold inside it is
# delicate. Only the strings `extract` offers are counted — a verbatim quote of
# the user's request is structural and never reaches this.
HANGUL_PROSE_LIMIT = 0.20


def hangul_share(data: Mapping[str, Any]) -> tuple[float, int]:
    """Return the Hangul share of the report's authored prose, and its length."""
    text = "".join(extract(data).values())
    if not text:
        return 0.0, 0
    hangul = sum(1 for char in text if "가" <= char <= "힣")
    return hangul / len(text), len(text)


def prose_is_english(data: Mapping[str, Any]) -> bool:
    """Whether the data.json was authored in English, as the SSOT contract requires.

    The report language names what the human HTML renders in, not what the
    worker writes. A worker that authors Korean anyway hands every later phase,
    validator and agent a record in a language they do not read.
    """
    share, _ = hangul_share(data)
    return share < HANGUL_PROSE_LIMIT


def _schema_string_keys(schema: Mapping[str, Any], *, enums: bool) -> set[str]:
    found: set[str] = set()

    def walk(node: Any) -> None:
        if not isinstance(node, Mapping):
            return
        if node.get("type") == "object":
            for key, child in (node.get("properties") or {}).items():
                if isinstance(child, Mapping) and child.get("type") == "string":
                    if ("enum" in child) == enums:
                        found.add(str(key))
                elif isinstance(child, Mapping) and enums and "enum" in child:
                    # An enum with no declared type is still an enum.
                    found.add(str(key))
                walk(child)
        elif node.get("type") == "array":
            walk(node.get("items") or {})
        for combinator in ("oneOf", "anyOf", "allOf"):
            for branch in node.get(combinator) or []:
                walk(branch)

    for definition in (schema.get("$defs") or {}).values():
        walk(definition)
    walk(schema)
    return found


def schema_string_keys(schema: Mapping[str, Any]) -> set[str]:
    """Every leaf key in the schema whose value is a free-form string.

    The drift guard compares this against the two sets above, so adding a
    report field forces a decision about whether a translator may touch it.
    """
    return _schema_string_keys(schema, enums=False)


def schema_enum_keys(schema: Mapping[str, Any]) -> set[str]:
    """Every leaf key the schema constrains to an enum, anywhere.

    A name can be an enum in one definition and free prose in another —
    `risk` is `high|medium|low` on a hotspot and a sentence on a risk row.
    The renderer reaches for the same name either way (`tone-{{ row.risk }}`),
    so one enum occurrence makes the name structural everywhere.
    """
    return _schema_string_keys(schema, enums=True)
