#!/usr/bin/env python3
"""Deterministic Facebook cover two-frame crop study. Standard library only.

The source is one 851 x 315 PNG, the size Facebook recommends. The script
builds left- and right-anchored exports for the 16:9 desktop frame
(560 x 315) and the 2.4:1 mobile frame (756 x 315), measures the crop shares,
the 40 px avatar overlap and 320 px previews. No clock, randomness or network.
"""

import hashlib
import json
import math
import os
import struct
import zlib

HERE = os.path.dirname(os.path.abspath(__file__))
SRC_W, SRC_H = 851, 315
FRAMES = [("desktop", 560, 315), ("mobile", 756, 315)]
AVATAR_RADIUS = 40

BG = (247, 244, 236, 255)
GRID = (221, 226, 226, 255)
GRID_MAJOR = (176, 188, 192, 255)
INK = (18, 38, 54, 255)
CREAM = (255, 252, 243, 255)
ACCENT = (31, 93, 140, 255)
MARKERS = [
    (198, 95, 62, 255),
    (36, 106, 158, 255),
    (210, 158, 58, 255),
]
AVATAR_MARKER = (176, 62, 126, 255)
FEATURE_TL = 1
FEATURE_A = 2
FEATURE_B = 3
FEATURE_AVATAR = 4

FONT = {
    " ": ["00000"] * 7,
    "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
    "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
    "2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
    "3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
    "4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
    "5": ["11111", "10000", "10000", "11110", "00001", "00001", "11110"],
    "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
    "A": ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
    "B": ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
    "C": ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
    "E": ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
    "F": ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
    "K": ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
    "O": ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
    "X": ["10001", "10001", "01010", "00100", "01010", "10001", "10001"],
}


def canvas(width, height, colour=BG):
    return [bytearray(bytes(colour) * width) for _ in range(height)]


def rect(pixels, width, height, x0, y0, box_w, box_h, colour):
    row_bytes = bytes(colour)
    for y in range(max(0, y0), min(height, y0 + box_h)):
        row = pixels[y]
        for x in range(max(0, x0), min(width, x0 + box_w)):
            row[x * 4:x * 4 + 4] = row_bytes


def text(pixels, width, height, x, y, value, scale, colour):
    cursor = x
    for char in value:
        glyph = FONT[char]
        for gy, line in enumerate(glyph):
            for gx, bit in enumerate(line):
                if bit == "1":
                    rect(pixels, width, height, cursor + gx * scale, y + gy * scale, scale, scale, colour)
        cursor += 6 * scale


def centered_text(pixels, width, height, y, value, scale, colour):
    x = (width - (len(value) * 6 * scale - scale)) // 2
    text(pixels, width, height, x, y, value, scale, colour)


def mask_canvas(width=SRC_W, height=SRC_H):
    return [bytearray(width) for _ in range(height)]


def mask_rect(mask, width, height, x0, y0, box_w, box_h, value):
    for y in range(max(0, y0), min(height, y0 + box_h)):
        row = mask[y]
        for x in range(max(0, x0), min(width, x0 + box_w)):
            row[x] = value


def source_image():
    pixels = canvas(SRC_W, SRC_H)
    features = mask_canvas()
    for x in range(0, SRC_W, 50):
        colour = GRID_MAJOR if x % 250 == 0 else GRID
        rect(pixels, SRC_W, SRC_H, x, 0, 1, SRC_H, colour)
    for y in range(0, SRC_H, 50):
        colour = GRID_MAJOR if y % 250 == 0 else GRID
        rect(pixels, SRC_W, SRC_H, 0, y, SRC_W, 1, colour)

    rect(pixels, SRC_W, SRC_H, 0, 0, SRC_W, 10, INK)
    rect(pixels, SRC_W, SRC_H, 0, SRC_H - 10, SRC_W, 10, INK)
    rect(pixels, SRC_W, SRC_H, 0, 0, 10, SRC_H, INK)
    rect(pixels, SRC_W, SRC_H, SRC_W - 10, 0, 10, SRC_H, INK)

    markers = [
        ("top-left", 24, 24, 64, FEATURE_TL, MARKERS[0]),
        ("frame-a", 560, 24, 64, FEATURE_A, MARKERS[1]),
        ("frame-b", 763, 24, 64, FEATURE_B, MARKERS[2]),
    ]
    for _, x, y, size, feature, colour in markers:
        rect(pixels, SRC_W, SRC_H, x, y, size, size, colour)
        mask_rect(features, SRC_W, SRC_H, x, y, size, size, feature)

    rect(pixels, SRC_W, SRC_H, 2, 277, 36, 36, AVATAR_MARKER)
    mask_rect(features, SRC_W, SRC_H, 2, 277, 36, 36, FEATURE_AVATAR)

    centered_text(pixels, SRC_W, SRC_H, 108, "FACEBOOK", 6, INK)
    centered_text(pixels, SRC_W, SRC_H, 168, "851 X 315", 4, ACCENT)
    return pixels, features, [
        {"name": name, "x": x, "y": y, "width": size, "height": size, "feature": feature}
        for name, x, y, size, feature, _ in markers
    ]


