import os
import sys
import fnmatch
import argparse
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional, Tuple, Any
import tempfile
import shutil


OUTPUT_MODE_FILE = "file"
OUTPUT_MODE_DIR = "dir"


@dataclass
class ConverterSpec:
    input_ext: str
    output_ext: str
    converter: Callable[..., Any]
    description: str = ""
    ignore_temp_prefixes: Tuple[str, ...] = ()
    output_mode: str = OUTPUT_MODE_FILE
    options: Dict[str, Any] = field(default_factory=dict)

    def __post_init__(self):
        self.input_ext = normalize_ext(self.input_ext)
        self.output_ext = normalize_ext(self.output_ext)
        if self.output_mode not in (OUTPUT_MODE_FILE, OUTPUT_MODE_DIR):
            raise ValueError(f"Invalid output_mode: {self.output_mode}")


class ConverterRegistry:
    def __init__(self):
        self._specs: Dict[Tuple[str, str], ConverterSpec] = {}
        self._import_errors: Dict[str, Exception] = {}

    def register(self, spec: ConverterSpec):
        key = (spec.input_ext, spec.output_ext)
        self._specs[key] = spec

    def set_import_error(self, module_name: str, error: Exception):
        self._import_errors[module_name] = error

    def get(self, input_ext: str, output_ext: str) -> Optional[ConverterSpec]:
        return self._specs.get((normalize_ext(input_ext), normalize_ext(output_ext)))

    def has(self, input_ext: str, output_ext: str) -> bool:
        return self.get(input_ext, output_ext) is not None

    def list_specs(self):
        return list(self._specs.values())

    def list_output_exts(self):
        return sorted({spec.output_ext for spec in self._specs.values()})

    def print_available_converters(self):
        print("Available converters:")
        for spec in sorted(self._specs.values(), key=lambda s: (s.output_ext, s.input_ext, s.output_mode)):
            desc = f" ({spec.description})" if spec.description else ""
            mode = f" [{spec.output_mode}]" if spec.output_mode != OUTPUT_MODE_FILE else ""
            print(f"  {spec.input_ext} -> {spec.output_ext}{mode}{desc}")

        if self._import_errors:
            print("\nUnavailable modules:")
            for module_name, err in self._import_errors.items():
                print(f"  {module_name}: {err}")


def normalize_ext(ext: str) -> str:
    ext = ext.strip().lower()
    if not ext.startswith("."):
        ext = "." + ext
    return ext


def build_output_path(input_path: str, output_ext: str, output_mode: str = OUTPUT_MODE_FILE) -> str:
    stem = os.path.splitext(input_path)[0]
    if output_mode == OUTPUT_MODE_DIR:
        return stem + normalize_ext(output_ext) + "s"
    return stem + normalize_ext(output_ext)


def should_ignore_file(filename: str, spec: ConverterSpec) -> bool:
    return any(filename.startswith(prefix) for prefix in spec.ignore_temp_prefixes)


def path_exists_for_mode(path: str, output_mode: str) -> bool:
    if output_mode == OUTPUT_MODE_DIR:
        return os.path.isdir(path) and any(True for _ in os.scandir(path))
    return os.path.exists(path)


def get_latest_mtime(path: str, output_mode: str) -> Optional[float]:
    if output_mode == OUTPUT_MODE_FILE:
        if not os.path.exists(path):
            return None
        return os.path.getmtime(path)

    if not os.path.isdir(path):
        return None

    latest = os.path.getmtime(path)
    found_any = False
    for dirpath, dirnames, filenames in os.walk(path):
        for name in dirnames + filenames:
            found_any = True
            candidate = os.path.join(dirpath, name)
            try:
                latest = max(latest, os.path.getmtime(candidate))
            except OSError:
                pass
    return latest if found_any else None


def should_convert(input_path: str, output_path: str, output_mode: str, update: bool, overwrite: bool) -> Tuple[bool, str]:
    if overwrite:
        return True, "overwrite=1"

    exists = path_exists_for_mode(output_path, output_mode)
    if not exists:
        return True, "output does not exist"

    if not update:
        return False, "output exists and update=0"

    output_mtime = get_latest_mtime(output_path, output_mode)
    if output_mtime is None:
        return True, "output exists but is empty/incomplete"

    input_mtime = os.path.getmtime(input_path)
    if input_mtime > output_mtime:
        return True, "source is newer than output"

    return False, "up to date"


