#!/usr/bin/env python3
"""Rebuild an SEC XML form document from beatandraise.com's JSON for it.

The JSON mirrors the XML:
  - an element with only text is a string ("" for an empty element)
  - an element with attributes or children is an object keyed by child name
    (namespace prefixes dropped); attributes are "@name" keys ("@xsi:nil" etc.
    for XML Schema instance attributes); text is "#text"
  - a name that repeats among siblings is an array, in document order
  - "#order" lists the child names (and "#text") in document order wherever an
    object has more than one kind of content; follow it to rebuild the order

Namespaces, comments and the whitespace at the ends of text runs are not kept.
Everything else is: beatandraise.com stores a document only after checking
that this rebuild matches the original.

Usage:
    python3 sec_json_to_xml.py ROOT < doc.json > doc.xml
    # ROOT is the document's root element, e.g. ownershipDocument, edgarSubmission

or from Python:
    from sec_json_to_xml import json_to_xml
    xml_text = json_to_xml("ownershipDocument", doc)

Standard library only. Public domain.
"""
import json
import sys
from xml.sax.saxutils import escape, quoteattr

XSI = "http://www.w3.org/2001/XMLSchema-instance"


def _as_list(v):
    if v is None:
        return []
    if isinstance(v, list):
        return v
    return [v]


def _element(out, name, v):
    if isinstance(v, str):
        if v == "":
            out.append("<%s/>" % name)
        else:
            out.append("<%s>%s</%s>" % (name, escape(v), name))
        return
    if not isinstance(v, dict):
        raise ValueError("<%s>: unexpected JSON value %r" % (name, type(v).__name__))

    attrs = sorted(k for k in v if k.startswith("@"))
    kids = [k for k in v if not k.startswith("@") and k not in ("#text", "#order")]
    tag = [name]
    if any(k.startswith("@xsi:") for k in attrs):
        tag.append('xmlns:xsi="%s"' % XSI)
    for k in attrs:
        tag.append("%s=%s" % (k[1:], quoteattr(v[k])))

    texts = _as_list(v.get("#text"))
    if "#order" in v:
        order = _as_list(v["#order"])
    else:
        if len(kids) > 1 or (kids and texts):
            raise ValueError("<%s>: more than one kind of content and no #order" % name)
        order = ["#text"] * len(texts) + [kids[0]] * len(_as_list(v[kids[0]])) if kids else ["#text"] * len(texts)

    if not order:
        out.append("<%s/>" % " ".join(tag))
        return
    out.append("<%s>" % " ".join(tag))
    used = {}
    for n in order:
        items = texts if n == "#text" else _as_list(v.get(n))
        i = used.get(n, 0)
        if i >= len(items):
            raise ValueError("<%s>: #order names %s more often than it occurs" % (name, n))
        used[n] = i + 1
        if n == "#text":
            out.append(escape(items[i]))
        else:
            _element(out, n, items[i])
    for k in kids + ["#text"]:
        if used.get(k, 0) != len(_as_list(v.get(k))):
            raise ValueError("<%s>: %s is not fully placed by #order" % (name, k))
    out.append("</%s>" % name)


def json_to_xml(root, doc):
    """Return the XML text for a document's JSON, given its root element name."""
    out = ['<?xml version="1.0" encoding="UTF-8"?>\n']
    _element(out, root, doc)
    return "".join(out)


if __name__ == "__main__":
    if len(sys.argv) != 2:
        sys.exit("usage: sec_json_to_xml.py ROOT < doc.json > doc.xml")
    sys.stdout.write(json_to_xml(sys.argv[1], json.load(sys.stdin)))