def write_png(path, pixels, width, height):
    raw = b"".join(b"\x00" + bytes(row) for row in pixels)

    def chunk(kind, data):
        return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data))

    with open(path, "wb") as stream:
        stream.write(b"\x89PNG\r\n\x1a\n")
        stream.write(chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)))
        stream.write(chunk(b"IDAT", zlib.compress(raw, 9)))
        stream.write(chunk(b"IEND", b""))


def resample(source, source_w, source_h, width, height, sx, sy, step_x, step_y, features=None):
    output = canvas(width, height, (0, 0, 0, 0))
    output_features = mask_canvas(width, height) if features is not None else None
    for y in range(height):
        source_y = min(source_h - 1, max(0, int(sy + (y + 0.5) * step_y - 0.5)))
        row = output[y]
        feature_row = output_features[y] if output_features is not None else None
        for x in range(width):
            source_x = min(source_w - 1, max(0, int(sx + (x + 0.5) * step_x - 0.5)))
            row[x * 4:x * 4 + 4] = source[source_y][source_x * 4:source_x * 4 + 4]
            if feature_row is not None:
                feature_row[x] = features[source_y][source_x]
    return (output, output_features) if output_features is not None else output


def fill_export(source, features, width, height, focus):
    scale = max(width / SRC_W, height / SRC_H)
    kept_w, kept_h = width / scale, height / scale
    sx, sy = (SRC_W - kept_w) * focus, (SRC_H - kept_h) * focus
    pixels, output_features = resample(source, SRC_W, SRC_H, width, height, sx, sy, kept_w / width, kept_h / height, features)
    crop_area = SRC_W * SRC_H - kept_w * kept_h
    return pixels, output_features, {
        "scale": scale,
        "focus": focus,
        "source_x": sx,
        "source_y": sy,
        "source_region": [kept_w, kept_h],
        "crop_area_px": crop_area,
        "crop_share": crop_area / (SRC_W * SRC_H),
        "kept_share_of_width": kept_w / SRC_W,
    }


def verify_png(path, pixels, width, height):
    with open(path, "rb") as stream:
        data = stream.read()
    if data[:8] != b"\x89PNG\r\n\x1a\n":
        raise ValueError(f"{path}: bad PNG signature")
    offset, chunks, image_data, header = 8, 0, bytearray(), None
    while offset < len(data):
        length = struct.unpack(">I", data[offset:offset + 4])[0]
        kind = data[offset + 4:offset + 8]
        chunk_data = data[offset + 8:offset + 8 + length]
        stored_crc = struct.unpack(">I", data[offset + 8 + length:offset + 12 + length])[0]
        if zlib.crc32(kind + chunk_data) != stored_crc:
            raise ValueError(f"{path}: bad CRC in {kind.decode('ascii')}")
        if kind == b"IHDR":
            header = struct.unpack(">IIBBBBB", chunk_data)
        elif kind == b"IDAT":
            image_data.extend(chunk_data)
        chunks += 1
        offset += 12 + length
        if kind == b"IEND":
            break
    if header is None or not image_data:
        raise ValueError(f"{path}: incomplete PNG")
    read_width, read_height, bit_depth, colour_type, compression, filter_method, interlace = header
    if (read_width, read_height, bit_depth, colour_type, compression, filter_method, interlace) != (width, height, 8, 6, 0, 0, 0):
        raise ValueError(f"{path}: unexpected IHDR {header}")
    raw = zlib.decompress(image_data)
    stride = 1 + width * 4
    if len(raw) != height * stride:
        raise ValueError(f"{path}: decompressed raster has {len(raw)} bytes, expected {height * stride}")
    for y in range(height):
        start = y * stride
        if raw[start] != 0:
            raise ValueError(f"{path}: unexpected PNG filter {raw[start]} on row {y}")
        if raw[start + 1:start + stride] != bytes(pixels[y]):
            raise ValueError(f"{path}: decoded row {y} differs from the generated canvas")
    return {
        "width": read_width,
        "height": read_height,
        "bit_depth": bit_depth,
        "colour_type": "RGBA",
        "chunks": chunks,
        "chunk_crc_ok": True,
        "raster_sha256": hashlib.sha256(raw).hexdigest(),
        "file_bytes": len(data),
        "pixel_match": True,
    }


