"""Run: python3 -m unittest discover -s experiments/cell-pin -v"""
import copy
import hashlib
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
import cell_pin as cp

HERE = Path(__file__).resolve().parent


class CellPinTests(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)
        self.base = Path(self.tmp.name)
        for name in ['table.csv', 'report.md', 'pins.json']:
            (self.base / name).write_bytes((HERE / 'examples' / name).read_bytes())
        self.spec = json.loads((self.base / 'pins.json').read_text())

    def check(self):
        return cp.check(self.spec, self.base)

    def write_doc(self, text):
        (self.base / 'report.md').write_text(text)

    def doc(self):
        return (self.base / 'report.md').read_text()

    def write_table(self, text, repin=True):
        p = self.base / 'table.csv'
        p.write_text(text)
        if repin:
            self.spec['sources']['repairs']['sha256'] = hashlib.sha256(p.read_bytes()).hexdigest()

    def test_sentence_final_numbers_are_candidates(self):
        text = 'Counts: 18. Mean: 27.5. Signed: -2.5. Scientific: 1e3. Grouped: 1,234.'
        self.assertEqual([m.group() for m in cp.NUMBER.finditer(text)],
                         ['18', '27.5', '-2.5', '1e3', '1,234'])

    def test_terminal_punctuation_does_not_split_numeric_or_identifier_tokens(self):
        for text in ['118', '27.50', '1.2.3', 'v1.2', '12.foo', '12x', 'x12']:
            with self.subTest(text=text):
                tokens = [m.group() for m in cp.NUMBER.finditer(text)]
                self.assertEqual(tokens, [text] if text in ['118', '27.50'] else [])

    def test_sentence_final_value_can_be_pinned_and_extra_number_stays_unchecked(self):
        self.write_doc('The count is 18. Another count is 4.')
        self.spec['pins'] = [self.spec['pins'][0]]
        self.spec['pins'][0]['quote'] = 'The count is 18.'
        r = self.check()
        self.assertEqual(r['summary']['exact_text_matches'], 1)
        self.assertEqual([p['value'] for p in r['unpinned_numeric_candidates']], ['4'])
        self.assertEqual(r['exit_code'], 1)

    def test_missing_quote_report_exposes_exact_old_anchor_without_terminal_controls(self):
        self.write_doc('The revised count is 18.')
        self.spec['pins'][0]['quote'] = 'old\nquote\x1b[31m 18'
        report = cp.human_report(self.check())
        self.assertIn('EXPECTED QUOTE "old\\nquote\\u001b[31m 18"', report)
        self.assertNotIn('\x1b', report)

    def test_explicit_preamble_preserves_raw_hash_and_matches(self):
        self.write_table('A synthetic title\n' + (self.base/'table.csv').read_text())
        with self.assertRaises(cp.InputError):
            self.check()
        self.spec['sources']['repairs']['skip_records'] = 1
        r = self.check()
        self.assertEqual(r['summary']['exact_text_matches'], 3)
        self.assertEqual(r['sources']['repairs']['skip_records'], 1)
        self.assertIn('all bytes hashed', cp.human_report(r))
        self.write_table((self.base/'table.csv').read_text().replace('synthetic', 'revised'), repin=False)
        self.assertEqual(self.check()['summary']['exact_text_matches'], 0)
        self.assertIn('source_changed', self.check()['pins'][0]['issues'])

    def test_preamble_counts_csv_records_not_physical_lines(self):
        self.write_table('"Title with\na newline"\n' + (self.base/'table.csv').read_text())
        self.spec['sources']['repairs']['skip_records'] = 1
        self.assertEqual(self.check()['summary']['exact_text_matches'], 3)

    def test_bad_preamble_selection_rejected(self):
        for value in [-1, True, '1', 1.0, 101, None, 100]:
            with self.subTest(value=value):
                self.spec['sources']['repairs']['skip_records'] = value
                with self.assertRaises(cp.InputError):
                    self.check()

    def test_preamble_does_not_relax_table_validation(self):
        self.spec['sources']['repairs']['skip_records'] = 1
        for table in ['title\na,a\n1,2\n', 'title\na,b\n1\n']:
            self.write_table(table)
            with self.assertRaises(cp.InputError):
                self.check()

    def test_three_matches_do_not_hide_unpinned_fourth_number(self):
        r = self.check()
        self.assertEqual(r['summary']['exact_text_matches'], 3)
        self.assertEqual([p['value'] for p in r['unpinned_numeric_candidates']], ['4'])
        self.assertEqual(r['exit_code'], 1)

    def test_fully_selected_text_passes_but_not_as_truth(self):
        self.write_doc(self.doc().replace(' The room had 4 tables.', ''))
        r = self.check()
        self.assertEqual(r['exit_code'], 0)
        self.assertIn('no semantic', r['scope'])

    def test_planted_value_with_matching_pin_is_rejected_against_cell(self):
        self.write_doc(self.doc().replace('18 repairs', '19 repairs'))
        self.spec['pins'][0]['quote'] = self.spec['pins'][0]['quote'].replace('18', '19')
        self.spec['pins'][0]['value'] = '19'
        p = self.check()['pins'][0]
        self.assertEqual(p['cell'], '18')
        self.assertIn('text_mismatch', p['issues'])

    def test_editing_document_without_updating_pin_fails_closed(self):
        self.write_doc(self.doc().replace('18 repairs', '19 repairs'))
        r = self.check()
        self.assertIn('quote_missing', r['pins'][0]['issues'])
        self.assertIn('19', [p['value'] for p in r['unpinned_numeric_candidates']])

    def test_source_byte_change_invalidates_even_matching_cells(self):
        self.write_table((self.base/'table.csv').read_text() + 'July,18,27.5\n', repin=False)
        r = self.check()
        self.assertEqual(r['summary']['exact_text_matches'], 0)
        self.assertTrue(all('source_changed' in p['issues'] for p in r['pins']))

    def test_duplicate_quote_is_not_resolved_by_guessing(self):
        self.write_doc(self.doc() + '\n' + self.spec['pins'][0]['quote'])
        self.assertIn('quote_ambiguous', self.check()['pins'][0]['issues'])

    def test_duplicate_value_inside_quote_is_ambiguous(self):
        self.write_doc('There were 18 and 18 repairs.')
        self.spec['pins'][0]['quote'] = self.doc()
        self.assertIn('value_ambiguous_in_quote', self.check()['pins'][0]['issues'])

    def test_same_span_cannot_be_pinned_twice(self):
        self.spec['pins'].append({**self.spec['pins'][0], 'id': 'duplicate'})
        r = self.check()
        self.assertIn('overlapping_pin', r['pins'][0]['issues'])
        self.assertIn('overlapping_pin', r['pins'][-1]['issues'])
        self.assertEqual(r['summary']['exact_text_matches'], 2)

    def test_selector_must_find_one_row(self):
        self.write_table((self.base/'table.csv').read_text() + 'April,18,27.5\n')
        self.assertIn('row_ambiguous', self.check()['pins'][0]['issues'])

    def test_missing_row_column_source(self):
        cases = [('where', {'month': 'July'}, 'row_missing'), ('column', 'missing', 'unknown_column'), ('source', 'missing', 'unknown_source')]
        for key, value, issue in cases:
            with self.subTest(issue=issue):
                original = copy.deepcopy(self.spec)
                self.spec['pins'][0][key] = value
                self.assertIn(issue, self.check()['pins'][0]['issues'])
                self.spec = original

    def test_wrong_semantic_row_can_match_and_is_not_claimed_detected(self):
        # Deliberate counterexample, not a capability claim. June also has 18.
        self.write_table('month,repairs,mean_minutes\nApril,18,27.5\nMay,23,24.0\nJune,18,25.5\n')
        self.spec['pins'][0]['where'] = {'month': 'June'}
        self.assertEqual(self.check()['pins'][0]['status'], 'exact_text_match')

    def test_substring_cannot_pin_digits_inside_a_larger_token(self):
        self.write_doc('There were 118 repairs.')
        self.spec['pins'][0]['quote'] = '18 repairs.'
        self.assertIn('value_not_whole_document_token', self.check()['pins'][0]['issues'])

    def test_formatting_is_exact_not_numeric_equivalence(self):
        self.write_doc(self.doc().replace('27.5', '27.50'))
        self.spec['pins'][1]['quote'] = self.spec['pins'][1]['quote'].replace('27.5', '27.50')
        self.spec['pins'][1]['value'] = '27.50'
        self.assertIn('text_mismatch', self.check()['pins'][1]['issues'])

    def test_new_context_does_not_change_existing_comparisons(self):
        self.write_doc('Some unrelated context.\n' * 1000 + self.doc())
        self.assertEqual(self.check()['summary']['exact_text_matches'], 3)

    def test_quotes_crlf_unicode_and_bom(self):
        text = 'month,repairs,mean_minutes\r\n"April",18,27.5\r\nMay,23,24.0\r\n'
        self.write_table('\ufeff' + text)
        self.assertEqual(self.check()['summary']['exact_text_matches'], 3)

    def test_bad_csv_headers_width_and_quotes_rejected(self):
        for text in ['a,a\n1,2\n', 'a,b\n1\n', 'a,b\n"unclosed,1', ',b\n1,2\n']:
            with self.subTest(text=text):
                self.write_table(text)
                with self.assertRaises(cp.InputError):
                    self.check()

    def test_paths_cannot_escape_package(self):
        for path in ['../outside.csv', '/etc/passwd']:
            with self.subTest(path=path):
                self.spec['sources']['repairs']['path'] = path
                with self.assertRaises(cp.InputError):
                    self.check()

    def test_symlink_cannot_escape_package(self):
        (self.base/'outside').symlink_to('/etc/passwd')
        self.spec['sources']['repairs']['path'] = 'outside'
        with self.assertRaises(cp.InputError):
            self.check()

    def test_invalid_schema_rejected(self):
        original = copy.deepcopy(self.spec)
        for mutate in [lambda s: s.update(pins=[]), lambda s: s.update(version=True), lambda s: s.update(extra='oops'), lambda s: s['pins'][0].update(where={}), lambda s: s['pins'].append(copy.deepcopy(s['pins'][0]))]:
            with self.subTest(mutate=mutate):
                self.spec = copy.deepcopy(original)
                mutate(self.spec)
                with self.assertRaises(cp.InputError):
                    self.check()

    def test_duplicate_json_keys_rejected(self):
        (self.base/'pins.json').write_text('{"version":1,"version":1}')
        with self.assertRaises(cp.InputError):
            cp.load_spec(self.base/'pins.json')

    def test_human_output_keeps_limitations_and_unchecked_number_visible(self):
        report = cp.human_report(self.check())
        self.assertIn('3/3 selected spans match', report)
        self.assertIn('1 digit-based candidates unchecked', report)
        self.assertIn('Not a fact-check', report)
        self.assertIn('UNCHECKED "4" at line 4', report)
        self.assertIn('"month": "April"', report)

    def test_cli_report_exit_codes_and_read_only_behavior(self):
        before = {p.name: p.read_bytes() for p in self.base.iterdir()}
        cmd = [sys.executable, str(HERE/'cell_pin.py'), str(self.base/'pins.json'), '--json']
        run = subprocess.run(cmd, capture_output=True, text=True)
        self.assertEqual(run.returncode, 1)
        self.assertEqual(json.loads(run.stdout)['summary']['exact_text_matches'], 3)
        self.assertEqual(before, {p.name: p.read_bytes() for p in self.base.iterdir()})
        self.write_doc(self.doc().replace(' The room had 4 tables.', ''))
        self.assertEqual(subprocess.run(cmd, capture_output=True).returncode, 0)
        (self.base/'pins.json').write_text('{broken')
        run = subprocess.run(cmd, capture_output=True, text=True)
        self.assertEqual(run.returncode, 2)
        self.assertIn('input_error', json.loads(run.stdout))


if __name__ == '__main__':
    unittest.main()
