#!/usr/bin/env python3
"""Synthetic component inspection. Python 3 standard library only; no network.
Run: python3 scripts/proof/cv-inspection-specimen.py --output public/proof
Check committed output: add --check. Downloaded source accepts any output folder.
Ground truth is used only after detection, never by inspect_image().
"""
import argparse
import hashlib
import json
import pathlib
import statistics
import struct
import zlib

WIDTH, HEIGHT = 320, 180
PARAMETERS = dict(dark_below=100, area_min=600, area_max=1600,
                  fill_min=0.85, border_median_min=180, connectivity=4)
BOXES = [(40, 65, 70, 101), (120, 65, 150, 101), (200, 65, 230, 101)]


def rectangle(pixels, box, value):
    x0, y0, x1, y1 = box
    for y in range(y0, y1):
        for x in range(x0, x1):
            pixels[y * WIDTH + x] = value


def inspect_image(pixels):
    border = pixels[:WIDTH] + pixels[-WIDTH:]
    border += [pixels[y * WIDTH + x] for y in range(1, HEIGHT - 1)
               for x in (0, WIDTH - 1)]
    illumination = statistics.median(border)
    if illumination < PARAMETERS['border_median_min']:
        return dict(decision='abstain', reason='Border median below the declared lighting threshold.',
                    border_median=illumination, components=[], accepted_count=None)
    seen, components = set(), []
    for start, value in enumerate(pixels):
        if value >= PARAMETERS['dark_below'] or start in seen:
            continue
        seen.add(start)
        stack, points = [start], []
        while stack:
            p = stack.pop()
            points.append((p % WIDTH, p // WIDTH))
            x, y = p % WIDTH, p // WIDTH
            for nx, ny in ((x-1, y), (x+1, y), (x, y-1), (x, y+1)):
                n = ny * WIDTH + nx
                if 0 <= nx < WIDTH and 0 <= ny < HEIGHT and n not in seen and pixels[n] < PARAMETERS['dark_below']:
                    seen.add(n)
                    stack.append(n)
        xs, ys = zip(*points)
        box = [min(xs), min(ys), max(xs)+1, max(ys)+1]
        area = len(points)
        fill = area / ((box[2]-box[0]) * (box[3]-box[1]))
        accepted = PARAMETERS['area_min'] <= area <= PARAMETERS['area_max'] and fill >= PARAMETERS['fill_min']
        components.append(dict(box=box, pixels=area, fill_fraction=round(fill, 6), accepted=accepted))
    return dict(decision='report', reason='Lighting gate passed; report qualifying dark components.',
                border_median=illumination, components=components,
                accepted_count=sum(c['accepted'] for c in components))


def iou(a, b):
    intersection = max(0, min(a[2], b[2])-max(a[0], b[0])) * max(0, min(a[3], b[3])-max(a[1], b[1]))
    return intersection / ((a[2]-a[0])*(a[3]-a[1])+(b[2]-b[0])*(b[3]-b[1])-intersection)


def evaluate(result):
    if result['decision'] == 'abstain':
        return dict(matched=None, false_positives=None, missed=None,
                    outcome='withheld', note='No count issued; this is not a successful inspection.')
    unmatched, matched, false_positives = list(BOXES), 0, 0
    for component in result['components']:
        if not component['accepted']:
            continue
        index = next((i for i, b in enumerate(unmatched) if iou(component['box'], b) >= 0.5), None)
        if index is None:
            false_positives += 1
        else:
            matched += 1
            unmatched.pop(index)
    return dict(matched=matched, false_positives=false_positives, missed=len(unmatched),
                outcome='matches constructed labels' if not false_positives and not unmatched else 'fails constructed labels',
                note='One-to-one bounding-box matching at IoU >= 0.5 against three generated target rectangles.')


def png(rgb):
    def chunk(kind, data):
        return struct.pack('!I', len(data)) + kind + data + struct.pack('!I', zlib.crc32(kind+data) & 0xffffffff)
    rows = b''.join(b'\x00' + bytes(rgb[y*WIDTH*3:(y+1)*WIDTH*3]) for y in range(HEIGHT))
    return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('!2I5B', WIDTH, HEIGHT, 8, 2, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(rows, 9)) + chunk(b'IEND', b'')


def panel(pixels, result=None):
    rgb = [channel for value in pixels for channel in (value, value, value)]
    def dot(x, y, color):
        if 0 <= x < WIDTH and 0 <= y < HEIGHT:
            rgb[(y*WIDTH+x)*3:(y*WIDTH+x)*3+3] = color
    if result:
        for c in result['components']:
            color = (0, 150, 180) if c['accepted'] else (190, 75, 0)
            x0, y0, x1, y1 = c['box']
            for offset in (1, 2):
                for x in range(x0-offset, x1+offset):
                    dot(x, y0-offset, color); dot(x, y1-1+offset, color)
                for y in range(y0-offset, y1+offset):
                    dot(x0-offset, y, color); dot(x1-1+offset, y, color)
        if result['decision'] == 'abstain':
            for x in range(WIDTH):
                dot(x, 0, (190, 75, 0)); dot(x, HEIGHT-1, (190, 75, 0))
    return png(rgb)


def generate():
    files, cases = {}, []
    settings = [
        ('clean', 'Separated shapes', 'Three dark rectangles on an even light background.', 235),
        ('contamination', 'A false positive', 'A fourth dark rectangle represents contamination. Its size passes the same rule as a target.', 235),
        ('joined', 'Two objects become one component', 'A dark bridge joins two target rectangles. The merged component is rejected by the size and fill rules.', 235),
        ('dim', 'Lighting gate withholds the count', 'The whole background is darkened. The border check fails before component counting.', 140),
    ]
    for key, title, change, background in settings:
        pixels = [background] * (WIDTH * HEIGHT)
        for box in BOXES:
            rectangle(pixels, box, 45)
        if key == 'contamination':
            rectangle(pixels, (265, 70, 290, 100), 45)
        if key == 'joined':
            rectangle(pixels, (70, 80, 120, 84), 45)
        result = inspect_image(pixels)
        observed = evaluate(result)
        input_name, output_name = f'cv-{key}-input.png', f'cv-{key}-result.png'
        files[input_name], files[output_name] = panel(pixels), panel(pixels, result)
        cases.append(dict(id=key, title=title, change=change, input=input_name, result_image=output_name,
                          input_sha256=hashlib.sha256(files[input_name]).hexdigest(),
                          target_boxes=BOXES, target_count=len(BOXES), detection=result, evaluation=observed))
    # These expectations test the intended demonstration, including its failures.
    assert [(c['detection']['accepted_count'], c['evaluation']['false_positives'], c['evaluation']['missed']) for c in cases] == [(3, 0, 0), (4, 1, 0), (1, 0, 2), (None, None, None)]
    source = pathlib.Path(__file__).read_bytes()
    report = dict(version=1, specimen='Synthetic threshold and connected-component inspection',
                  provenance='All pixels and labels generated by this source; no camera, client, stock or personal images.',
                  source_sha256=hashlib.sha256(source).hexdigest(), dimensions=[WIDTH, HEIGHT], parameters=PARAMETERS,
                  limitations=['Four constructed cases, not an accuracy benchmark or representative dataset.',
                               'Fixed thresholds are chosen for these shapes; no model training or semantic object recognition.',
                               'The border lighting rule only observes the border and cannot detect every lighting failure.',
                               'The count does not control equipment or certify product quality.'], cases=cases)
    files['cv-inspection-results.json'] = (json.dumps(report, indent=2)+'\n').encode()
    files['cv-inspection-specimen.py'] = source
    return files, report


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('cv-output'))
    parser.add_argument('--check', action='store_true', help='Compare generated bytes with existing outputs, without writing.')
    args = parser.parse_args()
    artifacts, report = generate()
    if args.check:
        mismatches = [name for name, data in artifacts.items() if not (args.output/name).exists() or (args.output/name).read_bytes() != data]
        if mismatches:
            raise SystemExit('CHECK FAILED: '+', '.join(mismatches))
    else:
        args.output.mkdir(parents=True, exist_ok=True)
        for name, data in artifacts.items():
            (args.output/name).write_bytes(data)
    print(json.dumps({'check': args.check, 'artifacts': len(artifacts), 'observed': [{'case': c['id'], 'count': c['detection']['accepted_count'], 'outcome': c['evaluation']['outcome']} for c in report['cases']]}, indent=2))