def feature_pixels(features, value, x0=0, y0=0, x1=None, y1=None):
    height, width = len(features), len(features[0])
    x1 = width if x1 is None else x1
    y1 = height if y1 is None else y1
    return sum(row[x0:x1].count(value) for row in features[y0:y1])


def avatar_mask(width, height, radius=AVATAR_RADIUS):
    """Quarter disc at the lower-left corner, in export pixels."""
    mask = [[0] * width for _ in range(height)]
    for y in range(height):
        for x in range(width):
            if (x + 0.5) ** 2 + (height - (y + 0.5)) ** 2 <= radius * radius:
                mask[y][x] = 1
    return mask


def digest(path):
    with open(path, "rb") as stream:
        return hashlib.sha256(stream.read()).hexdigest()


def write_two_frame_svg(path):
    svg = """<svg xmlns="http://www.w3.org/2000/svg" width="920" height="430" viewBox="0 0 920 430" role="img" aria-labelledby="title desc">
<title id="title">One 851 by 315 upload, two Facebook cover frames</title>
<desc id="desc">A 851 by 315 rectangle with a 560 by 315 desktop 16 to 9 window and a 756 by 315 mobile 2.4 to 1 window drawn from the left edge. The windows overlap; the desktop window ends 291 pixels earlier than the full width and the mobile window ends 95 pixels earlier.</desc>
<rect width="920" height="430" fill="#f7f4ec"/>
<text x="24" y="36" fill="#122636" font-family="Georgia, serif" font-size="26" font-weight="700">One upload · 851 × 315</text>
<g transform="translate(24,58)">
<rect width="851" height="315" fill="#efece2" stroke="#122636" stroke-width="2"/>
<path d="M0 50H851M0 100H851M0 150H851M0 200H851M0 250H851M0 300H851M50 0V315M100 0V315M150 0V315M200 0V315M250 0V315M300 0V315M350 0V315M400 0V315M450 0V315M500 0V315M550 0V315M600 0V315M650 0V315M700 0V315M750 0V315M800 0V315" stroke="#dde2e2"/>
<rect x="1" y="1" width="559" height="313" fill="rgba(31,93,140,.16)" stroke="#1f5d8c" stroke-width="4"/>
<rect x="1" y="1" width="755" height="313" fill="none" stroke="#c96b3b" stroke-width="4" stroke-dasharray="12 8"/>
<path d="M2 315A40 40 0 0 0 42 275L42 315Z" fill="rgba(176,62,126,.55)" stroke="#b03e7e" stroke-width="2"/>
<text x="280" y="182" fill="#1f5d8c" font-family="Arial, sans-serif" font-size="26" font-weight="700" text-anchor="middle">16:9 desktop</text>
<text x="658" y="228" fill="#c96b3b" font-family="Arial, sans-serif" font-size="26" font-weight="700" text-anchor="middle">2.4:1 mobile</text>
</g>
<text x="24" y="414" fill="#5b6874" font-family="Arial, sans-serif" font-size="17">Both windows are anchored left. Facebook does not publish the exact crop rectangle.</text>
</svg>
"""
    with open(path, "w", encoding="utf-8") as stream:
        stream.write(svg)