def parse_target_patterns(target_text: Optional[str]) -> Optional[List[str]]:
    if not target_text:
        return None
    patterns = [p.strip() for p in target_text.split(";") if p.strip()]
    return patterns or None


def matches_target(filename: str, target_patterns: Optional[List[str]]) -> bool:
    if not target_patterns:
        return True

    lower_name = filename.lower()
    for pattern in target_patterns:
        if fnmatch.fnmatch(lower_name, pattern.lower()):
            return True
    return False


# ---------- importable wrappers ----------
def wrap_simple(converter: Callable[[str, Optional[str]], Any]) -> Callable[..., bool]:
    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
        result = converter(input_path, output_path)
        return bool(result is not None)
    return _wrapped


def wrap_image_to_dir(converter: Callable[..., Any], rename_func: Optional[Callable[[str], Any]] = None) -> Callable[..., bool]:
    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
        os.makedirs(output_path, exist_ok=True)
        converter(input_path, output_path, "png")
        if rename_func is not None:
            rename_func(output_path)
        return True
    return _wrapped


def wrap_docx_to_png(docx_to_pdf: Callable[[str, str], Any], pdf_to_images: Callable[..., Any]) -> Callable[..., bool]:
    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
        os.makedirs(output_path, exist_ok=True)
        tmp_pdf = os.path.splitext(input_path)[0] + ".pdf"
        docx_to_pdf(input_path, tmp_pdf)
        pdf_to_images(tmp_pdf, output_path, "png")
        return True
    return _wrapped


def wrap_md_with_images(convert_func: Callable[..., Any]) -> Callable[..., bool]:
    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
        image_dir = os.path.splitext(output_path)[0] + ".images"
        result = convert_func(input_path, output_path, image_dir)
        return bool(result is not None or os.path.exists(output_path))
    return _wrapped


def wrap_pdf2md(convert_func: Callable[[str, Optional[str]], Any]) -> Callable[..., bool]:
    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
        result = convert_func(input_path, output_path)
        return bool(result is not None or os.path.exists(output_path))
    return _wrapped


def wrap_pandoc(target: str, default_template: Optional[str] = None) -> Callable[..., bool]:
    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
        from pandoc import convert_md, find_template

        pandoc_path = kwargs.get("pandoc_path") or "pandoc"
        template = kwargs.get("template") or default_template
        if template:
            template = find_template(template)

        return bool(convert_md(
            infile_md=input_path,
            target=target,
            pandoc_path=pandoc_path,
            outfile=output_path,
            template=template,
            toc=bool(kwargs.get("toc", 0)),
            css=kwargs.get("css"),
            mathml=bool(kwargs.get("mathml", False)),
            no_yaml=bool(kwargs.get("no_yaml", False)),
            verbose=bool(kwargs.get("verbose", False)),
            smart_conversion=bool(kwargs.get("smart_conversion", False)),
        ))
    return _wrapped


def wrap_ipynb_json_to_md(convert_func: Callable[..., Any]) -> Callable[..., bool]:
    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
        result = convert_func(input_path, output_path)
        return bool(result is not None or os.path.exists(output_path))
    return _wrapped


def wrap_ipynb_json_to_pdf(ipynb_or_json_to_md: Callable[..., Any], md_to_pdf: Callable[..., Any]) -> Callable[..., bool]:
    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
        tmp_dir = tempfile.mkdtemp(prefix="convert_pipeline_")
        tmp_md = os.path.join(tmp_dir, os.path.splitext(os.path.basename(input_path))[0] + ".md")
        try:
            md_result = ipynb_or_json_to_md(input_path, tmp_md)
            if md_result is False or not os.path.exists(tmp_md):
                return False
            pdf_result = md_to_pdf(tmp_md, output_path)
            return bool(pdf_result is not None or os.path.exists(output_path))
        finally:
            shutil.rmtree(tmp_dir, ignore_errors=True)
    return _wrapped


