#!/usr/bin/env python3
"""Bounded, offline structural evidence check. MIT; no semantic validation."""

import hashlib
import json
import os
import re
import stat
import sys

VERSION = "1.0.0"
MAX_BYTES = 1_048_576  # Per input, before decoding/parsing.
MAX_DEPTH = 5
MAX_REVIEWS = 1000
MAX_TOPICS = 100
MAX_EVIDENCE = 1000  # Per topic.
MAX_TOTAL_EVIDENCE = 5000
MAX_ID_CHARS = 128
MAX_TEXT_CHARS = 4096
MAX_QUOTE_CHARS = 1024
MAX_SEARCH_CHARS = 10_000_000  # Sum of text + quote lengths per known-ID reference.
MAX_PATH_CHARS = 4096
MAX_PATH_PARTS = 64
NOT_CHECKED = [
    "meaning_and_polarity", "topic_classification", "coverage_of_all_complaints",
    "representativeness", "truth_of_customer_statements", "real_service_or_model_quality",
]


class InvalidInput(Exception):
    """Only fixed, program-owned codes may cross the CLI boundary."""

    def __init__(self, code):
        self.code = code
        super().__init__(code)


def fail(code):
    raise InvalidInput(code)


def file_stamp(info):
    return (info.st_dev, info.st_ino, info.st_mode, info.st_size,
            info.st_mtime_ns, info.st_ctime_ns)


def read_regular(path):
    """Walk without following symlinks, reject special files, then read bounded bytes.

    Each directory is held open while opening its child. A replaced path cannot
    redirect a previously opened descriptor. O_NONBLOCK prevents a raced FIFO
    from blocking; pre/post fstat detects ordinary concurrent modification.
    """
    if not isinstance(path, str) or not path or len(path) > MAX_PATH_CHARS or "\x00" in path:
        fail("path_invalid")
    parts = [part for part in path.split("/") if part not in ("", ".")]
    if not parts or len(parts) > MAX_PATH_PARTS or path.endswith("/"):
        fail("path_invalid")
    # This implementation is supported/tested only on Python 3.14/macOS.
    base_flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
    folder = os.open("/" if path.startswith("/") else ".", base_flags | os.O_DIRECTORY)
    try:
        for part in parts[:-1]:
            child = os.open(part, base_flags | os.O_DIRECTORY, dir_fd=folder)
            os.close(folder)
            folder = child
        before = os.stat(parts[-1], dir_fd=folder, follow_symlinks=False)
        if not stat.S_ISREG(before.st_mode):
            fail("regular_file_required")
        if before.st_size > MAX_BYTES:
            fail("input_limit")
        fd = os.open(parts[-1], base_flags, dir_fd=folder)
        try:
            opened = os.fstat(fd)
            if not stat.S_ISREG(opened.st_mode):
                fail("regular_file_required")
            if opened.st_size > MAX_BYTES:
                fail("input_limit")
            if file_stamp(before) != file_stamp(opened):
                fail("input_changed")
            chunks = []
            total = 0
            while total <= MAX_BYTES:
                chunk = os.read(fd, min(65_536, MAX_BYTES + 1 - total))
                if not chunk:
                    break
                chunks.append(chunk)
                total += len(chunk)
            if total > MAX_BYTES:
                fail("input_limit")
            if file_stamp(opened) != file_stamp(os.fstat(fd)) or total != opened.st_size:
                fail("input_changed")
            return b"".join(chunks)
        finally:
            os.close(fd)
    finally:
        os.close(folder)


def check_depth(text):
    """Bound containers *before* json.loads; braces inside strings do not count."""
    depth = 0
    quoted = False
    escaped = False
    for char in text:
        if quoted:
            if escaped:
                escaped = False
            elif char == "\\":
                escaped = True
            elif char == '"':
                quoted = False
        elif char == '"':
            quoted = True
        elif char in "[{":
            depth += 1
            if depth > MAX_DEPTH:
                fail("nesting_limit")
        elif char in "]}":
            depth -= 1
    # Syntax errors, unbalanced containers/strings are handled by json.loads.


def unique_pairs(items):
    obj = {}
    for key, value in items:
        if key in obj:
            fail("duplicate_json_key")
        obj[key] = value
    return obj


def parse_integer(value):
    if len(value.lstrip("-")) > 4:
        fail("number_limit")
    return int(value)


def reject_float(_value):
    fail("integer_required")


def reject_constant(_value):
    fail("non_finite_number")


def decode(raw):
    if raw.startswith(b"\xef\xbb\xbf"):
        fail("utf8_without_bom_required")
    text = raw.decode("utf-8", errors="strict")
    check_depth(text)
    return json.loads(text, object_pairs_hook=unique_pairs, parse_int=parse_integer,
                      parse_float=reject_float, parse_constant=reject_constant)


def fields(value, expected):
    if type(value) is not dict or set(value) != set(expected):
        fail("fields_mismatch")


def nonempty_string(value, maximum):
    if (type(value) is not str or not value.strip() or len(value) > maximum
            or any(0xD800 <= ord(char) <= 0xDFFF for char in value)):
        fail("string_invalid")


def bounded_array(value, maximum, minimum=0):
    if type(value) is not list or not minimum <= len(value) <= maximum:
        fail("array_limit")


def count_integer(value):
    if type(value) is not int or not 0 <= value <= MAX_REVIEWS:
        fail("count_invalid")