def write_avatar_svg(path):
    svg = """<svg xmlns="http://www.w3.org/2000/svg" width="360" height="240" viewBox="0 0 120 80" role="img" aria-labelledby="title desc">
<title id="title">The 40 pixel avatar overlap drawn at the lower-left corner</title>
<desc id="desc">A corner crop with a dashed quarter disc 40 pixels high and 40 pixels wide. Facebook publishes about 40 pixels of overlap. The full avatar diameter and reach are not stated.</desc>
<rect width="120" height="80" fill="#f7f4ec"/>
<path d="M0 20H120M0 40H120M0 60H120M20 0V80M40 0V80M60 0V80M80 0V80M100 0V80" stroke="#dde2e2" stroke-width=".6"/>
<rect width="120" height="80" fill="none" stroke="#122636" stroke-width="1"/>
<path d="M0 80A40 40 0 0 0 40 40L40 80Z" fill="rgba(176,62,126,.28)" stroke="#b03e7e" stroke-width="1.4" stroke-dasharray="4 3"/>
<path d="M0 74H6M0 68H12M0 62H18" stroke="#b03e7e" stroke-width=".8"/>
<text x="46" y="70" fill="#8a3062" font-family="Arial, sans-serif" font-size="8" font-weight="700">40 px</text>
<text x="46" y="58" fill="#5b6874" font-family="Arial, sans-serif" font-size="6">overlap depth</text>
<text x="46" y="16" fill="#5b6874" font-family="Arial, sans-serif" font-size="6">exact avatar</text>
<text x="46" y="24" fill="#5b6874" font-family="Arial, sans-serif" font-size="6">shape and reach</text>
<text x="46" y="32" fill="#5b6874" font-family="Arial, sans-serif" font-size="6">Not stated</text>
</svg>
"""
    with open(path, "w", encoding="utf-8") as stream:
        stream.write(svg)


