"""PageFuse CLI entry point."""

import os
import re
import shutil
import tempfile
from pathlib import Path

import time

import resource
import sys
import threading

import click
from concurrent.futures import ThreadPoolExecutor, as_completed
from rich.console import Console
from rich.table import Table

from src.cli import license as license_mod
from src.core import convert as convert_core
from src.core import pdf as pdf_core

console = Console()

SUPPORTED_FORMATS = {".pdf"} | convert_core.LIBREOFFICE_FORMATS | convert_core.IMAGE_FORMATS | convert_core.MARKDOWN_FORMATS

# Matches valid page specs: "all", "1", "1-3", "1,3,5-8", etc.
_PAGES_RE = re.compile(r"^(all|\d+(-\d+)?(,\d+(-\d+)?)*)$")

# Files at or above this size get a visible size label in the loading line.
_LARGE_FILE_THRESHOLD = 50 * 1_000_000  # 50 MB


def _format_size(size_bytes: int) -> str:
    """Return a human-readable file size string."""
    if size_bytes >= 1_000_000_000:
        return f"{size_bytes / 1_000_000_000:.1f} GB"
    if size_bytes >= 1_000_000:
        return f"{size_bytes / 1_000_000:.1f} MB"
    if size_bytes >= 1_000:
        return f"{size_bytes / 1_000:.0f} KB"
    return f"{size_bytes} B"


def _mem_mb() -> str:
    """Return current process RSS in MB as a compact string."""
    try:
        kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
        mb = kb // 1024 if sys.platform != "darwin" else kb // (1024 * 1024)
        return f"{mb}MB"
    except Exception:
        return ""


def _size_hint(file_path: str) -> str:
    """Return a formatted size string if the file is large, else empty string."""
    try:
        size = Path(file_path).stat().st_size
    except OSError:
        return ""
    if size < _LARGE_FILE_THRESHOLD:
        return ""
    return f"  [yellow]{_format_size(size)}[/yellow]"


_METADATA_KEYS = {"title", "author", "subject"}


def _parse_split_config(path: str) -> tuple[str, list[dict]]:
    """Parse a split .fuse config file.

    Returns (source_path, list of {output, pages} dicts).

    Format:
        from: report.pdf

        output: cover.pdf       1
        output: body.pdf        2-10
        output: appendix.docx  all
    """
    source = None
    outputs = []
    metadata = {}
    with open(path) as f:
        for raw in f:
            line = raw.strip()
            if not line or line.startswith("#"):
                continue
            if line.startswith("from:"):
                val = line[len("from:"):].strip()
                if source:
                    raise click.ClickException(
                        f"Split config must have exactly one 'from:' line. Found:\n"
                        f"  {source}\n"
                        f"  {val}"
                    )
                source = val
                # Detect "from: file.pdf all" — page spec not valid for source.
                parts = source.rsplit(" ", 1)
                if len(parts) == 2 and _PAGES_RE.match(parts[1]):
                    raise click.ClickException(
                        f"'from:' does not accept a page spec — "
                        f"split always reads the whole source file.\n"
                        f"Did you mean: from: {parts[0]}"
                    )
                continue
            if line.startswith("output:"):
                rest = line[len("output:"):].strip()
                tokens = rest.split()
                if len(tokens) >= 2 and _PAGES_RE.match(tokens[-1]):
                    out_path = " ".join(tokens[:-1])
                    pages = tokens[-1]
                else:
                    out_path = rest
                    pages = "all"
                outputs.append({"output": out_path, "pages": pages})
                continue
            for key in _METADATA_KEYS:
                if line.startswith(f"{key}:"):
                    metadata[key] = line[len(key) + 1:].strip()
                    break
    if not source:
        raise click.ClickException("Split config missing required 'from:' line.")
    if not outputs:
        raise click.ClickException("Split config has no output lines.")
    return source, outputs, metadata


def _parse_fuse_config(path: str) -> tuple[list[str], list[dict], dict]:
    """Parse a .fuse config file.

    Returns (outputs, sources, metadata) where:
      outputs  — list of output file paths (from 'output:' lines)
      sources  — list of {file, pages} dicts (from 'from:' lines)
      metadata — dict with optional keys: title, author, subject
    """
    outputs = []
    sources = []
    metadata = {}
    with open(path) as f:
        for raw in f:
            line = raw.strip()
            if not line or line.startswith("#"):
                continue
            if line.startswith("output:"):
                outputs.append(line[len("output:"):].strip())
                continue
            if line.startswith("from:"):
                rest = line[len("from:"):].strip()
                tokens = rest.split()
                if len(tokens) >= 2 and _PAGES_RE.match(tokens[-1]):
                    file_path = " ".join(tokens[:-1])
                    pages = tokens[-1]
                else:
                    file_path = rest
                    pages = "all"
                sources.append({"file": file_path, "pages": pages})
                continue
            # Metadata lines: title: / author: / subject:
            for key in _METADATA_KEYS:
                if line.startswith(f"{key}:"):
                    metadata[key] = line[len(key) + 1:].strip()
                    break
    if not outputs:
        raise click.ClickException("Config missing required 'output:' line.")
    if not sources:
        raise click.ClickException("Config has no source files.")
    return outputs, sources, metadata