# ---------- registry ----------
def safe_import_registry(registry: ConverterRegistry):
    try:
        from docx2pdf import docx_to_pdf
    except Exception as e:
        registry.set_import_error("docx2pdf", e)
    else:
        registry.register(ConverterSpec(
            input_ext=".docx",
            output_ext=".pdf",
            converter=wrap_simple(docx_to_pdf),
            description="Word to PDF",
        ))

    try:
        from xlsx2pdf import xlsx_to_pdf
    except Exception as e:
        registry.set_import_error("xlsx2pdf", e)
    else:
        registry.register(ConverterSpec(
            input_ext=".xlsx",
            output_ext=".pdf",
            converter=wrap_simple(xlsx_to_pdf),
            description="Excel to PDF",
            ignore_temp_prefixes=("~$",),
        ))

    try:
        from pptx2pdf import pptx_to_pdf
    except Exception as e:
        registry.set_import_error("pptx2pdf", e)
    else:
        registry.register(ConverterSpec(
            input_ext=".pptx",
            output_ext=".pdf",
            converter=wrap_simple(pptx_to_pdf),
            description="PowerPoint to PDF",
        ))

    try:
        from pptx2pdf_with_notes_importable import pptx_to_pdf_with_notes
    except Exception as e:
        registry.set_import_error("pptx2pdf_with_notes_importable", e)
    else:
        registry.register(ConverterSpec(
            input_ext=".pptx",
            output_ext=".notes.pdf",
            converter=wrap_simple(pptx_to_pdf_with_notes),
            description="PowerPoint to PDF with speaker notes",
        ))

    try:
        from html2pdf_importable import html_to_pdf
    except Exception as e:
        registry.set_import_error("html2pdf_importable", e)
    else:
        registry.register(ConverterSpec(
            input_ext=".html",
            output_ext=".pdf",
            converter=wrap_simple(html_to_pdf),
            description="HTML to PDF",
        ))
        registry.register(ConverterSpec(
            input_ext=".htm",
            output_ext=".pdf",
            converter=wrap_simple(html_to_pdf),
            description="HTML to PDF",
        ))

    try:
        from md2pdf_importable import md_to_pdf
    except Exception as e:
        registry.set_import_error("md2pdf_importable", e)
    else:
        registry.register(ConverterSpec(
            input_ext=".md",
            output_ext=".pdf",
            converter=wrap_simple(md_to_pdf),
            description="Markdown to PDF",
        ))

    try:
        from txt2pdf_importable import txt_to_pdf
    except Exception as e:
        registry.set_import_error("txt2pdf_importable", e)
    else:
        registry.register(ConverterSpec(
            input_ext=".txt",
            output_ext=".pdf",
            converter=wrap_simple(txt_to_pdf),
            description="Text to PDF",
        ))

    try:
        from img2pdf_importable import image_to_pdf
    except Exception as e:
        registry.set_import_error("img2pdf_importable", e)
    else:
        for ext in (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp"):
            registry.register(ConverterSpec(
                input_ext=ext,
                output_ext=".pdf",
                converter=wrap_simple(image_to_pdf),
                description="Image to PDF",
            ))

    # Markdown converters with formula/image extraction
    try:
        import docx2md
    except Exception as e:
        registry.set_import_error("docx2md", e)
    else:
        registry.register(ConverterSpec(
            input_ext=".docx",
            output_ext=".md",
            converter=wrap_md_with_images(docx2md.convert),
            description="Word to Markdown with equations/images",
        ))

    try:
        import pptx2md2
    except Exception as e:
        registry.set_import_error("pptx2md2", e)
    else:
        registry.register(ConverterSpec(
            input_ext=".pptx",
            output_ext=".md",
            converter=wrap_md_with_images(pptx2md2.extract_content_to_markdown),
            description="PowerPoint to Markdown with equations/images",
        ))

    try:
        import pdf2md
    except Exception as e:
        registry.set_import_error("pdf2md", e)
    else:
        registry.register(ConverterSpec(
            input_ext=".pdf",
            output_ext=".md",
            converter=wrap_pdf2md(pdf2md.convert),
            description="PDF to Markdown",
        ))

    try:
        import pptx2img
    except Exception as e:
        registry.set_import_error("pptx2img", e)
    else:
        registry.register(ConverterSpec(
            input_ext=".pptx",
            output_ext=".png",
            converter=wrap_image_to_dir(pptx2img.export_img, rename_func=getattr(pptx2img, "rename_img", None)),
            description="PowerPoint to PNG images",
            output_mode=OUTPUT_MODE_DIR,
        ))

    try:
        import docx2img
    except Exception as e:
        registry.set_import_error("docx2img", e)
    else:
        registry.register(ConverterSpec(
            input_ext=".docx",
            output_ext=".png",
            converter=wrap_docx_to_png(docx2img.convert_word_to_pdf, docx2img.convert_pdf_to_images),
            description="Word to PNG images",
            output_mode=OUTPUT_MODE_DIR,
        ))

    try:
        import pdf2img
    except Exception as e:
        registry.set_import_error("pdf2img", e)
    else:
        registry.register(ConverterSpec(
            input_ext=".pdf",
            output_ext=".png",
            converter=wrap_image_to_dir(pdf2img.convert_pdf_to_images),
            description="PDF to PNG images",
            output_mode=OUTPUT_MODE_DIR,
        ))

    try:
        import ipynb2md
    except Exception as e:
        registry.set_import_error("ipynb2md", e)
    else:
        registry.register(ConverterSpec(
            input_ext=".ipynb",
            output_ext=".md",
            converter=wrap_ipynb_json_to_md(ipynb2md.convert_ipynb_to_md),
            description="Jupyter Notebook to Markdown",
        ))
        registry.register(ConverterSpec(
            input_ext=".json",
            output_ext=".md",
            converter=wrap_ipynb_json_to_md(ipynb2md.convert_json_to_md),
            description="JSON to Markdown",
        ))

        try:
            from md2pdf_importable import md_to_pdf as md_to_pdf_for_pipeline
        except Exception:
            try:
                from md2pdf import md_to_pdf as md_to_pdf_for_pipeline
            except Exception as e:
                registry.set_import_error("md2pdf pipeline backend", e)
            else:
                registry.register(ConverterSpec(
                    input_ext=".ipynb",
                    output_ext=".pdf",
                    converter=wrap_ipynb_json_to_pdf(ipynb2md.convert_ipynb_to_md, md_to_pdf_for_pipeline),
                    description="Jupyter Notebook to PDF via Markdown pipeline",
                ))
                registry.register(ConverterSpec(
                    input_ext=".json",
                    output_ext=".pdf",
                    converter=wrap_ipynb_json_to_pdf(ipynb2md.convert_json_to_md, md_to_pdf_for_pipeline),
                    description="JSON to PDF via Markdown pipeline",
                ))
        else:
            registry.register(ConverterSpec(
                input_ext=".ipynb",
                output_ext=".pdf",
                converter=wrap_ipynb_json_to_pdf(ipynb2md.convert_ipynb_to_md, md_to_pdf_for_pipeline),
                description="Jupyter Notebook to PDF via Markdown pipeline",
            ))
            registry.register(ConverterSpec(
                input_ext=".json",
                output_ext=".pdf",
                converter=wrap_ipynb_json_to_pdf(ipynb2md.convert_json_to_md, md_to_pdf_for_pipeline),
                description="JSON to PDF via Markdown pipeline",
            ))

    # pandoc.py 由来の追加変換
    try:
        import pandoc as pandoc_module
        _ = pandoc_module.find_pandoc
        _ = pandoc_module.convert_md
    except Exception as e:
        registry.set_import_error("pandoc", e)
    else:
        registry.register(ConverterSpec(
            input_ext=".md",
            output_ext=".docx",
            converter=wrap_pandoc("docx"),
            description="Markdown to Word via Pandoc",
        ))
        registry.register(ConverterSpec(
            input_ext=".md",
            output_ext=".pptx",
            converter=wrap_pandoc("pptx"),
            description="Markdown to PowerPoint via Pandoc",
        ))
        registry.register(ConverterSpec(
            input_ext=".md",
            output_ext=".html",
            converter=wrap_pandoc("html"),
            description="Markdown to HTML via Pandoc",
        ))


def convert_file(
    input_path: str,
    output_ext: str,
    registry: ConverterRegistry,
    update: bool = True,
    overwrite: bool = False,
    converter_kwargs: Optional[Dict[str, Any]] = None,
) -> bool:
    input_path = os.path.abspath(input_path)
    input_ext = normalize_ext(os.path.splitext(input_path)[1])
    converter_kwargs = converter_kwargs or {}

    spec = registry.get(input_ext, output_ext)
    if spec is None:
        print(f"Skipping unsupported file: '{input_path}'")
        return False

    filename = os.path.basename(input_path)
    if should_ignore_file(filename, spec):
        print(f"Skipping temporary file: '{input_path}'")
        return False

    output_path = build_output_path(input_path, output_ext, spec.output_mode)
    do_convert, reason = should_convert(
        input_path=input_path,
        output_path=output_path,
        output_mode=spec.output_mode,
        update=update,
        overwrite=overwrite,
    )

    if do_convert:
        print(f"Converting: '{input_path}' -> '{output_path}' ({reason})")
        kwargs = dict(spec.options)
        kwargs.update(converter_kwargs)
        result = spec.converter(input_path, output_path, **kwargs)
        return bool(result)

    print(f"Skipping: '{input_path}' ({reason})")
    return False


def walk_and_convert(
    root_dir: str,
    output_ext: str,
    registry: ConverterRegistry,
    max_level: int = -1,
    update: bool = True,
    overwrite: bool = False,
    target_patterns: Optional[List[str]] = None,
    converter_kwargs: Optional[Dict[str, Any]] = None,
):
    if not os.path.isdir(root_dir):
        print(f"Error: Root directory '{root_dir}' does not exist.")
        return 0, 0

    root_dir_abs = os.path.abspath(root_dir)
    output_ext = normalize_ext(output_ext)

    print(f"Searching in '{root_dir_abs}'")
    print(f"Target output format: {output_ext}")
    print(f"Max level: {max_level}")
    print(f"update: {int(update)}")
    print(f"overwrite: {int(overwrite)}")
    if converter_kwargs:
        for key, value in sorted(converter_kwargs.items()):
            if value not in (None, ""):
                print(f"{key}: {value}")
    if target_patterns:
        print(f"Target patterns: {';'.join(target_patterns)}")
    else:
        print("Target patterns: (all matching input files)")
    print("")

    converted = 0
    scanned = 0

    for dirpath, dirnames, filenames in os.walk(root_dir_abs):
        current_level = dirpath.count(os.sep) - root_dir_abs.count(os.sep)

        if max_level != -1 and current_level >= max_level:
            del dirnames[:]

        for filename in filenames:
            if not matches_target(filename, target_patterns):
                continue

            input_path = os.path.join(dirpath, filename)
            input_ext = normalize_ext(os.path.splitext(filename)[1])

            if registry.has(input_ext, output_ext):
                scanned += 1
                if convert_file(
                    input_path=input_path,
                    output_ext=output_ext,
                    registry=registry,
                    update=update,
                    overwrite=overwrite,
                    converter_kwargs=converter_kwargs,
                ):
                    converted += 1

    print("\nConversion process finished.")
    print(f"Scanned supported files: {scanned}")
    print(f"Converted files: {converted}")
    return scanned, converted


def parse_args():
    parser = argparse.ArgumentParser(
        description="Convert files in a directory tree using registered converters."
    )
    parser.add_argument("root_dir", nargs="?", default=".", help="Root directory to scan")
    parser.add_argument("--infile", default="", help="Convert only this file and skip recursive search when specified")
    parser.add_argument("output_ext", nargs="?", default=".pdf", help="Target output extension, e.g. pdf or .notes.pdf")
    parser.add_argument("--max_level", nargs="?", default="-1", help="Maximum recursion depth (-1 means unlimited)")
    parser.add_argument("--target", default=None, help='Filename patterns separated by ";" , e.g. --target="*.pptx;*.docx"')
    parser.add_argument("--update", type=int, choices=[0, 1], default=1, help="If 0, skip when output already exists even if source is newer")
    parser.add_argument("--overwrite", type=int, choices=[0, 1], default=0, help="If 1, always convert regardless of timestamps")
    parser.add_argument("--pandoc_path", default=None, help="Path to pandoc executable for Pandoc-based conversions")
    parser.add_argument("--template_docx", default=None, help="Pandoc reference doc/template for md -> docx")
    parser.add_argument("--template_pptx", default=None, help="Pandoc reference doc/template for md -> pptx")
    parser.add_argument("--css", default=None, help="CSS for md -> html via Pandoc")
    parser.add_argument("--toc", type=int, choices=[0, 1], default=0, help="Enable table of contents for Pandoc docx output")
    parser.add_argument("--mathml", action="store_true", help="Use MathML for md -> html via Pandoc")
    parser.add_argument("--no_yaml", action="store_true", help="Pass no_yaml option to Pandoc conversion")
    parser.add_argument("--verbose", action="store_true", help="Verbose mode for Pandoc conversion")
    parser.add_argument("--smart_conversion", type=int, choices=[0, 1], default=0, help="Enable smart_conversion in pandoc.py")
    parser.add_argument("--list", action="store_true", help="List available converters and exit")
    parser.add_argument("--no-pause", action="store_true", help="Do not wait for ENTER before exit")
    return parser.parse_args()


def build_converter_kwargs(args, output_ext: str) -> Dict[str, Any]:
    output_ext = normalize_ext(output_ext)
    kwargs: Dict[str, Any] = {}

    if args.pandoc_path:
        kwargs["pandoc_path"] = args.pandoc_path
    if output_ext == ".docx" and args.template_docx:
        kwargs["template"] = args.template_docx
    elif output_ext == ".pptx" and args.template_pptx:
        kwargs["template"] = args.template_pptx
    elif output_ext == ".html" and args.css:
        kwargs["css"] = args.css

    kwargs["toc"] = bool(args.toc)
    kwargs["mathml"] = bool(args.mathml)
    kwargs["no_yaml"] = bool(args.no_yaml)
    kwargs["verbose"] = bool(args.verbose)
    kwargs["smart_conversion"] = bool(args.smart_conversion)
    return kwargs


def main():
    args = parse_args()

    try:
        max_level = int(args.max_level)
    except ValueError:
        print(f"Error: Invalid max_level '{args.max_level}'.")
        return 1

    registry = ConverterRegistry()
    safe_import_registry(registry)

    if args.list:
        registry.print_available_converters()
        return 0

    output_ext = normalize_ext(args.output_ext)
    available_outputs = registry.list_output_exts()

    if output_ext not in available_outputs:
        print(f"Error: No converters registered for output format '{output_ext}'.")
        if available_outputs:
            print("Registered output formats:", ", ".join(available_outputs))
        registry.print_available_converters()
        return 1

    target_patterns = parse_target_patterns(args.target)
    converter_kwargs = build_converter_kwargs(args, output_ext)

    if args.infile:
        infile = os.path.abspath(args.infile)
        if not os.path.exists(infile):
            print(f"Error: infile '{args.infile}' does not exist.")
            return 1
        print(f"Single-file mode: {infile}")
        converted = convert_file(
            input_path=infile,
            output_ext=output_ext,
            registry=registry,
            update=bool(args.update),
            overwrite=bool(args.overwrite),
            converter_kwargs=converter_kwargs,
        )
        print("\nConversion process finished.")
        print("Scanned supported files: 1" if registry.has(os.path.splitext(infile)[1], output_ext) else "Scanned supported files: 0")
        print(f"Converted files: {1 if converted else 0}")
        return 0 if converted or registry.has(os.path.splitext(infile)[1], output_ext) else 1

    walk_and_convert(
        root_dir=args.root_dir,
        output_ext=output_ext,
        registry=registry,
        max_level=max_level,
        update=bool(args.update),
        overwrite=bool(args.overwrite),
        target_patterns=target_patterns,
        converter_kwargs=converter_kwargs,
    )
    return 0


if __name__ == "__main__":
    rc = main()
    print("\nProgram execution completed.")
    if "--no-pause" not in sys.argv:
        input("\nPress ENTER to terminate>>\n")
    raise SystemExit(rc)