def main():
    exports_dir = os.path.join(HERE, "exports")
    previews_dir = os.path.join(HERE, "previews-320")
    negative_dir = os.path.join(HERE, "negative")
    os.makedirs(exports_dir, exist_ok=True)
    os.makedirs(previews_dir, exist_ok=True)
    os.makedirs(negative_dir, exist_ok=True)

    source, source_features, markers = source_image()
    source_path = os.path.join(HERE, "source-grid-851x315.png")
    write_png(source_path, source, SRC_W, SRC_H)
    source_readback = verify_png(source_path, source, SRC_W, SRC_H)
    write_two_frame_svg(os.path.join(HERE, "two-frame-diagram.svg"))
    write_avatar_svg(os.path.join(HERE, "avatar-overlap.svg"))

    mask = avatar_mask(756, 315)
    avatar_mask_pixels = sum(sum(row) for row in mask)

    rows, previews = [], []
    for name, width, height in FRAMES:
        for focus in (0.0, 1.0):
            label = "left" if focus == 0 else "right"
            filename = f"{name}-{width}x{height}-{label}.png"
            pixels, output_features, geometry = fill_export(source, source_features, width, height, focus)
            path = os.path.join(exports_dir, filename)
            write_png(path, pixels, width, height)
            readback = verify_png(path, pixels, width, height)
            covered = sum(
                1
                for y in range(height)
                for x in range(width)
                if output_features[y][x] == FEATURE_AVATAR and mask[y][x] == 1
            ) if name == "mobile" else 0
            preview_width = 320
            preview_height = max(1, round(height * preview_width / width))
            preview, preview_features = resample(
                pixels, width, height, preview_width, preview_height,
                0, 0, width / preview_width, height / preview_height, output_features
            )
            preview_path = os.path.join(previews_dir, filename)
            write_png(preview_path, preview, preview_width, preview_height)
            preview_readback = verify_png(preview_path, preview, preview_width, preview_height)
            rows.append({
                "frame": name,
                "canvas": [width, height],
                "file": f"exports/{filename}",
                "bytes": readback["file_bytes"],
                "sha256": digest(path),
                **geometry,
                "focus": label,
                "visible_marker_pixels": {
                    "top-left": feature_pixels(output_features, FEATURE_TL),
                    "frame-a": feature_pixels(output_features, FEATURE_A),
                    "frame-b": feature_pixels(output_features, FEATURE_B),
                },
                "avatar_marker_pixels": feature_pixels(output_features, FEATURE_AVATAR),
                "avatar_mask_pixels_over_marker": covered,
                "png_readback": readback,
                "preview": f"previews-320/{filename}",
                "preview_pixels": [preview_readback["width"], preview_readback["height"]],
                "preview_bytes": preview_readback["file_bytes"],
                "preview_sha256": digest(preview_path),
                "preview_png_readback": preview_readback,
            })
            previews.append({
                "file": f"previews-320/{filename}",
                "width": preview_readback["width"],
                "height": preview_readback["height"],
                "bytes": preview_readback["file_bytes"],
            })

    # Negative controls: a one-pixel-short canvas and a 16 px shifted mobile crop.
    wrong_path = os.path.join(negative_dir, "wrong-desktop-559x315.png")
    desktop_left = next(row for row in rows if row["frame"] == "desktop" and row["focus"] == "left")
    desktop_pixels, _, _ = fill_export(source, source_features, 560, 315, 0.0)
    write_png(wrong_path, [row[:559 * 4] for row in desktop_pixels], 559, 315)
    shifted_path = os.path.join(negative_dir, "shifted-mobile-756.png")
    shifted, _ = resample(source, SRC_W, SRC_H, 756, 315, 16, 0, 756 / 756, 315 / 315, source_features)
    write_png(shifted_path, shifted, 756, 315)

    def compare(path, width, height, expected_pixels):
        data = open(path, "rb").read()
        return {
            "file": os.path.relpath(path, HERE),
            "width": struct.unpack(">I", data[16:20])[0],
            "height": struct.unpack(">I", data[20:24])[0],
            "bytes": len(data),
            "matches_expected": (
                struct.unpack(">I", data[16:20])[0] == width
                and struct.unpack(">I", data[20:24])[0] == height
                and data == expected_pixels
            ),
        }

    expected_mobile = open(os.path.join(exports_dir, "mobile-756x315-left.png"), "rb").read()
    expected_desktop = open(os.path.join(exports_dir, "desktop-560x315-left.png"), "rb").read()
    negative = {
        "wrong_canvas": compare(wrong_path, 560, 315, expected_desktop),
        "shifted_mobile": compare(shifted_path, 756, 315, expected_mobile),
    }

    result = {
        "generator": "make_samples.py",
        "method": "nearest-neighbour sample of the browser crop box; the browser resampler differs",
        "png_readback_method": "reopen, validate signature and chunk CRCs, decompress IDAT, compare every decoded row with the generated canvas",
        "source": {
            "file": "source-grid-851x315.png",
            "width": SRC_W,
            "height": SRC_H,
            "area_px": SRC_W * SRC_H,
            "bytes": source_readback["file_bytes"],
            "sha256": digest(source_path),
            "readback": source_readback,
            "markers": markers,
        },
        "frames": [
            {"name": name, "width": width, "height": height, "kept_of_851": width / SRC_W}
            for name, width, height in FRAMES
        ],
        "avatar_mask": {
            "radius_px": AVATAR_RADIUS,
            "centre": [0, SRC_H],
            "box_px": AVATAR_RADIUS * AVATAR_RADIUS,
            "continuous_area_px": round(math.pi * AVATAR_RADIUS ** 2 / 4, 2),
            "pixel_count": avatar_mask_pixels,
            "source_marker_pixels": 36 * 36,
        },
        "exports": rows,
        "previews": previews,
        "negative_controls": negative,
        "desktop_left_sha256": desktop_left["sha256"],
    }
    with open(os.path.join(HERE, "results.json"), "w", encoding="utf-8") as stream:
        json.dump(result, stream, indent=2)
        stream.write("\n")
    print(json.dumps({
        "source_bytes": result["source"]["bytes"],
        "avatar_mask_pixels": avatar_mask_pixels,
        "rows": [
            {
                "id": f'{row["frame"]}-{row["focus"]}',
                "bytes": row["bytes"],
                "crop_share": round(row["crop_share"] * 100, 2),
                "avatar_marker": row["avatar_marker_pixels"],
                "covered": row["avatar_mask_pixels_over_marker"],
            }
            for row in rows
        ],
        "negative": {key: value["matches_expected"] for key, value in negative.items()},
    }, indent=2))


if __name__ == "__main__":
    main()