def _estimate_resources(
    parsed_sources: list[tuple[str, str]],
    outputs: list[str],
) -> None:
    """Estimate disk and memory requirements and warn if resources may be tight.

    Prints a summary and warns if available disk space looks insufficient.
    Never blocks — estimates are rough.
    """
    import shutil as _shutil

    # --- disk: estimate intermediate (assembled) PDF size ---
    # Source files are converted to PDFs before assembly.  Each format expands
    # differently when rendered to PDF, so we use per-format multipliers rather
    # than just summing raw file sizes.
    _TO_PDF_MULT = {
        ".pdf":      1.0,   # passthrough — no change
        ".docx":     3.0,   # Word embeds fonts + renders layout
        ".doc":      3.0,
        ".odt":      2.5,
        ".pptx":     5.0,   # slides often image-heavy
        ".ppt":      5.0,
        ".odp":      4.0,
        ".html":     2.0,
        ".md":       1.2,   # text-only, small
        ".png":      1.2,   # img2pdf is lossless
        ".jpg":      1.1,
        ".jpeg":     1.1,
        ".tiff":     1.3,
    }
    est_pdf_bytes = 0
    for file_path, _ in parsed_sources:
        try:
            size = Path(file_path).stat().st_size
            mult = _TO_PDF_MULT.get(Path(file_path).suffix.lower(), 2.0)
            est_pdf_bytes += int(size * mult)
        except OSError:
            pass

    total_input_bytes = sum(
        Path(fp).stat().st_size for fp, _ in parsed_sources
        if Path(fp).exists()
    )

    # --- disk: estimate output size per format from assembled PDF size ---
    # HTML:   base64 PNG at 2× scale ≈ 8× PDF
    # Images: rendered PNGs ≈ 6× PDF  (no base64 overhead)
    # DOCX/ODT: LO re-encodes text, usually smaller ≈ 0.5× PDF
    # PDF:    lossless copy ≈ 1× PDF
    _PDF_TO_OUT_MULT = {
        ".pdf":  1.0,
        ".html": 8.0,
        ".png":  6.0,
        ".jpg":  3.0,
        ".jpeg": 3.0,
        ".tiff": 6.0,
        ".docx": 0.5,
        ".odt":  0.5,
        ".odp":  0.5,
    }
    estimated_output_bytes = sum(
        int(est_pdf_bytes * _PDF_TO_OUT_MULT.get(Path(o).suffix.lower(), 1.0))
        for o in outputs
    )
    has_html = any(Path(o).suffix.lower() == ".html" for o in outputs)
    has_image = any(Path(o).suffix.lower() in convert_core.IMAGE_OUTPUT_FORMATS for o in outputs)

    # --- disk: check available space on output directory ---
    output_dir = Path(outputs[0]).parent or Path(".")
    try:
        free_bytes = _shutil.disk_usage(output_dir).free
    except OSError:
        free_bytes = None

    # --- memory: estimate peak usage ---
    lo_conversions = sum(
        1 for fp, _ in parsed_sources
        if Path(fp).suffix.lower() in convert_core.LIBREOFFICE_FORMATS
    )
    # LibreOffice: ~300 MB each, up to 4 concurrent; HTML rendering: ~150 MB/page peak
    mem_lo_mb = min(lo_conversions, 4) * 300
    mem_render_mb = 150 if (has_html or has_image) else 0
    est_mem_mb = 100 + mem_lo_mb + mem_render_mb

    # --- print summary ---
    console.print(
        f"  [dim]Input[/dim]   {_format_size(total_input_bytes)}  "
        f"[dim]Est. output[/dim]  {_format_size(estimated_output_bytes)}  "
        f"[dim]Est. peak memory[/dim]  ~{est_mem_mb} MB"
    )

    if free_bytes is not None:
        needed = estimated_output_bytes * 2  # 2× safety margin (tmp + final)
        if needed > free_bytes:
            console.print(
                f"  [yellow]⚠ Low disk space:[/yellow] need ~{_format_size(needed)}, "
                f"only {_format_size(free_bytes)} free on {output_dir}"
            )


_SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"

# ANSI colour codes (no rich dependency for in-place row updates)
_A = {
    "green":  "\033[32m",
    "cyan":   "\033[36m",
    "red":    "\033[31m",
    "dim":    "\033[2m",
    "reset":  "\033[0m",
}


class _TaskDisplay:
    """In-place task table using targeted ANSI cursor moves.

    Prints the full table once, then rewrites only the rows that change.
    No full-screen clear/redraw → no flashing even over VNC/slow terminals.
    """

    def __init__(self, tasks: list[dict], t0: float):
        self.tasks = tasks
        self.t0 = t0
        self._n = len(tasks)
        self._lock = threading.Lock()
        self._stop = threading.Event()
        self._thread: threading.Thread | None = None
        self._peak_mem_mb = 0

    def _render(self, task: dict) -> str:
        state = task["state"]
        now = time.monotonic()
        if state == "done":
            icon = f"{_A['green']}✓{_A['reset']}"
            t = f"{task['elapsed']:.1f}s"
        elif state == "active":
            frame = _SPINNER_FRAMES[int(now * 10) % len(_SPINNER_FRAMES)]
            icon = f"{_A['cyan']}{frame}{_A['reset']}"
            t = f"{now - self.t0:.1f}s"
        elif state == "error":
            icon = f"{_A['red']}✗{_A['reset']}"
            t = f"{task.get('elapsed') or 0:.1f}s"
        else:
            icon = f"{_A['dim']}·{_A['reset']}"
            t = ""
        label  = task["label"]
        detail = task.get("detail", "")
        size   = task.get("size", "")
        mem    = task.get("mem", "")
        return f" {icon}  {label:<32}{_A['dim']}  {detail:<8}  {size:>8}  {mem:>6}  {t:>6}{_A['reset']}"

    def _write_row(self, idx: int) -> None:
        rows_up = self._n - idx
        row = self._render(self.tasks[idx])
        with self._lock:
            # Save cursor → move up to target row → clear + rewrite → restore cursor.
            # Using save/restore avoids cumulative drift from relative up/down moves.
            sys.stdout.write(f"\033[s\033[{rows_up}A\033[2K\r{row}\033[u")
            sys.stdout.flush()

    def start(self) -> None:
        """Print the initial table (all rows pending)."""
        header = (
            f" {_A['dim']}{' '}  {'Task':<32}  {'Detail':<8}  {'Size':>8}  {'Mem':>6}  {'Time':>6}{_A['reset']}\n"
            f" {_A['dim']}{'-'}  {'-'*32}  {'-'*8}  {'-'*8}  {'-'*6}  {'-'*6}{_A['reset']}"
        )
        sys.stdout.write("\n" + header + "\n")
        for task in self.tasks:
            sys.stdout.write(self._render(task) + "\n")
        sys.stdout.flush()
        self._thread = threading.Thread(target=self._spin, daemon=True)
        self._thread.start()

    def _spin(self) -> None:
        """Background thread: animate spinner on active rows at ~10 fps."""
        while not self._stop.wait(0.1):
            for i, task in enumerate(self.tasks):
                if task["state"] == "active":
                    self._write_row(i)

    def update(self, idx: int, state: str, elapsed: float | None = None, size: str | None = None, size_bytes: int = 0, mem: str | None = None) -> None:
        """Change a task's state and immediately redraw its row."""
        self.tasks[idx]["state"] = state
        if elapsed is not None:
            self.tasks[idx]["elapsed"] = elapsed
        if size is not None:
            self.tasks[idx]["size"] = size
            self.tasks[idx]["size_bytes"] = size_bytes
        if mem is not None:
            self.tasks[idx]["mem"] = mem
            try:
                self._peak_mem_mb = max(self._peak_mem_mb, int(mem.replace("MB", "")))
            except (ValueError, AttributeError):
                pass
        self._write_row(idx)

    def stop(self) -> None:
        """Stop the spinner thread, print totals row, leave cursor below table."""
        self._stop.set()
        if self._thread:
            self._thread.join(timeout=0.5)

        total_bytes = sum(t.get("size_bytes", 0) for t in self.tasks if t.get("output_task"))
        total_time  = time.monotonic() - self.t0
        total_size  = _format_size(total_bytes) if total_bytes else ""
        peak_mem    = f"{self._peak_mem_mb}MB" if self._peak_mem_mb else ""
        sep = f" {_A['dim']}{'-'}  {'-'*32}  {'-'*8}  {'-'*8}  {'-'*6}  {'-'*6}{_A['reset']}"
        footer = (
            f" {' '}  {_A['dim']}{'Total':<32}  {'':8}  {total_size:>8}  {peak_mem:>6}  {total_time:>5.1f}s{_A['reset']}"
        )
        sys.stdout.write(sep + "\n" + footer + "\n\n")
        sys.stdout.flush()


