#!/usr/bin/env python3
"""Cell Pin: exact numeric transcription checks, not a fact checker. Python 3.10+."""
import argparse
import csv
import hashlib
import io
import json
from pathlib import Path
import re
import sys

VERSION = '0.3'
LIMIT = 2 * 1024 * 1024
# Deliberately lexical: dates may be split; word-numbers/identifiers are not found.
# A terminal full stop is punctuation, but a dot followed by a word/digit
# must not allow a partial decimal or dotted identifier to become a candidate.
NUMBER = re.compile(r'(?<![\w.])[+−-]?(?:[0-9]{1,3}(?:,[0-9]{3})+|[0-9]+)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?(?!\w|\.\w)')


class InputError(ValueError):
    pass


def require(condition, message):
    if not condition:
        raise InputError(message)


def read_local(base, name):
    require(isinstance(name, str) and name, 'Path must be a nonempty string')
    path = Path(name)
    require(not path.is_absolute(), 'Paths must be relative to the pins file')
    path = (base / path).resolve()
    require(path.is_relative_to(base.resolve()), 'Path escapes the pins directory')
    require(path.is_file(), f'Not a file: {name}')
    require(path.stat().st_size <= LIMIT, f'File exceeds {LIMIT} bytes: {name}')
    with path.open('rb') as f:
        data = f.read(LIMIT + 1)
    require(len(data) <= LIMIT, f'File exceeds {LIMIT} bytes: {name}')
    return data


def text_bytes(data):
    try:
        return data.decode('utf-8-sig')
    except UnicodeDecodeError as e:
        raise InputError('Files must be UTF-8') from e


def parse_csv(data, skip_records=0):
    require(type(skip_records) is int and 0 <= skip_records <= 100,
            'skip_records must be an integer from 0 to 100')
    try:
        rows = list(csv.reader(io.StringIO(text_bytes(data), newline=''), strict=True))
    except csv.Error as e:
        raise InputError(f'Malformed CSV: {e}') from e
    rows = rows[skip_records:]
    require(bool(rows), 'Empty CSV after selected preamble')
    headers = rows[0]
    require(bool(headers) and all(headers), 'CSV headers must be nonempty')
    require(len(set(headers)) == len(headers), 'Duplicate CSV headers')
    require(all(len(r) == len(headers) for r in rows[1:]), 'CSV rows have inconsistent widths')
    return headers, [dict(zip(headers, r)) for r in rows[1:]]


def occurrences(text, needle):
    at = text.find(needle)
    while at >= 0:
        yield at
        at = text.find(needle, at + 1)


def unique_object(pairs):
    obj = {}
    for key, value in pairs:
        require(key not in obj, f'Duplicate JSON key: {key}')
        obj[key] = value
    return obj


def load_spec(path):
    path = Path(path).resolve()
    require(path.is_file(), 'Pins file not found')
    data = read_local(path.parent, path.name)
    try:
        spec = json.loads(text_bytes(data), object_pairs_hook=unique_object)
    except json.JSONDecodeError as e:
        raise InputError(f'Malformed JSON: {e.msg}') from e
    return spec, path.parent


