#!/usr/bin/env python3
"""Standalone PNG transaction transport; no account access or KMCoin submission.

Requires Pillow (python -m pip install Pillow).
Encode: python minecraft_skin_tx.py encode original.png encoded.png
Decode: python minecraft_skin_tx.py decode encoded.png
See MINECRAFT_SKIN_TX.md for the fixed layout and limitations.
"""

import argparse
from io import BytesIO
from pathlib import Path
import sys

from PIL import Image, UnidentifiedImageError


SKIN_SIZE = (64, 64)
MAX_PNG_BYTES = 1024 * 1024
# Right leg, BASE layer; coordinates start at the PNG's top-left (0, 0).
# Read top/bottom faces first, then the four side faces, each row left-to-right.
# UV reference: https://github.com/bs-community/skinview3d/blob/master/src/model.ts
LEG_PIXELS = tuple(
    [(x, y) for y in range(16, 20) for x in range(4, 12)]
    + [(x, y) for y in range(20, 32) for x in range(0, 16)]
)
CAPACITY_BYTES = len(LEG_PIXELS) * 3  # 224 pixels, 672 bytes INCLUDING the ';'.


class SkinPayloadError(ValueError):
    """An unsupported skin or invalid transport payload (not tx authorization)."""


def payload_bytes(text: str) -> bytes:
    if text == ";" or text.count(";") != 1 or not text.endswith(";"):
        raise SkinPayloadError("Payload must be nonempty with exactly one ';', at the end.")
    if any(ord(char) < 32 or 127 <= ord(char) < 160 for char in text):
        raise SkinPayloadError("Payload must not contain NUL, newlines, or control characters.")
    try:
        data = text.encode("utf-8", errors="strict")
    except UnicodeError as exc:
        raise SkinPayloadError("Payload is not valid UTF-8 text.") from exc
    if len(data) > CAPACITY_BYTES:
        raise SkinPayloadError(
            f"Payload needs {len(data)} bytes; the right leg holds {CAPACITY_BYTES}. "
            "Nothing was truncated."
        )
    return data


def load_skin(path: Path) -> Image.Image:
    # Bound input before decoding and accept PNG by its contents, not extension.
    with path.open("rb") as stream:
        raw = stream.read(MAX_PNG_BYTES + 1)
    if len(raw) > MAX_PNG_BYTES:
        raise SkinPayloadError("Skin PNG exceeds the 1 MiB input limit.")
    try:
        with Image.open(BytesIO(raw), formats=["PNG"]) as image:
            if image.size != SKIN_SIZE:
                raise SkinPayloadError("Only 64x64 Java skin PNGs are supported.")
            if getattr(image, "is_animated", False):
                raise SkinPayloadError("Animated PNGs are not supported.")
            image.verify()
        # Pillow requires reopening after verify(). Work only on a detached copy.
        with Image.open(BytesIO(raw), formats=["PNG"]) as image:
            return image.convert("RGBA")
    except (UnidentifiedImageError, OSError, SyntaxError, Image.DecompressionBombError) as exc:
        raise SkinPayloadError(f"Cannot read a valid skin PNG: {exc}") from exc


def require_rgba_skin(image: Image.Image) -> None:
    if image.size != SKIN_SIZE or image.mode != "RGBA":
        raise SkinPayloadError("Expected a 64x64 RGBA skin image.")


def encode_image(image: Image.Image, text: str) -> Image.Image:
    require_rgba_skin(image)
    data = payload_bytes(text)
    result = image.copy()
    for offset in range(0, len(data), 3):
        position = LEG_PIXELS[offset // 3]
        channels = list(result.getpixel(position)[:3])
        chunk = data[offset:offset + 3]
        # Preserve even the unused channels in the final, partially used pixel.
        channels[:len(chunk)] = chunk
        result.putpixel(position, (*channels, 255))
    return result


def decode_image(image: Image.Image) -> str:
    require_rgba_skin(image)
    data = bytearray()
    for position in LEG_PIXELS:
        red, green, blue, alpha = image.getpixel(position)
        if alpha != 255:
            raise SkinPayloadError("Pixels carrying the payload must be fully opaque.")
        for channel in (red, green, blue):
            data.append(channel)
            if channel == ord(";"):
                # Everything after the terminator is artwork, not payload.
                # This also accepts our original zero-padded probe images.
                try:
                    text = bytes(data).decode("utf-8", errors="strict")
                except UnicodeError as exc:
                    raise SkinPayloadError("Encoded payload is not valid UTF-8.") from exc
                payload_bytes(text)
                return text
    raise SkinPayloadError("No terminating ';' found in the right-leg region.")


def encode_file(source: Path, destination: Path, text: str) -> int:
    if source.resolve() == destination.resolve():
        raise SkinPayloadError("Use a separate output file; the original skin is never overwritten.")
    if destination.exists():
        raise SkinPayloadError("Output already exists. Choose a new output filename.")
    with load_skin(source) as original:
        with encode_image(original, text) as encoded:
            buffer = BytesIO()
            encoded.save(buffer, format="PNG")
            png = buffer.getvalue()
    # Verify the serialized PNG before creating any output file.
    with Image.open(BytesIO(png)) as saved:
        with saved.convert("RGBA") as rgba:
            if decode_image(rgba) != text:
                raise SkinPayloadError("PNG round-trip verification failed.")
    with destination.open("xb") as stream:
        stream.write(png)
    return len(payload_bytes(text))


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    commands = parser.add_subparsers(dest="command", required=True)
    encode = commands.add_parser("encode", help="write a separate skin PNG; prompt for text")
    encode.add_argument("source", type=Path)
    encode.add_argument("output", type=Path)
    encode.add_argument("--text", help="literal payload instead of the interactive prompt")
    decode = commands.add_parser("decode", help="print the exact decoded text; no tx submission")
    decode.add_argument("skin", type=Path)
    args = parser.parse_args()
    try:
        if args.command == "encode":
            text = args.text
            if text is None:
                text = input("Full transaction ending in ; (transport only) > ")
            size = encode_file(args.source, args.output, text)
            print(f"[SKIN] Encoded and locally verified {size}/{CAPACITY_BYTES} bytes.")
            print(f"[SKIN] Saved {args.output}. Nothing uploaded or submitted to KMCoin.")
        else:
            with load_skin(args.skin) as skin:
                text = decode_image(skin)
            # UTF-8 bytes, with no added newline; suitable for an exact byte comparison.
            sys.stdout.buffer.write(text.encode("utf-8"))
    except (OSError, ValueError, EOFError) as exc:
        print(f"[SKIN ERROR] {exc}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