def _run_split(source: str, out_specs: list[dict], tmp_dir: str, metadata: dict | None = None) -> None:
    """Load source, then extract pages into each output — with a live task table."""
    t0 = time.monotonic()
    error_holder: list[str] = []

    def _file_size(path: str) -> tuple[str, int]:
        try:
            b = Path(path).stat().st_size
            return _format_size(b), b
        except OSError:
            return "", 0

    sz_str, sz_bytes = _file_size(source)
    load_task = {"label": f"Loading  {Path(source).name}", "detail": "all", "state": "pending",
                 "elapsed": None, "size": sz_str, "size_bytes": sz_bytes, "mem": "", "output_task": False}
    output_tasks = [
        {"label": f"Write  {Path(e['output']).name}", "detail": e["pages"], "state": "pending",
         "elapsed": None, "size": "", "size_bytes": 0, "mem": "", "output_task": True}
        for e in out_specs
    ]
    all_tasks = [load_task] + output_tasks
    display = _TaskDisplay(all_tasks, t0)
    display.start()

    # --- load source ---
    display.update(0, "active")
    t_load = time.monotonic()
    try:
        input_pdf = _resolve_source(source, "all", tmp_dir)["pdf_path"]
    except Exception as e:
        display.update(0, "error", time.monotonic() - t_load)
        display.stop()
        raise click.ClickException(str(e))
    display.update(0, "done", time.monotonic() - t_load, mem=_mem_mb())

    # --- parallel outputs ---
    finals = [None] * len(out_specs)

    def _write(idx, entry):
        display.update(1 + idx, "active")
        t_start = time.monotonic()
        out_path = entry["output"]
        page_spec = entry["pages"]
        out_suffix = Path(out_path).suffix.lower()
        slice_pdf = os.path.join(tmp_dir, f"slice_{idx}.pdf")
        slice_meta = {"title": Path(out_path).stem} | (metadata or {})
        pdf_core.assemble([{"pdf_path": input_pdf, "pages": page_spec}], slice_pdf, metadata=slice_meta)
        if out_suffix == ".pdf":
            shutil.copy(slice_pdf, out_path)
            final = out_path
        else:
            final = convert_core.pdf_to_format(slice_pdf, out_path)
        sz, sz_b = _file_size(final)
        display.update(1 + idx, "done", time.monotonic() - t_start, size=sz, size_bytes=sz_b, mem=_mem_mb())
        return idx, final

    with ThreadPoolExecutor(max_workers=4) as pool:
        futures = {pool.submit(_write, i, e): i for i, e in enumerate(out_specs)}
        for future in as_completed(futures):
            i = futures[future]
            try:
                idx, final = future.result()
                finals[idx] = final
            except Exception as e:
                display.update(1 + i, "error", time.monotonic() - t0)
                error_holder.append(str(e))

    display.stop()

    if error_holder:
        raise click.ClickException(error_holder[0])

    for entry, final in zip(out_specs, finals):
        if final != entry["output"]:
            console.print(f"[yellow]Note:[/yellow] Multi-page image output saved as {final}")


