#!/usr/bin/env python3
"""Reproduce Table 1 arithmetic and finite sensitivity checks. Python 3 stdlib only.

No source download, network access, PDF library, or unpublished data is required.
The input is a literal transcription, not independently recoded source papers.
"""
import argparse
import csv
import hashlib
import itertools
import json
from collections import Counter
from pathlib import Path

CATEGORIES = ('Context', 'Process', 'Task', 'Team', 'Actor')
HERE = Path(__file__).resolve().parent


def load_rows(path=HERE / 'table-1.csv'):
    with Path(path).open(newline='', encoding='utf-8') as handle:
        rows = list(csv.DictReader(handle))
    for row in rows:
        row['row'] = int(row['row'])
        for category in CATEGORIES:
            row[category] = int(row[category])
            if row[category] not in (0, 1):
                raise ValueError('Every cell must be 0 or 1')
    if len({r['row'] for r in rows}) != len(rows):
        raise ValueError('Duplicate row identifier')
    return rows


def summarise(rows):
    if not rows:
        raise ValueError('At least one row is required')
    totals = {c: sum(r[c] for r in rows) for c in CATEGORIES}
    count = len(rows)
    return {
        'n': count,
        'counts': totals,
        'percentages': {c: round(100 * totals[c] / count, 6) for c in CATEGORIES},
        'rounded_percentages': {c: int(100 * totals[c] / count + .5) for c in CATEGORIES},
        'row_code_count_distribution': dict(sorted(Counter(sum(r[c] for c in CATEGORIES) for r in rows).items())),
        'all_five_rows': [r['row'] for r in rows if all(r[c] for c in CATEGORIES)],
        'no_visible_mark_rows': [r['row'] for r in rows if not any(r[c] for c in CATEGORIES)],
        'unique_leader': next((c for c in CATEGORIES if all(totals[c] > totals[q] for q in CATEGORIES if q != c)), None),
        'unique_lowest': next((c for c in CATEGORIES if all(totals[c] < totals[q] for q in CATEGORIES if q != c)), None),
        'process_strict_majority': totals['Process'] * 2 > count,
    }


def omit(rows, identifiers):
    ids = set(identifiers)
    if not ids.issubset({r['row'] for r in rows}):
        raise ValueError('Unknown row to omit')
    return [r for r in rows if r['row'] not in ids]


def omission_checks(rows, size):
    checked = 0
    failures = []
    process_margins = []
    for dropped in itertools.combinations([r['row'] for r in rows], size):
        s = summarise(omit(rows, dropped))
        checked += 1
        process_margins.append(s['counts']['Process'] - max(s['counts'][c] for c in CATEGORIES if c != 'Process'))
        if s['unique_leader'] != 'Process' or s['unique_lowest'] != 'Task':
            failures.append({'omitted': list(dropped), 'counts': s['counts']})
    return {'rows_removed_per_case': size, 'cases_checked': checked,
            'process_leader_or_task_lowest_failures': failures,
            'minimum_process_lead': min(process_margins),
            'maximum_process_lead': max(process_margins)}


def recoding_thresholds(rows):
    counts = summarise(rows)['counts']
    result = {}
    for challenger in CATEGORIES:
        if challenger == 'Process':
            continue
        gap = counts['Process'] - counts[challenger]
        exclusive = [r['row'] for r in rows if r['Process'] and not r[challenger]]
        # One cell toggle changes a difference by at most one; swapping two
        # binary cells in one eligible row changes it by exactly two.
        swaps = (gap + 1) // 2
        assert len(exclusive) >= swaps
        result[challenger] = {
            'initial_process_lead': gap,
            'cell_toggles_to_tie': gap,
            'cell_toggles_to_overtake': gap + 1,
            'row_recodes_to_tie_or_overtake': swaps,
            'row_recodes_to_strictly_overtake': gap // 2 + 1,
            'witness_row_ids_for_tie_or_overtake': exclusive[:swaps],
            'witness_operation': 'Within each listed row, remove Process and add the challenger. Hypothetical stress test; not a justified correction.',
            'process_only_relative_to_challenger_rows': len(exclusive),
        }
    fullest = max(sum(r[c] for c in CATEGORIES) for r in rows)
    result['all_five'] = {
        'minimum_added_cells_to_create_one_all_five_row': 5 - fullest,
        'closest_row_ids': [r['row'] for r in rows if sum(r[c] for c in CATEGORIES) == fullest],
        'meaning': 'A binary coverage count is not evidence that a study examined interactions among all five dimensions.',
    }
    result['strict_majority'] = {
        'process_cell_removals_to_lose_strict_majority_at_fixed_n': counts['Process'] - len(rows) // 2,
        'meaning': 'Adversarial changes to Process coding, not a probability or an empirically calibrated error rate.',
    }
    return result


def run(data_path=HERE / 'table-1.csv'):
    rows = load_rows(data_path)
    meta = json.loads((HERE / 'table-1.json').read_text())
    actual = summarise(rows)
    reported = meta['reported_summary']
    differences = [{
        'category': c,
        'visible_count': actual['counts'][c],
        'reported_count': reported['counts'][c],
        'difference_visible_minus_reported': actual['counts'][c] - reported['counts'][c],
        'visible_rounded_percent': actual['rounded_percentages'][c],
        'reported_percent': reported['percentages'][c],
    } for c in CATEGORIES if actual['counts'][c] != reported['counts'][c]]
    cases = {
        'omit_blank_row_43': [43],
        'omit_2017_label_row_17': [17],
        'omit_four_dimension_row_47': [47],
        'omit_rows_17_and_43': [17, 43],
    }
    return {
        'analysis_version': '1.0', 'date': '2026-11-01',
        'input_csv_sha256': hashlib.sha256(Path(data_path).read_bytes()).hexdigest(),
        'categories': list(CATEGORIES), 'literal_table': actual,
        'reported_summary': reported, 'aggregate_disagreements': differences,
        'minimum_cell_changes_to_match_all_reported_counts': sum(abs(actual['counts'][c] - reported['counts'][c]) for c in CATEGORIES),
        'reconciliation_limit': 'Four cell changes can match marginal totals but are not identified or authorised by aggregate counts. No matrix is silently repaired.',
        'omission_scenarios': {name: {'omitted_rows': ids, **summarise(omit(rows, ids))} for name, ids in cases.items()},
        'exhaustive_omissions': [omission_checks(rows, 1), omission_checks(rows, 2)],
        'recoding_sensitivity': recoding_thresholds(rows),
        'limits': [
            'Counts reproduce visible marks in one identified PDF, not the original coding decisions.',
            'No original Scopus export, network, screening process or 62-paper qualitative coding was rerun.',
            'Omissions and recodings are constructed stress tests, not estimates of likely errors.',
            'The historical selected corpus does not establish research coverage in 2026 or causal effectiveness of project practices.',
            'An internal aggregate discrepancy alone does not establish misconduct, unreliable core conclusions, or grounds for retraction.',
        ],
    }


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--output', type=Path, default=HERE / 'results.json')
    args = parser.parse_args()
    result = run()
    args.output.write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8')
    print(json.dumps({'counts': result['literal_table']['counts'],
                      'aggregate_disagreements': len(result['aggregate_disagreements']),
                      'omission_cases': sum(r['cases_checked'] for r in result['exhaustive_omissions']),
                      'output': str(args.output)}, indent=2))