def validate(source, summary, source_sha):
    """Validate the complete profile before doing any substring searches."""
    fields(source, ("scope_id", "reviews"))
    nonempty_string(source["scope_id"], MAX_ID_CHARS)
    bounded_array(source["reviews"], MAX_REVIEWS, minimum=1)
    lookup = {}
    for row in source["reviews"]:
        fields(row, ("id", "text"))
        nonempty_string(row["id"], MAX_ID_CHARS)
        nonempty_string(row["text"], MAX_TEXT_CHARS)
        if row["id"] in lookup:
            fail("duplicate_source_id")
        lookup[row["id"]] = row["text"]

    fields(summary, ("scope_id", "source_sha256", "topics"))
    nonempty_string(summary["scope_id"], MAX_ID_CHARS)
    digest = summary["source_sha256"]
    if type(digest) is not str or re.fullmatch(r"[0-9a-f]{64}", digest) is None:
        fail("digest_invalid")
    if summary["scope_id"] != source["scope_id"]:
        fail("scope_mismatch")
    if digest != source_sha:
        fail("snapshot_mismatch")
    bounded_array(summary["topics"], MAX_TOPICS, minimum=1)
    topic_ids = set()
    total_evidence = 0
    search_chars = 0
    for topic in summary["topics"]:
        fields(topic, ("id", "count", "denominator", "evidence"))
        nonempty_string(topic["id"], MAX_ID_CHARS)
        if topic["id"] in topic_ids:
            fail("duplicate_topic_id")
        topic_ids.add(topic["id"])
        count_integer(topic["count"])
        count_integer(topic["denominator"])
        bounded_array(topic["evidence"], MAX_EVIDENCE)
        total_evidence += len(topic["evidence"])
        if total_evidence > MAX_TOTAL_EVIDENCE:
            fail("total_evidence_limit")
        for evidence in topic["evidence"]:
            fields(evidence, ("review_id", "quote"))
            nonempty_string(evidence["review_id"], MAX_ID_CHARS)
            nonempty_string(evidence["quote"], MAX_QUOTE_CHARS)
            text = lookup.get(evidence["review_id"])
            if text is not None:
                search_chars += len(text) + len(evidence["quote"])
                if search_chars > MAX_SEARCH_CHARS:
                    fail("search_budget_limit")
    return lookup


def verify(source, summary, source_sha):
    lookup = validate(source, summary, source_sha)
    signals = []
    checked = []
    for topic_index, topic in enumerate(summary["topics"]):
        seen = set()
        valid = set()
        for evidence_index, evidence in enumerate(topic["evidence"]):
            rid = evidence["review_id"]
            place = {"topic_index": topic_index, "evidence_index": evidence_index}
            if rid in seen:
                signals.append({**place, "code": "duplicate_evidence_id"})
            seen.add(rid)
            if rid not in lookup:
                signals.append({**place, "code": "unknown_review_id"})
            elif evidence["quote"] not in lookup[rid]:
                signals.append({**place, "code": "quote_not_verbatim"})
            else:
                valid.add(rid)
        if topic["count"] != len(valid):
            signals.append({"topic_index": topic_index, "code": "count_mismatch"})
        if topic["denominator"] != len(lookup):
            signals.append({"topic_index": topic_index, "code": "denominator_mismatch"})
        checked.append({"topic_index": topic_index, "valid_evidence_count": len(valid),
                        "source_texts": len(lookup)})
    return {"status": "signals" if signals else "structural_evidence_clear",
            "publication_allowed": False, "signals": signals, "topics": checked,
            "not_checked": NOT_CHECKED.copy(), "version": VERSION}


def cli(args):
    if args == ["--help"]:
        return {"status": "help", "usage": "python3 scripts/check.py REVIEWS.json SUMMARY.json",
                "version": VERSION, "publication_allowed": False}, 0
    if args == ["--version"]:
        return {"status": "version", "version": VERSION, "publication_allowed": False}, 0
    if len(args) != 2 or any(arg.startswith("--") for arg in args):
        fail("two_paths_required")
    raw = read_regular(args[0])
    source = decode(raw)
    summary = decode(read_regular(args[1]))
    result = verify(source, summary, hashlib.sha256(raw).hexdigest())
    return result, 1 if result["signals"] else 0


def main(args=None):
    try:
        result, code = cli(sys.argv[1:] if args is None else args)
    except InvalidInput as error:
        result, code = {"status": "invalid_input", "publication_allowed": False,
                        "error": {"code": error.code}, "version": VERSION}, 2
    except UnicodeError:
        result, code = invalid("utf8_required"), 2
    except json.JSONDecodeError:
        result, code = invalid("json_invalid"), 2
    except OSError:
        result, code = invalid("file_unavailable"), 2
    except KeyboardInterrupt:
        result, code = invalid("interrupted"), 2
    except Exception:
        # Never interpolate exception messages, input values or paths.
        result, code = invalid("internal_error"), 2
    payload = memoryview((json.dumps(result, ensure_ascii=True, sort_keys=True) + "\n").encode("ascii"))
    try:
        while payload:
            written = os.write(1, payload)
            if not written:
                return 2
            payload = payload[written:]
    except (OSError, KeyboardInterrupt):
        return 2
    return code


def invalid(code):
    return {"status": "invalid_input", "publication_allowed": False,
            "error": {"code": code}, "version": VERSION}


if __name__ == "__main__":
    sys.exit(main())