def _run_assembly(
    parsed_sources: list[tuple[str, str]],
    outputs: list[str],
    metadata: dict,
    tmp_dir: str,
) -> list[str]:
    """Convert sources (parallel), assemble, then write outputs (parallel).

    Shows a live task table: pending (·), active (spinner), done (✓), error (✗).
    Returns final output paths in the same order as outputs.
    """
    t0 = time.monotonic()
    finals = [None] * len(outputs)
    error_holder: list[str] = []

    def _input_label(file_path: str) -> str:
        return f"Loading  {Path(file_path).name}"

    def _file_size(path: str) -> tuple[str, int]:
        try:
            b = Path(path).stat().st_size
            return _format_size(b), b
        except OSError:
            return "", 0

    input_tasks = [
        {"label": _input_label(fp), "detail": ps, "state": "pending", "elapsed": None,
         "size": _file_size(fp)[0], "size_bytes": _file_size(fp)[1], "mem": "", "output_task": False}
        for fp, ps in parsed_sources
    ]
    assemble_task = {"label": "Assemble", "detail": "", "state": "pending", "elapsed": None, "size": "", "size_bytes": 0, "mem": "", "output_task": False}
    output_tasks = [
        {"label": f"Write  {Path(out).name}", "detail": Path(out).suffix, "state": "pending", "elapsed": None, "size": "", "size_bytes": 0, "mem": "", "output_task": True}
        for out in outputs
    ]
    all_tasks = input_tasks + [assemble_task] + output_tasks

    display = _TaskDisplay(all_tasks, t0)
    display.start()

    try:
        # --- parallel inputs ---
        resolved = [None] * len(parsed_sources)

        def _load(idx, file_path, page_spec):
            display.update(idx, "active")
            t_start = time.monotonic()
            result = _resolve_source(file_path, page_spec, tmp_dir)
            display.update(idx, "done", time.monotonic() - t_start, mem=_mem_mb())
            return idx, result

        with ThreadPoolExecutor(max_workers=4) as pool:
            futures = {
                pool.submit(_load, i, fp, ps): i
                for i, (fp, ps) in enumerate(parsed_sources)
            }
            for future in as_completed(futures):
                i = futures[future]
                try:
                    idx, result = future.result()
                    resolved[idx] = result
                except Exception as e:
                    display.update(i, "error", time.monotonic() - t0)
                    error_holder.append(str(e))

        if error_holder:
            raise click.ClickException(error_holder[0])

        # --- assemble ---
        asm_idx = len(input_tasks)
        display.update(asm_idx, "active")
        t_asm = time.monotonic()
        title = metadata.get("title") or Path(outputs[0]).stem
        effective_meta = {"title": title} | metadata
        tmp_pdf = os.path.join(tmp_dir, "assembled.pdf")
        try:
            pdf_core.assemble(resolved, tmp_pdf, metadata=effective_meta)
        except (RuntimeError, ValueError) as e:
            display.update(asm_idx, "error", time.monotonic() - t_asm)
            raise click.ClickException(str(e))
        display.update(asm_idx, "done", time.monotonic() - t_asm, mem=_mem_mb())

        # --- parallel outputs ---
        out_offset = len(input_tasks) + 1

        def _write(idx, output):
            display.update(out_offset + idx, "active")
            t_start = time.monotonic()
            if Path(output).suffix.lower() == ".pdf":
                shutil.copy(tmp_pdf, output)
                final = output
            else:
                final = convert_core.pdf_to_format(tmp_pdf, output)
            sz, sz_bytes = _file_size(final)
            display.update(out_offset + idx, "done", time.monotonic() - t_start, size=sz, size_bytes=sz_bytes, mem=_mem_mb())
            return idx, final

        with ThreadPoolExecutor(max_workers=4) as pool:
            futures = {
                pool.submit(_write, i, out): i
                for i, out in enumerate(outputs)
            }
            for future in as_completed(futures):
                i = futures[future]
                try:
                    idx, final = future.result()
                    finals[idx] = final
                except Exception as e:
                    display.update(out_offset + i, "error", time.monotonic() - t0)
                    error_holder.append(str(e))

        if error_holder:
            raise click.ClickException(error_holder[0])

    finally:
        display.stop()

    console.print(f"[green]Done[/green]")
    return finals


SUPPORTED_OUTPUT_FORMATS = convert_core.OUTPUT_FORMATS


def _validate(sources: list[tuple[str, str]], outputs: list[str]) -> None:
    """Pre-flight check — validate all inputs and outputs before any work starts.

    Batches all errors and raises a single ClickException if anything is wrong.
    """
    errors = []

    for file_path, _ in sources:
        p = Path(file_path)
        if not p.exists():
            errors.append(f"  File not found: '{file_path}'")
        elif not p.is_file():
            errors.append(f"  Not a file: '{file_path}'")
        elif p.suffix.lower() not in SUPPORTED_FORMATS:
            errors.append(
                f"  Unsupported input format '{p.suffix}' for '{file_path}'. "
                f"Supported: {', '.join(sorted(SUPPORTED_FORMATS))}"
            )

    for output in outputs:
        suffix = Path(output).suffix.lower()
        if not suffix:
            errors.append(f"  Output '{output}' has no file extension.")
        elif suffix not in SUPPORTED_OUTPUT_FORMATS:
            errors.append(
                f"  Unsupported output format '{suffix}' for '{output}'. "
                f"Supported: {', '.join(sorted(SUPPORTED_OUTPUT_FORMATS))}"
            )

    # Presentation sources cannot produce word-processor outputs.
    has_presentation = any(
        Path(fp).suffix.lower() in _PRESENTATION_FORMATS for fp, _ in sources
    )
    if has_presentation:
        bad_out = [o for o in outputs if Path(o).suffix.lower() in _WORD_OUTPUT_FORMATS]
        for o in bad_out:
            errors.append(
                f"  Cannot output Word format '{Path(o).suffix}' when a presentation "
                f"(.pptx/.ppt/.odp) is among the sources. "
                f"Run 'pagefuse info <source>' to see supported output formats."
            )

    # Word-processor sources cannot produce presentation outputs.
    has_word = any(
        Path(fp).suffix.lower() in _WORD_FORMATS for fp, _ in sources
    )
    if has_word:
        bad_out = [o for o in outputs if Path(o).suffix.lower() in _PRESENTATION_OUTPUT_FORMATS]
        for o in bad_out:
            errors.append(
                f"  Cannot output presentation format '{Path(o).suffix}' when a word-processor "
                f"(.docx/.doc/.odt) is among the sources. "
                f"Run 'pagefuse info <source>' to see supported output formats."
            )

    if errors:
        raise click.ClickException("Error(s):\n" + "\n".join(errors))


def _validate_sources(entries: list[tuple[str, str]]) -> None:
    """Backwards-compatible wrapper — validates inputs only."""
    _validate(entries, [])


