#!/usr/bin/env python3
"""Read-only literal-text diagnostic. No rendering, repair, uploads or translation.
Python 3.10+. Use on a copy of an IDML exported by the document owner.
"""
import argparse
import json
from pathlib import Path
import re
import sys
import xml.etree.ElementTree as ET
from zipfile import BadZipFile, ZipFile

MAX_XML = 8 * 1024 * 1024
MAX_TOTAL = 64 * 1024 * 1024


def tag(node):
    return node.tag.rsplit('}', 1)[-1]


def literal_text(node):
    name = tag(node)
    if name == 'Content':
        yield ''.join(node.itertext())
        return
    if name == 'Br':
        yield '\n'
        return
    boundary = name in {'Cell', 'Footnote', 'Note', 'TextFrame'}
    if boundary:
        yield '\n'
    for child in node:
        yield from literal_text(child)
    if boundary:
        yield '\n'


def inspect(path, phrase):
    if not phrase or '\n' in phrase or '\r' in phrase:
        raise ValueError('Choose one nonempty expected phrase, without line breaks.')
    if path.stat().st_size > 200 * 1024 * 1024:
        raise ValueError('Input exceeds this diagnostic\'s 200 MiB file limit.')
    with ZipFile(path) as archive:
        entries = archive.infolist()
        if len(entries) > 10000:
            raise ValueError('Too many archive entries for this small diagnostic.')
        names = [item.filename for item in entries]
        if len(names) != len(set(names)):
            raise ValueError('Duplicate ZIP entry names: inspection is ambiguous.')
        total = 0

        def xml(name):
            nonlocal total
            info = archive.getinfo(name)
            total += info.file_size
            if info.file_size > MAX_XML or total > MAX_TOTAL:
                raise ValueError('XML exceeds the 8 MiB/part or 64 MiB/read limit.')
            raw = archive.read(info)
            probe = raw.replace(b'\x00', b'').upper()
            if b'<!DOCTYPE' in probe or b'<!ENTITY' in probe:
                raise ValueError('DTD/entity declarations are outside this diagnostic.')
            return ET.fromstring(raw)

        design = xml('designmap.xml')
        refs = [node.get('src') for node in design.iter()
                if tag(node) == 'Story' and node.get('src')]
        if not refs or len(refs) > 1000 or len(set(refs)) != len(refs):
            raise ValueError('Expected 1–1000 distinct story references in designmap.xml.')
        rows = []
        for name in refs:
            if not re.fullmatch(r'Stories/[^/\\]+\.xml', name) or name not in names:
                raise ValueError('Missing or unsupported story reference: ' + name)
            root = xml(name)
            stories = [node for node in root.iter() if tag(node) == 'Story' and node.get('Self')]
            if len(stories) != 1:
                raise ValueError('Expected exactly one story with a Self identifier in ' + name)
            text = ''.join(literal_text(stories[0]))
            rows.append({'part': name, 'story': stories[0].get('Self'),
                         'literalCharacters': len(text),
                         'phraseMatches': text.count(phrase)})
        return {'scope': 'Literal Content in designmap-referenced story parts only',
                'matchingStories': sum(row['phraseMatches'] > 0 for row in rows),
                'stories': rows,
                'layoutChecked': False,
                'warning': 'Presence is not visible placement or approval. A zero match is not proof of deletion.'}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('idml', type=Path)
    parser.add_argument('--contains', required=True, help='An exact, case-sensitive expected target phrase')
    args = parser.parse_args()
    try:
        result = inspect(args.idml, args.contains)
    except (OSError, BadZipFile, KeyError, ValueError, RuntimeError, ET.ParseError) as error:
        print('Cannot inspect this input: ' + str(error), file=sys.stderr)
        return 2
    print(json.dumps(result, ensure_ascii=False, indent=2))
    return 0


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