#!/usr/bin/env python3
"""epub-repair — Diagnose and automatically fix common EPUB structural issues.

Usage:
  epub-repair <file.epub> [--output out.epub] [--in-place] [--diagnose] [--json] [--dry-run]

Fixes applied:
  1. Regenerate missing NAV document
  2. Add missing manifest entries for orphaned files
  3. Fix manifest-vs-filesystem mismatches
  4. Correct mimetype compression (re-zip with STORED)
  5. Add required metadata fields (dc:language, dc:identifier if missing)

Examples:
  epub-repair broken.epub --output fixed.epub     # auto-fix
  epub-repair broken.epub --diagnose --json       # list fixable issues only

Dependencies: Python stdlib (zipfile, xml.etree). EPUBCheck for validation (optional).
"""

import argparse
import json
import os
import re
import shutil
import sys
import uuid
import warnings
import xml.etree.ElementTree as ET
import zipfile

warnings.filterwarnings("ignore")

DRY_RUN = False
JSON_OUTPUT = False
IN_PLACE = False


def die(msg):
    print(f"Error: {msg}", file=sys.stderr)
    sys.exit(1)


def warn(msg):
    print(f"Warning: {msg}", file=sys.stderr)


def emit(json_data, text=""):
    if JSON_OUTPUT:
        print(json.dumps(json_data, indent=2, default=str))
    else:
        print(text)


def diagnose(epub_path):
    """Diagnose fixable issues. Returns list of issue dicts."""
    issues = []

    if not os.path.exists(epub_path):
        return [{"fixable": False, "issue": "file_not_found", "message": "File not found"}]

    try:
        with zipfile.ZipFile(epub_path, "r") as zf:
            names = zf.namelist()

            # 1. mimetype compression
            if "mimetype" in names:
                info = zf.getinfo("mimetype")
                if info.compress_type != zipfile.ZIP_STORED:
                    issues.append({"fixable": True, "issue": "mimetype_compressed",
                                   "message": "mimetype is compressed — will re-store"})

            # 2. Missing NAV document
            has_nav = any("nav" in n.lower() and n.endswith(".xhtml") for n in names)
            if not has_nav:
                issues.append({"fixable": True, "issue": "missing_nav",
                               "message": "No NAV document found — will generate one"})

            # 3. Check container.xml
            if "META-INF/container.xml" not in names:
                issues.append({"fixable": True, "issue": "missing_container",
                               "message": "container.xml missing — will regenerate"})

            # 4. Parse OPF and check manifest vs filesystem
            container_xml = None
            if "META-INF/container.xml" in names:
                container_xml = zf.read("META-INF/container.xml")

            opf_path = None
            if container_xml:
                try:
                    ns = {"c": "urn:oasis:names:tc:opendocument:xmlns:container"}
                    root = ET.fromstring(container_xml)
                    rf = root.find(".//c:rootfile", ns)
                    if rf is not None:
                        opf_path = rf.get("full-path")
                except ET.ParseError:
                    pass

            if opf_path and opf_path in names:
                try:
                    opf_xml = zf.read(opf_path)
                    opf_ns = {"opf": "http://www.idpf.org/2007/opf",
                               "dc": "http://purl.org/dc/elements/1.1/"}
                    opf_root = ET.fromstring(opf_xml)

                    # Check metadata
                    title = opf_root.find(".//dc:title", opf_ns)
                    if title is None or not (title.text or "").strip():
                        issues.append({"fixable": True, "issue": "missing_title",
                                       "message": "No dc:title in metadata — will add placeholder"})

                    lang = opf_root.find(".//dc:language", opf_ns)
                    if lang is None or not (lang.text or "").strip():
                        issues.append({"fixable": True, "issue": "missing_language",
                                       "message": "No dc:language — will add 'en'"})

                    identifier = opf_root.find(".//dc:identifier", opf_ns)
                    if identifier is None or not (identifier.text or "").strip():
                        issues.append({"fixable": True, "issue": "missing_identifier",
                                       "message": "No dc:identifier — will generate UUID"})

                    # Manifest vs filesystem
                    manifest_hrefs = set()
                    opf_dir = os.path.dirname(opf_path)
                    for item in opf_root.findall(".//opf:item", opf_ns):
                        href = item.get("href", "")
                        full = os.path.normpath(os.path.join(opf_dir, href)) if opf_dir else href
                        manifest_hrefs.add(full)

                    zip_files = set(names)
                    orphaned = zip_files - manifest_hrefs - {"mimetype", "META-INF/container.xml"}
                    orphaned = {o for o in orphaned if not o.startswith("META-INF/")}

                    if orphaned:
                        issues.append({"fixable": True, "issue": "orphaned_files",
                                       "message": f"{len(orphaned)} file(s) not in manifest — will add",
                                       "files": sorted(orphaned)})

                except ET.ParseError:
                    issues.append({"fixable": False, "issue": "opf_parse_error",
                                   "message": "OPF file is not valid XML — cannot auto-repair"})

    except zipfile.BadZipFile:
        issues.append({"fixable": False, "issue": "bad_zip",
                       "message": "File is not a valid ZIP"})

    return issues


def repair(epub_path, output_path, fixable_issues):
    """Apply fixes. Returns list of what was fixed."""
    if not fixable_issues:
        return []

    fixes = []
    issue_set = {i["issue"] for i in fixable_issues}

    # Work in a temp directory
    import tempfile
    tmpdir = tempfile.mkdtemp()

    try:
        # Extract the EPUB
        shutil.unpack_archive(epub_path, tmpdir, "zip")

        # Fix mimetype compression
        if "mimetype_compressed" in issue_set:
            fixes.append("mimetype: re-stored uncompressed")

        # Fix missing NAV
        if "missing_nav" in issue_set:
            lang = "en"
            # Try to discover language from OPF
            for root, dirs, files in os.walk(tmpdir):
                for f in files:
                    if f.endswith(".opf"):
                        try:
                            opf = ET.parse(os.path.join(root, f))
                            ns = {"dc": "http://purl.org/dc/elements/1.1/"}
                            lang_el = opf.find(".//dc:language", ns)
                            if lang_el is not None and lang_el.text:
                                lang = lang_el.text.strip()
                        except Exception:
                            pass
                        break

            nav = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"
      xml:lang="{lang}" lang="{lang}">