def _resolve_source(file_path: str, page_spec: str, tmp_dir: str) -> dict:
    """Convert non-PDF formats to a temp PDF and return an assembly-ready dict."""
    suffix = Path(file_path).suffix.lower()
    if suffix not in SUPPORTED_FORMATS:
        # Check if the path looks like "file.ext pagespec" (space instead of colon).
        parts = file_path.rsplit(" ", 1)
        if len(parts) == 2 and _PAGES_RE.match(parts[1]) and Path(parts[0]).exists():
            raise click.ClickException(
                f"Unexpected page spec in '{file_path}'. "
                f"Use a colon to separate file and pages: '{parts[0]}:{parts[1]}'"
            )
        raise click.ClickException(
            f"Unsupported format '{suffix}'. Supported: {', '.join(sorted(SUPPORTED_FORMATS))}"
        )
    if suffix == ".pdf":
        pdf_path = file_path
    elif suffix in convert_core.LIBREOFFICE_FORMATS:
        pdf_path = convert_core.libreoffice_to_pdf(file_path, tmp_dir)
    elif suffix in convert_core.IMAGE_FORMATS:
        pdf_path = convert_core.image_to_pdf(file_path, tmp_dir)
    elif suffix in convert_core.MARKDOWN_FORMATS:
        pdf_path = convert_core.markdown_to_pdf(file_path, tmp_dir)
    return {"pdf_path": pdf_path, "pages": page_spec, "original_path": file_path}


class _HelpOnErrorCommand(click.Command):
    """Command subclass that prints the command's help on usage errors."""

    def make_context(self, info_name, args, **kwargs):
        try:
            return super().make_context(info_name, args, **kwargs)
        except click.UsageError as e:
            ctx = click.Context(self, info_name=info_name)
            click.echo(ctx.get_help())
            click.echo(f"\nError: {e.format_message()}", err=True)
            sys.exit(e.exit_code)


class _CategorizedGroup(click.Group):
    """Click Group that displays commands grouped by category in --help."""

    command_class = _HelpOnErrorCommand

    _CATEGORIES = [
        ("Document operations", ["assemble", "split"]),
        ("Utilities",           ["info", "init", "help"]),
        ("License",             ["activate", "deactivate", "status", "refresh"]),
    ]

    def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
        cat_map = {cmd: cat for cat, cmds in self._CATEGORIES for cmd in cmds}
        all_commands = self.list_commands(ctx)

        # Build ordered groups, preserving category order.
        groups: dict[str, list[str]] = {}
        for name in all_commands:
            cat = cat_map.get(name, "Other")
            groups.setdefault(cat, []).append(name)

        category_order = [cat for cat, _ in self._CATEGORIES] + ["Other"]
        for cat in category_order:
            cmds = groups.get(cat)
            if not cmds:
                continue
            rows = []
            for name in cmds:
                cmd = self.get_command(ctx, name)
                if cmd and not cmd.hidden:
                    rows.append((name, cmd.get_short_help_str(limit=formatter.width)))
            if not rows:
                continue
            with formatter.section(cat):
                formatter.write_dl(rows)


@click.group(cls=_CategorizedGroup)
@click.version_option(version="0.1.0", prog_name="pagefuse")
@click.option(
    "--lo-timeout", default=300, show_default=True, type=int, metavar="SECS",
    help="LibreOffice conversion timeout in seconds. There is no hard limit — increase for large/complex files.",
)
@click.pass_context
def cli(ctx, lo_timeout):
    """PageFuse — your pages, your way.

    Run 'pagefuse help <command>' for detailed help on any command.
    """
    convert_core.LO_TIMEOUT = lo_timeout
    if ctx.invoked_subcommand in ("activate", "deactivate"):
        return
    allowed, message = license_mod.check()
    if message:
        console.print(f"[yellow]{message}[/yellow]\n")
    if not allowed:
        raise SystemExit(1)


@cli.command()
@click.argument("key")
def activate(key):
    """Activate PageFuse with a license key."""
    console.print("Activating license...")
    try:
        body = license_mod.activate(key)
        email = body.get("meta", {}).get("customer_email", "")
        expires_at = body.get("license_key", {}).get("expires_at", "")
        msg = f"[green]✓[/green] License activated{f' for {email}' if email else ''}."
        if expires_at:
            msg += f"  Expires: {expires_at[:10]}"
        console.print(msg)
    except RuntimeError as e:
        raise click.ClickException(str(e))


@cli.command()
def deactivate():
    """Deactivate the license on this machine, freeing up an activation slot."""
    console.print("Deactivating license on this machine...")
    try:
        license_mod.deactivate()
        console.print("[green]✓[/green] License deactivated. You can now activate on another machine.")
    except RuntimeError as e:
        raise click.ClickException(str(e))


@cli.command()
def status():
    """Show local license status without contacting the server.

    Reads the cached license file and displays your license key, email,
    expiry date, and days remaining — entirely offline.

    \b
    pagefuse status
    """
    st = license_mod.status()

    if st["type"] == "none":
        console.print("  [dim]No license or trial found.[/dim]")
        console.print(f"  Purchase at {license_mod.PURCHASE_URL} then run: pagefuse activate <key>")
        return

    if st["type"] == "trial":
        expires = st["expires_at"][:10] if st.get("expires_at") else "unknown"
        days = st["days_left"]
        day_word = "day" if days == 1 else "days"
        console.print(
            f"  [dim]Type[/dim]        Trial\n"
            f"  [dim]Days left[/dim]   {days} {day_word}\n"
            f"  [dim]Expires[/dim]     {expires}"
        )
        return

    # Licensed
    expires = st.get("expires_at", "")
    expires_display = expires[:10] if expires else "—"
    dtx = st.get("days_to_expiry")
    days_display = f"{dtx} days" if dtx is not None else "—"
    valid_display = "[green]Active[/green]" if st.get("valid") else "[red]Invalid[/red]"
    last_val = st.get("last_validated", "")
    last_display = last_val[:10] if last_val else "—"

    console.print(
        f"  [dim]Type[/dim]            Licensed\n"
        f"  [dim]Key[/dim]             {st['key']}\n"
        f"  [dim]Email[/dim]           {st.get('email') or '—'}\n"
        f"  [dim]Status[/dim]          {valid_display}\n"
        f"  [dim]Last validated[/dim]  {last_display}\n"
        f"  [dim]Expires[/dim]         {expires_display}\n"
        f"  [dim]Days remaining[/dim]  {days_display}"
    )
    console.print(f"\n  Run [bold]pagefuse refresh[/bold] to re-fetch from the server.")


