#!/usr/bin/env python3

"""Convert a Matroska/EBML file into an XML representation of its
EBML element tree.

Python 3, standard library only.

You need a schema. For standard Matroska, it is in
https://raw.githubusercontent.com/ietf-wg-cellar/matroska-specification/master/ebml_matroska.xml

Written by ChatGPT and checked by Stéphane Bortzmeyer.

"""

import argparse
import datetime
import struct
import sys
import xml.etree.ElementTree as ET
from xml.sax.saxutils import escape, quoteattr


GLOBAL_IDS = {
    0xEC: "Void",
    0xBF: "CRC-32",
}

MAX_TEXT_SIZE = 1024 * 1024


class EBMLError(Exception):
    pass


def read_exact(f, size):
    data = f.read(size)
    if len(data) != size:
        raise EBMLError(
            f"Unexpected end of file at offset {f.tell() - len(data)}"
        )
    return data


def read_vint(f, is_id=False):
    """Read an EBML variable-length integer.

    For an ID, the marker bit is retained.
    For a size, the marker bit is removed.
    """
    first = read_exact(f, 1)[0]

    if first == 0:
        raise EBMLError(f"Invalid EBML VINT at offset {f.tell() - 1}")

    mask = 0x80
    length = 1

    while not first & mask:
        mask >>= 1
        length += 1

    if length > (4 if is_id else 8):
        raise EBMLError("EBML VINT is too long")

    value = first if is_id else first & (mask - 1)

    for byte in read_exact(f, length - 1):
        value = (value << 8) | byte

    if not is_id:
        unknown = (1 << (7 * length)) - 1
        if value == unknown:
            return None, length

    return value, length


def load_schema(filename):
    """Load the official EBML schema."""

    tree = ET.parse(filename)
    root = tree.getroot()

    elements = {}

    for element in root.iter():
        if element.tag.split("}")[-1] != "element":
            continue

        eid = int(element.attrib["id"], 16)
        path = element.attrib.get("path", "")
        name = element.attrib["name"]
        etype = element.attrib["type"]

        parent = path.rsplit("\\", 1)[0] or None

        elements[eid] = {
            "name": name,
            "type": etype,
            "path": path,
            "parent": parent,
        }

    return elements


def peek_element(f):
    """Read an element ID without changing the file position."""
    pos = f.tell()
    try:
        eid, _ = read_vint(f, is_id=True)
        return eid
    finally:
        f.seek(pos)


def decode_value(f, size, etype, preview):
    """Decode a leaf element without reading large binary payloads."""

    if etype == "binary":
        data = f.read(min(size, preview))
        f.seek(size - len(data), 1)
        return None, data.hex()

    if size > MAX_TEXT_SIZE:
        f.seek(size, 1)
        return None, None

    data = read_exact(f, size)

    if etype == "uinteger":
        return int.from_bytes(data, "big"), None

    if etype == "integer":
        return int.from_bytes(data, "big", signed=True), None

    if etype == "float":
        if size == 4:
            return struct.unpack(">f", data)[0], None
        if size == 8:
            return struct.unpack(">d", data)[0], None
        raise EBMLError(f"Invalid float size: {size}")

    if etype == "date":
        if size != 8:
            raise EBMLError(f"Invalid date size: {size}")

        ns = int.from_bytes(data, "big", signed=True)
        epoch = datetime.datetime(
            2001, 1, 1, tzinfo=datetime.timezone.utc
        )
        value = epoch + datetime.timedelta(microseconds=ns / 1000)
        return value.isoformat(), None

    if etype == "utf-8":
        return data.decode("utf-8", errors="replace"), None

    if etype == "string":
        return data.decode("ascii", errors="replace"), None

    return None, data.hex()


def write_element(f, out, schema, indent, end, preview):
    """Read one EBML element and write its XML representation."""

    start = f.tell()

    eid, id_length = read_vint(f, is_id=True)
    size, size_length = read_vint(f)

    data_start = f.tell()
    data_end = None if size is None else data_start + size

    info = schema.get(eid)

    name = info["name"] if info else f"Unknown_0x{eid:X}"
    etype = info["type"] if info else "unknown"

    attrs = [
        ("id", f"0x{eid:X}"),
        ("position", str(start)),
        ("size", "unknown" if size is None else str(size)),
        ("header-size", str(id_length + size_length)),
        ("type", etype),
    ]

    if size is None:
        attrs.append(("unknown-size", "true"))

    prefix = "  " * indent

    attr_text = "".join(
        f" {key}={quoteattr(value)}"
        for key, value in attrs
    )

    out.write(f"{prefix}<{name}{attr_text}")

    if etype == "master":
        out.write(">\n")

        current_path = info["path"] if info else None

        while True:
            pos = f.tell()

            if data_end is not None:
                if pos >= data_end:
                    break
                if pos > data_end:
                    raise EBMLError(
                        f"{name} exceeds its declared size"
                    )
            else:
                if pos >= end:
                    break

                # Unknown-sized master: detect the next sibling.
                if current_path is not None:
                    next_id = peek_element(f)
                    next_info = schema.get(next_id)

                    if (
                        next_info is not None
                        and next_id not in GLOBAL_IDS
                        and next_info["parent"] != current_path
                    ):
                        break

            if pos >= end:
                break

            write_element(
                f, out, schema, indent + 1,
                data_end if data_end is not None else end,
                preview
            )

        out.write(f"{prefix}</{name}>\n")

        if data_end is not None and f.tell() != data_end:
            raise EBMLError(
                f"Incorrect size while parsing {name}"
            )

    else:
        if size is None:
            raise EBMLError(
                f"Unknown-sized non-master element: {name}"
            )

        value, binary_preview = decode_value(
            f, size, etype, preview
        )

        if value is None and binary_preview is None:
            attrs.append(("value-omitted", "true"))

        if value is not None:
            out.write(">")
            out.write(escape(str(value)))
            out.write(f"</{name}>\n")

        elif binary_preview is not None:
            out.write(f" binary-preview={quoteattr(binary_preview)}")
            out.write(" />\n")

        else:
            out.write(" />\n")


def convert(input_file, output_file, schema_file, preview):
    schema = load_schema(schema_file)

    with open(input_file, "rb") as f, open(
        output_file, "w", encoding="utf-8"
    ) as out:

        out.write('<?xml version="1.0" encoding="UTF-8"?>\n')
        out.write("<EBMLDocument>\n")

        f.seek(0, 2)
        file_size = f.tell()
        f.seek(0)

        while f.tell() < file_size:
            write_element(
                f, out, schema, 1, file_size, preview
            )

        out.write("</EBMLDocument>\n")


def main():
    parser = argparse.ArgumentParser(
        description="Convert a Matroska EBML file to XML."
    )

    parser.add_argument("input", help="Input Matroska file")
    parser.add_argument("output", help="Output XML file")
    parser.add_argument(
        "-s", "--schema",
        default="ebml_matroska.xml",
        help="Matroska EBML schema"
    )
    parser.add_argument(
        "-p", "--preview",
        type=int,
        default=16,
        help="Number of binary bytes to display (default: 16)"
    )

    args = parser.parse_args()

    try:
        convert(
            args.input, args.output, args.schema, args.preview
        )
    except (EBMLError, OSError, ET.ParseError) as exc:
        sys.exit(f"Error: {exc}")

if __name__ == "__main__":
    main()