def check(spec, base):
    require(isinstance(spec, dict) and type(spec.get('version')) is int and spec['version'] == 1, 'Expected pins schema version 1')
    require(set(spec) == {'version', 'document', 'sources', 'pins'}, 'Unexpected or missing top-level field')
    require(isinstance(spec['sources'], dict) and bool(spec['sources']), 'sources must be a nonempty object')
    require(isinstance(spec['pins'], list) and 0 < len(spec['pins']) <= 500, 'pins must be a list of 1–500 entries')
    document_data = read_local(base, spec['document'])
    document = text_bytes(document_data)
    candidates = list(NUMBER.finditer(document))
    candidate_spans = {(m.start(), m.end()) for m in candidates}
    sources = {}
    for name, source in spec['sources'].items():
        require(isinstance(source, dict) and {'path', 'sha256'} <= set(source) <= {'path', 'sha256', 'skip_records'}, f'Invalid source: {name}')
        require(isinstance(source['sha256'], str) and re.fullmatch('[0-9a-f]{64}', source['sha256']), 'Expected lowercase SHA-256 hex')
        data = read_local(base, source['path'])
        digest = hashlib.sha256(data).hexdigest()
        skip_records = source.get('skip_records', 0)
        headers, rows = parse_csv(data, skip_records)
        sources[name] = dict(path=source['path'], expected_sha256=source['sha256'], actual_sha256=digest,
                             unchanged=digest == source['sha256'], skip_records=skip_records, headers=headers, rows=rows)
    results, ids, seen_spans = [], set(), {}
    for pin in spec['pins']:
        require(isinstance(pin, dict) and set(pin) == {'id', 'quote', 'value', 'source', 'where', 'column'}, 'Unexpected or missing pin field')
        require(all(isinstance(pin[k], str) and pin[k] for k in ['id', 'quote', 'value', 'source', 'column']), 'Pin strings must be nonempty')
        require(pin['id'] not in ids, f'Duplicate pin id: {pin["id"]}')
        ids.add(pin['id'])
        require(isinstance(pin['where'], dict) and bool(pin['where']) and all(isinstance(v, str) for v in pin['where'].values()), 'where must be a nonempty object of exact string selectors')
        require(NUMBER.fullmatch(pin['value']) is not None, 'value must be one digit-based numeric token')
        result = {**pin, 'status': 'unresolved', 'issues': []}
        results.append(result)
        spans = list(occurrences(document, pin['quote']))
        tokens = [m for m in NUMBER.finditer(pin['quote']) if m.group() == pin['value']]
        if len(spans) != 1:
            result['issues'].append('quote_missing' if not spans else 'quote_ambiguous')
        if len(tokens) != 1:
            result['issues'].append('value_missing_in_quote' if not tokens else 'value_ambiguous_in_quote')
        if len(spans) == len(tokens) == 1:
            span = (spans[0] + tokens[0].start(), spans[0] + tokens[0].end())
            result['span'] = list(span)
            if span not in candidate_spans:
                result['issues'].append('value_not_whole_document_token')
            if span in seen_spans:
                result['issues'].append('overlapping_pin')
                other = seen_spans[span]
                if 'overlapping_pin' not in other['issues']:
                    other['issues'].append('overlapping_pin')
                other['status'] = 'unresolved'
            else:
                seen_spans[span] = result
        source = sources.get(pin['source'])
        if source is None:
            result['issues'].append('unknown_source')
        else:
            result['source_sha256'] = source['actual_sha256']
            if not source['unchanged']:
                result['issues'].append('source_changed')
            if pin['column'] not in source['headers'] or not set(pin['where']).issubset(source['headers']):
                result['issues'].append('unknown_column')
            else:
                matches = [r for r in source['rows'] if all(r[k] == v for k, v in pin['where'].items())]
                if len(matches) != 1:
                    result['issues'].append('row_missing' if not matches else 'row_ambiguous')
                else:
                    result['cell'] = matches[0][pin['column']]
                    if result['cell'] != pin['value']:
                        result['issues'].append('text_mismatch')
        if not result['issues']:
            result['status'] = 'exact_text_match'
    covered = {tuple(r['span']) for r in results if r['status'] == 'exact_text_match'}
    uncovered = []
    for m in candidates:
        if (m.start(), m.end()) not in covered:
            line = document.count('\n', 0, m.start()) + 1
            column = m.start() - document.rfind('\n', 0, m.start())
            uncovered.append(dict(value=m.group(), span=[m.start(), m.end()], line=line, column=column))
    issues = sum(r['status'] != 'exact_text_match' for r in results)
    # Unused changed sources also fail: a pinned package must not silently drift.
    changed = [name for name, s in sources.items() if not s['unchanged']]
    return dict(tool='cell-pin', tool_version=VERSION, document=spec['document'],
                document_sha256=hashlib.sha256(document_data).hexdigest(),
                scope='Exact selected text-to-cell transcription only; no semantic, truth, unit, origin or freshness check.',
                sources={k: {a: v for a, v in s.items() if a not in ('headers', 'rows')} for k, s in sources.items()},
                pins=results, unpinned_numeric_candidates=uncovered,
                summary=dict(pins=len(results), exact_text_matches=len(results)-issues, unresolved_pins=issues,
                             numeric_candidates=len(candidates), unpinned_candidates=len(uncovered), changed_sources=changed),
                exit_code=1 if issues or uncovered or changed else 0)


def human_report(report):
    """Compact output keeps selected mappings and unchecked candidates visible."""
    q = lambda value: json.dumps(value, ensure_ascii=True)
    s = report['summary']
    lines = [f"Cell Pin {VERSION} — exact transcription only",
             f"{s['exact_text_matches']}/{s['pins']} selected spans match; "
             f"{s['unresolved_pins']} unresolved; {s['unpinned_candidates']} digit-based candidates unchecked.",
             "Not a fact-check: row meaning, units, source truth and currency are untested."]
    for name, source in report['sources'].items():
        if source['skip_records']:
            lines.append(f"SOURCE {q(name)}: skipped {source['skip_records']} CSV preamble records; all bytes hashed.")
    for pin in report['pins']:
        label = 'MATCH' if pin['status'] == 'exact_text_match' else 'CHECK'
        lines.append(f"{label} {q(pin['id'])}: prose {q(pin['value'])} -> "
                     f"{q(pin['source'])} {q(pin['where'])} / {q(pin['column'])} = {q(pin.get('cell'))}")
        if pin['issues']:
            lines.append('  ' + ', '.join(pin['issues']))
            if any(issue in pin['issues'] for issue in ('quote_missing', 'quote_ambiguous')):
                lines.append('  EXPECTED QUOTE ' + q(pin['quote']))
    for item in report['unpinned_numeric_candidates']:
        lines.append(f"UNCHECKED {q(item['value'])} at line {item['line']}, column {item['column']}")
    for name in s['changed_sources']:
        lines.append(f"SOURCE CHANGED {q(name)} — review the replacement; do not blindly update the hash.")
    return '\n'.join(lines)


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('pins', type=Path, help='Local JSON pins file; all paths stay inside its directory')
    parser.add_argument('--json', action='store_true', help='Emit full machine-readable report instead of compact text')
    args = parser.parse_args(argv)
    try:
        spec, base = load_spec(args.pins)
        report = check(spec, base)
    except (InputError, OSError) as e:
        print(json.dumps(dict(tool='cell-pin', input_error=str(e), exit_code=2), ensure_ascii=False))
        return 2
    print(json.dumps(report, ensure_ascii=False, indent=2) if args.json else human_report(report))
    return report['exit_code']


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