@cli.command()
def refresh():
    """Re-fetch license status and show expiry date.

    Contacts the Lemon Squeezy server to get the latest license state,
    updates the local cache, and prints your license details including
    expiry date and days remaining.

    \b
    pagefuse refresh          # check and display license status
    """
    st = license_mod.status()

    if st["type"] == "none":
        raise click.ClickException(
            "No license or trial found. Run 'pagefuse activate <key>' to activate."
        )

    if st["type"] == "trial":
        expires = st["expires_at"][:10] if st.get("expires_at") else "unknown"
        console.print(
            f"  [dim]Type[/dim]        Trial\n"
            f"  [dim]Days left[/dim]   {st['days_left']}\n"
            f"  [dim]Expires[/dim]     {expires}"
        )
        return

    # Licensed — re-validate online
    console.print("Fetching license status...")
    data = license_mod._load()
    try:
        valid, data = license_mod.revalidate(data)
    except Exception as e:
        raise click.ClickException(str(e))

    st = license_mod.status()
    expires = st.get("expires_at", "")
    expires_display = expires[:10] if expires else "—"
    dtx = st.get("days_to_expiry")
    days_display = f"{dtx} days" if dtx is not None else "—"
    status_display = "[green]Active[/green]" if valid else "[red]Invalid[/red]"

    console.print(
        f"  [dim]Type[/dim]            Licensed\n"
        f"  [dim]Key[/dim]             {st['key']}\n"
        f"  [dim]Email[/dim]           {st.get('email') or '—'}\n"
        f"  [dim]Status[/dim]          {status_display}\n"
        f"  [dim]Expires[/dim]         {expires_display}\n"
        f"  [dim]Days remaining[/dim]  {days_display}"
    )


def _dryrun_plan(
    parsed_sources: list[tuple[str, str]],
    outputs: list[str],
    metadata: dict | None = None,
    output_pages: list[str] | None = None,
) -> None:
    """Print a dry-run plan table without converting or writing anything."""
    _validate(parsed_sources, outputs)
    _estimate_resources(parsed_sources, outputs)
    console.print()

    # --- inputs table ---
    tbl = Table(title="Inputs", show_header=True, header_style="bold")
    tbl.add_column("File", style="cyan")
    tbl.add_column("Format")
    tbl.add_column("Pages")
    tbl.add_column("Size", justify="right")

    for file_path, page_spec in parsed_sources:
        p = Path(file_path)
        suffix = p.suffix.lower()
        try:
            size = _format_size(p.stat().st_size)
        except OSError:
            size = "?"
        tbl.add_row(p.name, suffix, page_spec, size)

    console.print(tbl)
    console.print()

    # --- outputs table ---
    out_tbl = Table(title="Outputs", show_header=True, header_style="bold")
    out_tbl.add_column("File", style="bold")
    out_tbl.add_column("Format")
    if output_pages is not None:
        out_tbl.add_column("Pages")

    for i, output in enumerate(outputs):
        suffix = Path(output).suffix.lower()
        row = [Path(output).name, suffix]
        if output_pages is not None:
            row.append(output_pages[i] if i < len(output_pages) else "all")
        out_tbl.add_row(*row)

    console.print(out_tbl)
    console.print()

    if metadata:
        meta_parts = [f"{k}: {v}" for k, v in metadata.items() if v]
        if meta_parts:
            console.print(f"  [dim]Metadata  {', '.join(meta_parts)}[/dim]")

    console.print("\n[dim]Dry run complete — no files were written.[/dim]")


@cli.command()
@click.argument("config_or_output")
@click.argument("sources", nargs=-1, required=False)
@click.option("--dry-run", is_flag=True, help="Preview plan without converting or writing any files.")
def assemble(config_or_output, sources, dry_run):
    """Combine pages from multiple documents into one output.

    Pass a .fuse config file, or use inline OUTPUT SOURCE... arguments:

    \b
    pagefuse assemble board_pack.fuse
    pagefuse assemble out.pdf cover.pdf:1 terms.docx:all pricing.pdf:1-3
    pagefuse assemble --dry-run board_pack.fuse
    """
    p = Path(config_or_output)
    if p.suffix.lower() == ".fuse":
        if sources:
            raise click.ClickException("Cannot mix a .fuse config with inline arguments.")
        if not p.exists():
            raise click.ClickException(f"Config file not found: '{config_or_output}'")
        outputs, sources_cfg, metadata = _parse_fuse_config(str(p))
        parsed = [(e["file"], e["pages"]) for e in sources_cfg]
    else:
        if not sources:
            raise click.UsageError("Provide at least one source file or a .fuse config.")
        outputs = [config_or_output]
        metadata = {"title": p.stem}
        parsed = []
        for src in sources:
            if ":" in src:
                file_path, page_spec = src.rsplit(":", 1)
            else:
                file_path, page_spec = src, "all"
            parsed.append((file_path, page_spec))

    if dry_run:
        _dryrun_plan(parsed, outputs, metadata)
        return

    _validate(parsed, outputs)
    _estimate_resources(parsed, outputs)
    with tempfile.TemporaryDirectory() as tmp_dir:
        finals = _run_assembly(parsed, outputs, metadata, tmp_dir)
    for path, final in zip(outputs, finals):
        if final != path:
            console.print(f"[yellow]Note:[/yellow] Multi-page image output saved as {final}")