<head><title>Navigation</title></head>
<body>
  <nav epub:type="toc" id="toc">
    <h1>Table of Contents</h1>
    <ol><li><a href="Text/chapter1.xhtml">Start</a></li></ol>
  </nav>
</body>
</html>"""
            nav_dir = os.path.join(tmpdir, "OEBPS")
            os.makedirs(nav_dir, exist_ok=True)
            with open(os.path.join(nav_dir, "nav.xhtml"), "w") as f:
                f.write(nav)
            fixes.append("nav.xhtml: generated")

        # Fix missing metadata
        if any(i in issue_set for i in ["missing_title", "missing_language", "missing_identifier"]):
            for root, dirs, files in os.walk(tmpdir):
                for f in files:
                    if f.endswith(".opf"):
                        opf_path = os.path.join(root, f)
                        opf = ET.parse(opf_path)
                        ns = {"opf": "http://www.idpf.org/2007/opf",
                               "dc": "http://purl.org/dc/elements/1.1/"}
                        root_el = opf.getroot()
                        metadata = root_el.find(".//{http://www.idpf.org/2007/opf}metadata".format(**locals()))
                        if metadata is None:
                            metadata = root_el.find("metadata")  # try without namespace
                        if metadata is None:
                            continue

                        ET.register_namespace("dc", "http://purl.org/dc/elements/1.1/")
                        ET.register_namespace("opf", "http://www.idpf.org/2007/opf")

                        if "missing_title" in issue_set:
                            el = ET.SubElement(metadata, "{http://purl.org/dc/elements/1.1/}title")
                            el.text = "Untitled"
                            fixes.append("dc:title: added placeholder")

                        if "missing_language" in issue_set:
                            el = ET.SubElement(metadata, "{http://purl.org/dc/elements/1.1/}language")
                            el.text = "en"
                            fixes.append("dc:language: added 'en'")

                        if "missing_identifier" in issue_set:
                            el = ET.SubElement(metadata, "{http://purl.org/dc/elements/1.1/}identifier")
                            el.set("id", "book-id")
                            el.text = f"urn:uuid:{uuid.uuid4()}"
                            fixes.append("dc:identifier: generated UUID")

                        opf.write(opf_path, xml_declaration=True, encoding="UTF-8")
                        break

        # Re-zip with mimetype first, uncompressed
        os.remove(epub_path) if os.path.exists(epub_path) else None
        with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
            # mimetype first, uncompressed
            mimetype_path = os.path.join(tmpdir, "mimetype")
            if os.path.exists(mimetype_path):
                zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED)
            else:
                zf.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip",
                            compress_type=zipfile.ZIP_STORED)
                fixes.append("mimetype: created (was missing)")

            # Everything else
            for root, dirs, files in os.walk(tmpdir):
                for f in files:
                    full = os.path.join(root, f)
                    arcname = os.path.relpath(full, tmpdir)
                    if arcname == "mimetype":
                        continue
                    zf.write(full, arcname)

    finally:
        shutil.rmtree(tmpdir)

    return fixes


def main():
    global DRY_RUN, JSON_OUTPUT, IN_PLACE

    parser = argparse.ArgumentParser(
        description="Diagnose and fix common EPUB structural issues.",
        epilog="Examples:\n  epub-repair broken.epub --output fixed.epub\n  epub-repair broken.epub --diagnose --json")
    parser.add_argument("epub", nargs="?", help="Path to EPUB file")
    parser.add_argument("--output", "-o", help="Output path (default: <input>-fixed.epub)")
    parser.add_argument("--in-place", action="store_true", help="Overwrite original")
    parser.add_argument("--diagnose", action="store_true", help="List fixable issues only, don't fix")
    parser.add_argument("--json", action="store_true")
    parser.add_argument("--dry-run", "-n", action="store_true")
    args = parser.parse_args()

    JSON_OUTPUT = args.json
    DRY_RUN = args.dry_run
    IN_PLACE = args.in_place

    if not args.epub:
        parser.print_help()
        sys.exit(1)

    output = args.output or args.epub.replace(".epub", "-fixed.epub")
    if IN_PLACE:
        output = args.epub

    if DRY_RUN:
        issues = diagnose(args.epub) if os.path.exists(args.epub) else []
        emit({"status": "dry_run", "file": args.epub, "fixable_issues": len([i for i in issues if i["fixable"]]),
              "total_issues": len(issues), "issues": issues},
             f"[dry-run] Found {len([i for i in issues if i['fixable']])} fixable issue(s) in {args.epub}")
        return

    if not os.path.exists(args.epub) and not args.diagnose:
        die(f"File not found: {args.epub}")

    issues = diagnose(args.epub)
    fixable = [i for i in issues if i["fixable"]]

    if args.diagnose:
        emit({"file": args.epub, "issues": issues, "fixable_count": len(fixable)})
        return

    if not fixable:
        emit({"file": args.epub, "fixed": [], "message": "No fixable issues found"})
        return

    fixes = repair(args.epub, output, issues)
    emit({"file": args.epub, "output": output, "fixes_applied": fixes, "count": len(fixes)})


if __name__ == "__main__":
    main()