@cli.command(hidden=True)
@click.argument("output")
@click.argument("sources", nargs=-1, required=True)
def quick(output, sources):
    """Deprecated: use 'pagefuse assemble' instead."""
    parsed = []
    for src in sources:
        if ":" in src:
            file_path, page_spec = src.rsplit(":", 1)
        else:
            file_path, page_spec = src, "all"
        parsed.append((file_path, page_spec))
    console.print("[yellow]Note:[/yellow] 'quick' is deprecated — use 'pagefuse assemble' instead.")
    _validate(parsed, [output])
    _estimate_resources(parsed, [output])
    with tempfile.TemporaryDirectory() as tmp_dir:
        finals = _run_assembly(parsed, [output], {"title": Path(output).stem}, tmp_dir)
    final = finals[0]
    if final != output:
        console.print(f"[yellow]Note:[/yellow] Multi-page image output saved as {final}")


@cli.command()
@click.argument("source_or_config", type=click.Path(exists=False))
@click.argument("outputs", nargs=-1, required=False)
@click.option("--dry-run", is_flag=True, help="Preview plan without converting or writing any files.")
def split(source_or_config, outputs, dry_run):
    """Extract pages from one document into multiple output files.

    Pass a .fuse config file, or use inline FILE:PAGES arguments:

    \b
    pagefuse split report.pdf cover.pdf:1 body.pdf:2-10 appendix.docx:11-20
    pagefuse split split.fuse
    pagefuse split --dry-run report.pdf cover.pdf:1 body.pdf:2-10
    """
    # Detect "report.pdf:1" — user added a page spec to the source by mistake.
    if ":" in source_or_config:
        base, _, spec = source_or_config.rpartition(":")
        if Path(base).exists():
            raise click.ClickException(
                f"Source file should not include a page spec — "
                f"'split' always reads the whole source.\n"
                f"Did you mean: pagefuse split {base} ..."
            )

    if not Path(source_or_config).exists():
        raise click.ClickException(f"Source file not found: '{source_or_config}'")

    if Path(source_or_config).suffix.lower() == ".fuse":
        if outputs:
            raise click.ClickException(
                "Cannot mix a .fuse config with inline output arguments."
            )
        source, out_specs, metadata = _parse_split_config(source_or_config)
    else:
        metadata = {}
        if not outputs:
            raise click.ClickException(
                "Provide output arguments or a .fuse config file."
            )
        source = source_or_config
        out_specs = []
        for out_spec in outputs:
            if ":" in out_spec:
                out_path, page_spec = out_spec.rsplit(":", 1)
                if not _PAGES_RE.match(page_spec):
                    out_path, page_spec = out_spec, "all"
            else:
                out_path, page_spec = out_spec, "all"
            out_specs.append({"output": out_path, "pages": page_spec})

    src_suffix = Path(source).suffix.lower()
    if src_suffix in convert_core.IMAGE_FORMATS:
        raise click.ClickException(
            f"Images ({src_suffix}) cannot be used as a split source — "
            "use 'pagefuse assemble' to assemble or convert images."
        )

    if src_suffix in _PRESENTATION_FORMATS:
        bad = [e["output"] for e in out_specs if Path(e["output"]).suffix.lower() in _WORD_OUTPUT_FORMATS]
        if bad:
            console.print(f"[red]Error:[/red] Cannot output word-processor format ({', '.join(bad)}) from a presentation source ({src_suffix}).\n")
            ctx = click.get_current_context()
            ctx.invoke(info, files=(source,))
            raise SystemExit(1)

    if src_suffix in _WORD_FORMATS:
        bad = [e["output"] for e in out_specs if Path(e["output"]).suffix.lower() in _PRESENTATION_OUTPUT_FORMATS]
        if bad:
            console.print(f"[red]Error:[/red] Cannot output presentation format ({', '.join(bad)}) from a word-processor source ({src_suffix}).\n")
            ctx = click.get_current_context()
            ctx.invoke(info, files=(source,))
            raise SystemExit(1)

    out_paths = [e["output"] for e in out_specs]

    if dry_run:
        _dryrun_plan([(source, "all")], out_paths, metadata,
                     output_pages=[e["pages"] for e in out_specs])
        return

    _validate([(source, "all")], out_paths)
    _estimate_resources([(source, "all")], out_paths)
    with tempfile.TemporaryDirectory() as tmp_dir:
        _run_split(source, out_specs, tmp_dir, metadata=metadata)


_PRESENTATION_FORMATS = {".pptx", ".ppt", ".odp"}
_WORD_FORMATS = {".docx", ".doc", ".odt"}
_WORD_OUTPUT_FORMATS = _WORD_FORMATS
_PRESENTATION_OUTPUT_FORMATS = {".odp"}


def _format_support_info(suffix: str) -> dict:
    """Return split/assemble input+output support info for a file suffix."""
    all_out = sorted(SUPPORTED_OUTPUT_FORMATS)

    def _allowed_out(src_suffix):
        excluded = set()
        if src_suffix in _PRESENTATION_FORMATS:
            excluded |= _WORD_OUTPUT_FORMATS
        if src_suffix in _WORD_FORMATS:
            excluded |= _PRESENTATION_OUTPUT_FORMATS
        return "  ".join(fmt for fmt in all_out if fmt not in excluded)

    split_input = suffix not in convert_core.IMAGE_FORMATS
    split_out = "—" if not split_input else _allowed_out(suffix)
    assemble_out = _allowed_out(suffix)

    return {
        "split_input":    "yes" if split_input else "no (images cannot be split sources)",
        "split_output":   split_out,
        "assemble_input": "yes",
        "assemble_output": assemble_out,
    }


def _get_file_info(file: str, tmp_dir: str | None = None) -> dict:
    """Return {page_count, metadata} for any supported file without full conversion."""
    suffix = Path(file).suffix.lower()

    if suffix == ".pdf":
        return pdf_core.get_pdf_info(file)

    if suffix in {".docx", ".doc", ".odt"}:
        import zipfile as _zipfile
        import re as _re
        import docx as _docx
        doc = _docx.Document(file)
        cp = doc.core_properties
        pages = None
        try:
            with _zipfile.ZipFile(file) as z:
                if "docProps/app.xml" in z.namelist():
                    content = z.read("docProps/app.xml").decode("utf-8", errors="ignore")
                    m = _re.search(r"<[^>]*:?Pages>(\d+)</[^>]*:?Pages>", content)
                    if m:
                        pages = int(m.group(1))
        except Exception:
            pass
        if pages is None and tmp_dir:
            try:
                pdf_path = convert_core.libreoffice_to_pdf(file, tmp_dir)
                pages = pdf_core.get_pdf_info(pdf_path)["page_count"]
            except Exception:
                pass
        return {
            "page_count": pages if pages is not None else "?",
            "metadata": {"title": cp.title or "", "author": cp.author or ""},
        }

    if suffix in {".pptx", ".ppt", ".odp"}:
        import pptx as _pptx
        prs = _pptx.Presentation(file)
        cp = prs.core_properties
        return {
            "page_count": len(prs.slides),
            "metadata": {"title": cp.title or "", "author": cp.author or ""},
        }

    if suffix in convert_core.IMAGE_FORMATS:
        from PIL import Image as _Image
        with _Image.open(file) as img:
            w, h = img.size
        return {
            "page_count": 1,
            "metadata": {"title": "", "author": "", "dimensions": f"{w}×{h}px"},
        }

    if suffix in convert_core.MARKDOWN_FORMATS:
        return {"page_count": "?", "metadata": {"title": "", "author": ""}}

    return {"page_count": "?", "metadata": {"title": "", "author": ""}}


@cli.command()
@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
def info(files):
    """Show page count, metadata, and supported formats for one or more documents."""
    has_error = False
    for file in files:
        suffix = Path(file).suffix.lower()

        # --- format support check ---
        if suffix not in SUPPORTED_FORMATS:
            console.print(
                f"[yellow]Warning:[/yellow] '{suffix}' is not a supported input format — "
                f"pagefuse cannot read this file.\n"
            )
            has_error = True
            continue

        try:
            with tempfile.TemporaryDirectory() as _tmp:
                data = _get_file_info(file, tmp_dir=_tmp)
        except Exception as e:
            console.print(f"[red]Error[/red] {file}: {e}")
            has_error = True
            continue

        # --- file info table ---
        info_tbl = Table(show_header=False, box=None, padding=(0, 2))
        info_tbl.add_column("Key", style="dim")
        info_tbl.add_column("Value")
        info_tbl.add_row("File", Path(file).name)
        info_tbl.add_row("Format", suffix.lstrip(".").upper())
        info_tbl.add_row("Pages", str(data["page_count"]))
        if data["metadata"].get("title"):
            info_tbl.add_row("Title", data["metadata"]["title"])
        if data["metadata"].get("author"):
            info_tbl.add_row("Author", data["metadata"]["author"])
        if data["metadata"].get("dimensions"):
            info_tbl.add_row("Dimensions", data["metadata"]["dimensions"])
        console.print(info_tbl)

        # --- format support table ---
        sup = _format_support_info(suffix)
        sup_tbl = Table(title="Format Support", show_header=True, header_style="bold")
        sup_tbl.add_column("Command")
        sup_tbl.add_column("Input support")
        sup_tbl.add_column("Output support")
        sup_tbl.add_row("assemble", sup["assemble_input"], sup["assemble_output"])
        sup_tbl.add_row("split",    sup["split_input"],    sup["split_output"])
        console.print(sup_tbl)

        if file != files[-1]:
            console.print()

    if has_error:
        raise SystemExit(1)


_ASSEMBLE_TEMPLATE = (
    "# PageFuse assemble config — combine multiple documents into one output.\n"
    "# Pages:  all  /  1  /  1-3  /  1,3,5-8\n"
    "# Output: .pdf  /  .docx  /  .html  /  .png  /  .jpg  /  .tiff\n"
    "\n"
    "output: assembled.pdf\n"
    "# output: assembled.docx  # add more output: lines for additional formats\n"
    "\n"
    "# Metadata (optional — title defaults to output filename)\n"
    "# title:   My Document\n"
    "# author:  Jane Smith\n"
    "# subject: Q4 Report\n"
    "\n"
    "from: document.pdf    all\n"
    "from: report.docx     1-3\n"
)

_SPLIT_TEMPLATE = (
    "# PageFuse split config — extract pages from one document into multiple outputs.\n"
    "# Pages:  all  /  1  /  1-3  /  1,3,5-8\n"
    "# Output: .pdf  /  .docx  /  .html  /  .png  /  .jpg  /  .tiff\n"
    "\n"
    "from: input.pdf\n"
    "\n"
    "output: cover.pdf             1\n"
    "output: body.pdf              2-10\n"
    "output: appendix.pdf          11-20\n"
    "# output: summary.docx        1-5   # any output format works\n"
    "# output: cover_preview.png   1\n"
)


@cli.command()
@click.option("--output", default="config.fuse", show_default=True, help="Config filename to create.")
@click.option("--split", "mode", flag_value="split", help="Scaffold a split config instead of assemble.")
@click.option("--assemble", "mode", flag_value="assemble", default=True, help="Scaffold an assemble config (default).")
def init(output, mode):
    """Scaffold a template .fuse config file.

    \b
    pagefuse init                        # assemble config (default)
    pagefuse init --split                # split config
    pagefuse init --output my.fuse       # custom filename
    """
    out_path = Path(output)
    if out_path.exists():
        raise click.ClickException(f"{output} already exists.")

    out_path.write_text(_SPLIT_TEMPLATE if mode == "split" else _ASSEMBLE_TEMPLATE)
    console.print(f"[green]✓[/green] Created {output} ({mode} config)")


@cli.command(name="help")
@click.argument("command", required=False)
@click.pass_context
def help_cmd(ctx, command):
    """Show help for a command.

    \b
    pagefuse help              # list all commands
    pagefuse help quick        # help for the quick command
    pagefuse help assemble     # help for the assemble command
    """
    if command is None:
        click.echo(ctx.parent.get_help())
        return

    cmd = cli.get_command(ctx, command)
    if cmd is None:
        available = ", ".join(sorted(cli.list_commands(ctx)))
        raise click.ClickException(
            f"Unknown command '{command}'. Available commands: {available}"
        )

    with click.Context(cmd, info_name=command, parent=ctx.parent) as cmd_ctx:
        click.echo(cmd.get_help(cmd_ctx))
