#!/usr/bin/env python3
"""
PPT Master - SVG Post-processing Tool (Unified Entry Point)

Processes SVG files from svg_output/ and produces the visual preview in
svg_final/, embedding supported raster/SVG assets. Native PPTX export continues
to read svg_output/ by default; svg_final/ may be opened directly or inserted
as an SVG image. EMF/WMF assets retain their external-reference exception.
By default, all processing steps are executed. You can also specify individual
steps via arguments.

Architecture note: this module's outputs feed svg_final/ on disk AND its
sub-modules (svg_finalize.embed_icons, svg_finalize.flatten_tspan, ...)
are memory-reused by svg_to_pptx during native conversion. Deleting any
step here may also break native pptx output, not just svg_final/.
See scripts/docs/svg-pipeline.md before modifying the shared pipeline.

Usage:
    # Execute all processing steps (recommended)
    python3 scripts/finalize_svg.py <project_directory>

    # Execute only specific steps
    python3 scripts/finalize_svg.py <project_directory> --only embed-icons align-images

Examples:
    python3 scripts/finalize_svg.py projects/my_project
    python3 scripts/finalize_svg.py examples/ppt169_demo --only embed-icons

Processing options:
    embed-icons   - Expand project icons and static same-document <use>
    align-images  - Align (slice/meet) and Base64-embed all <image> in one pass.
                    Replaces the former crop-images + fix-aspect + embed-images
                    trio. The old names remain accepted as aliases for the
                    merged step, so existing --only invocations keep working.
    flatten-text  - Convert <tspan> to independent <text> (for special renderers)
"""

import argparse
import os
import shutil
import sys
import tempfile
from enum import Enum
from pathlib import Path
from typing import TextIO
from xml.etree import ElementTree as ET

from console_encoding import configure_utf8_stdio

configure_utf8_stdio()

# Import finalize helpers from the internal package.
sys.path.insert(0, str(Path(__file__).parent))
from resource_paths import icon_search_dirs_for_project  # noqa: E402
from svg_finalize.align_embed_images import (
    align_and_embed_images_in_svg,
    count_office_vector_refs_in_svg,
)
from svg_finalize.embed_icons import process_svg_file as embed_icons_in_file
from svg_to_pptx.geometry_properties import (
    GeometryStyleError,
    materialize_inline_geometry_in_file,
)
from svg_to_pptx.use_expander import (
    UseExpansionError,
    expand_local_use_references_in_file,
)


class FlattenTextResult(Enum):
    """Describe whether flattening changed a file, skipped it, or failed."""

    CHANGED = "changed"
    UNCHANGED = "unchanged"
    ERROR = "error"


def safe_print(text: str, *, file: TextIO | None = None) -> None:
    """Print text while tolerating Windows terminal encoding limits."""
    stream = file or sys.stdout
    try:
        print(text, file=stream)
    except UnicodeEncodeError:
        replacements = {
            chr(0x23F3): "[..]",
            chr(0x2705): "[DONE]",
            chr(0x274C): "[ERROR]",
            chr(0x26A0) + chr(0xFE0F): "[WARN]",
            chr(0x1F4C1): "[DIR]",
            chr(0x1F4C4): "[FILE]",
            chr(0x1F4E6): "[OK]",
        }
        for source, target in replacements.items():
            text = text.replace(source, target)
        print(text, file=stream)


def process_flatten_text(
    svg_file: Path,
    verbose: bool = False,
) -> FlattenTextResult:
    """Flatten text in one SVG and report changed, unchanged, or error."""
    try:
        from svg_finalize.flatten_tspan import flatten_text_with_tspans

        tree = ET.parse(str(svg_file))
        changed = flatten_text_with_tspans(tree)

        if changed:
            tree.write(str(svg_file), encoding='unicode', xml_declaration=False)
            if verbose:
                safe_print(f"   [OK] {svg_file.name}: text flattened")
            return FlattenTextResult.CHANGED
        return FlattenTextResult.UNCHANGED
    except Exception as exc:
        safe_print(
            f"   [ERROR] {svg_file.name}: text flattening failed: {exc}",
            file=sys.stderr,
        )
        return FlattenTextResult.ERROR


def _path_lexists(path: Path) -> bool:
    """Return whether a path or dangling symlink occupies the target name."""
    return os.path.lexists(path)


def _publish_candidate_directory(candidate_dir: Path, output_dir: Path) -> None:
    """Publish one staged directory and restore the previous output on failure."""
    if output_dir.is_symlink() or (
        _path_lexists(output_dir) and not output_dir.is_dir()
    ):
        raise RuntimeError(f"Output path must be a real directory: {output_dir}")

    transaction_dir = Path(
        tempfile.mkdtemp(
            prefix=f".{output_dir.name}.publish-",
            dir=output_dir.parent,
        )
    )
    backup_dir = transaction_dir / "previous"
    preserve_backup = False

    try:
        if output_dir.is_dir():
            try:
                os.replace(output_dir, backup_dir)
                os.replace(candidate_dir, output_dir)
            except BaseException as publish_error:
                try:
                    if _path_lexists(backup_dir):
                        if _path_lexists(output_dir):
                            failed_output = transaction_dir / "failed-publish"
                            os.replace(output_dir, failed_output)
                        os.replace(backup_dir, output_dir)
                except BaseException as restore_error:
                    if (
                        not _path_lexists(backup_dir)
                        and _path_lexists(output_dir)
                    ):
                        raise publish_error
                    preserve_backup = _path_lexists(backup_dir)
                    raise RuntimeError(
                        "Failed to publish svg_final and restore the previous "
                        "directory; recovery directory: "
                        f"{transaction_dir}"
                    ) from restore_error
                raise
        else:
            os.replace(candidate_dir, output_dir)
    finally:
        if not preserve_backup:
            shutil.rmtree(transaction_dir, ignore_errors=True)


def _process_candidate_directory(
    candidate_dir: Path,
    *,
    options: dict[str, bool],
    quiet: bool,
    compress: bool,
    max_dimension: int | None,
    image_scale: float,
    icons_dir: Path,
    icons_fallback_dir: Path | None,
) -> bool:
    """Run every selected finalization pass against one unpublished candidate."""
    # Core normalization: downstream image/rect processors read XML geometry.
    geometry_count = 0
    for svg_file in candidate_dir.glob('*.svg'):
        try:
            geometry_count += materialize_inline_geometry_in_file(svg_file)
        except (OSError, ET.ParseError, GeometryStyleError) as exc:
            safe_print(
                f"[ERROR] {svg_file.name}: inline geometry materialization failed: {exc}"
            )
            return False

    # Step 1: Expand project icons, then standard same-document use references.
    if options.get('embed_icons'):
        if not quiet:
            safe_print("[1/3] Expanding icons + local use references...")
        icons_count = 0
        for svg_file in candidate_dir.glob('*.svg'):
            count = embed_icons_in_file(
                svg_file,
                icons_dir,
                dry_run=False,
                verbose=False,
                fallback_dir=icons_fallback_dir,
            )
            icons_count += count
        for svg_file in candidate_dir.glob('*.svg'):
            try:
                geometry_count += materialize_inline_geometry_in_file(svg_file)
            except (OSError, ET.ParseError, GeometryStyleError) as exc:
                safe_print(
                    f"[ERROR] {svg_file.name}: expanded icon geometry "
                    f"materialization failed: {exc}"
                )
                return False
        local_use_count = 0
        for svg_file in candidate_dir.glob('*.svg'):
            try:
                local_use_count += expand_local_use_references_in_file(svg_file)
            except (OSError, ET.ParseError, UseExpansionError) as exc:
                safe_print(
                    f"[ERROR] {svg_file.name}: local <use> expansion failed: {exc}"
                )
                return False
        if not quiet:
            if icons_count > 0:
                safe_print(f"      {icons_count} icon(s) embedded")
            else:
                safe_print("      No icons")
            if local_use_count > 0:
                safe_print(f"      {local_use_count} local use reference(s) expanded")
            else:
                safe_print("      No local use references")

    if not quiet and geometry_count:
        safe_print(
            f"[PREP] {geometry_count} inline geometry declaration(s) materialized"
        )

    # Step 2: Align (slice/meet) and Base64-embed all <image> in one pass.
    # Replaces the former crop-images / fix-aspect / embed-images trio: the
    # spatial transform (slice → crop, meet → fit-box) and the asset embed
    # are mutually exclusive branches per image, sequenced together so each
    # SVG is only parsed and serialized once and each bitmap is only read
    # from disk once.
    if options.get('align_images'):
        if not quiet:
            safe_print("[2/3] Aligning + embedding images...")
        img_count = 0
        img_errors = 0
        office_vector_count = 0
        for svg_file in candidate_dir.glob('*.svg'):
            office_vector_count += count_office_vector_refs_in_svg(svg_file)
            count, errs = align_and_embed_images_in_svg(
                svg_file,
                dry_run=False,
                verbose=False,
                compress=compress,
                max_dimension=max_dimension,
                image_scale=image_scale,
            )
            img_count += count
            img_errors += errs
        if img_errors:
            safe_print(
                f"[ERROR] Image alignment/embedding failed for "
                f"{img_errors} image(s); svg_final was not published",
                file=sys.stderr,
            )
            return False
        if not quiet:
            if img_count > 0:
                msg = f"      {img_count} image(s) aligned + embedded"
                safe_print(msg)
                if office_vector_count:
                    safe_print(
                        f"      {office_vector_count} Office vector(s) left external "
                        "for native PPTX passthrough"
                    )
            elif office_vector_count:
                safe_print(
                    f"      {office_vector_count} Office vector(s) left external "
                    "for native PPTX passthrough"
                )
            else:
                safe_print("      No images")

    # Step 3: Flatten text.
    if options.get('flatten_text'):
        if not quiet:
            safe_print("[3/3] Flattening text...")
        flatten_count = 0
        flatten_errors = 0
        for svg_file in candidate_dir.glob('*.svg'):
            result = process_flatten_text(svg_file, verbose=False)
            if result is FlattenTextResult.CHANGED:
                flatten_count += 1
            elif result is FlattenTextResult.ERROR:
                flatten_errors += 1
        if flatten_errors:
            safe_print(
                f"[ERROR] Text flattening failed for {flatten_errors} file(s); "
                "svg_final was not published",
                file=sys.stderr,
            )
            return False
        if not quiet:
            if flatten_count > 0:
                safe_print(f"      {flatten_count} file(s) processed")
            else:
                safe_print("      No processing needed")

    return True


def finalize_project(
    project_dir: Path,
    options: dict[str, bool],
    dry_run: bool = False,
    quiet: bool = False,
    compress: bool = True,
    max_dimension: int | None = 2560,
    image_scale: float = 2.0,
) -> bool:
    """
    Finalize SVG files in the project

    Args:
        project_dir: Project directory path
        options: Processing options dictionary
        dry_run: Preview only, do not execute
        quiet: Quiet mode, reduce output
        compress: Compress images before embedding
        max_dimension: Downscale images exceeding this dimension
        image_scale: Target image pixels per SVG display pixel
    """
    svg_output = project_dir / 'svg_output'
    svg_final = project_dir / 'svg_final'
    icons_dir, icons_fallback_dir = icon_search_dirs_for_project(project_dir)

    # Check if svg_output exists
    if not svg_output.exists():
        safe_print(f"[ERROR] svg_output directory not found: {svg_output}")
        return False

    # Get list of SVG files
    svg_files = list(svg_output.glob('*.svg'))
    if not svg_files:
        safe_print(f"[ERROR] No SVG files in svg_output")
        return False

    if not quiet:
        print()
        safe_print(f"[DIR] Project: {project_dir.name}")
        safe_print(f"[FILE] {len(svg_files)} SVG file(s)")

    if dry_run:
        safe_print("[PREVIEW] Preview mode, no operations will be performed")
        return True

    candidate_dir = Path(
        tempfile.mkdtemp(
            prefix=f".{svg_final.name}.candidate-",
            dir=svg_final.parent,
        )
    )
    try:
        try:
            shutil.copytree(svg_output, candidate_dir, dirs_exist_ok=True)
            candidate_ready = _process_candidate_directory(
                candidate_dir,
                options=options,
                quiet=quiet,
                compress=compress,
                max_dimension=max_dimension,
                image_scale=image_scale,
                icons_dir=icons_dir,
                icons_fallback_dir=icons_fallback_dir,
            )
        except Exception as exc:
            safe_print(
                f"[ERROR] SVG finalization failed before publish: {exc}",
                file=sys.stderr,
            )
            return False

        if not candidate_ready:
            return False

        try:
            _publish_candidate_directory(candidate_dir, svg_final)
        except (OSError, RuntimeError) as exc:
            safe_print(
                f"[ERROR] svg_final publish failed: {exc}",
                file=sys.stderr,
            )
            return False
    finally:
        shutil.rmtree(candidate_dir, ignore_errors=True)

    # Done
    if not quiet:
        print()
        safe_print("[OK] Done!")
        print()
        print("Next steps:")
        print(f"  python scripts/svg_to_pptx.py \"{project_dir}\"")

    return True


def main() -> None:
    """Run the CLI entry point."""
    parser = argparse.ArgumentParser(
        description='PPT Master - SVG Post-processing Tool',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog='''
Examples:
  %(prog)s projects/my_project           # Execute all processing (default)
  %(prog)s projects/my_project --only embed-icons align-images
  %(prog)s projects/my_project -q        # Quiet mode

Processing options (for --only):
  embed-icons   Expand project icons and static same-document <use>
  align-images  Align (slice/meet) + Base64-embed all <image> (single pass)
  flatten-text  Flatten text

Aliases (still accepted):
  crop-images, fix-aspect, embed-images  → all map to align-images
        '''
    )

    parser.add_argument('project_dir', type=Path, help='Project directory path')
    parser.add_argument(
        '--only', nargs='+', metavar='OPTION',
        choices=[
            'embed-icons',
            'align-images',
            # Backwards-compatible aliases — all three map to align-images now.
            'crop-images', 'fix-aspect', 'embed-images',
            'flatten-text',
        ],
        help=('Execute only specified processing steps (default: all). '
              'crop-images / fix-aspect / embed-images are accepted as '
              'aliases for the merged align-images step.'),
    )
    parser.add_argument('--dry-run', '-n', action='store_true',
                        help='Preview only, do not execute')
    parser.add_argument('--quiet', '-q', action='store_true',
                        help='Quiet mode, reduce output')
    parser.add_argument('--compress', dest='compress', action='store_true', default=True,
                        help='Compress images before embedding (default)')
    parser.add_argument('--no-compress', dest='compress', action='store_false',
                        help='Disable image compression before embedding')
    parser.add_argument('--max-dimension', type=int, default=2560,
                        help='Downscale images exceeding this dimension on either axis (default: 2560)')
    parser.add_argument('--image-scale', type=float, default=2.0,
                        help='Target image pixels per SVG display pixel (default: 2.0)')

    args = parser.parse_args()

    if not args.project_dir.exists():
        safe_print(f"[ERROR] Project directory does not exist: {args.project_dir}")
        sys.exit(1)

    # Aliases: any of crop-images / fix-aspect / embed-images implies the
    # merged align-images step. Older invocations stay valid.
    _ALIGN_ALIASES = {'align-images', 'crop-images', 'fix-aspect', 'embed-images'}

    # Determine processing options
    if args.only:
        only = set(args.only)
        options = {
            'embed_icons': 'embed-icons' in only,
            'align_images': bool(only & _ALIGN_ALIASES),
            'flatten_text': 'flatten-text' in only,
        }
    else:
        # Execute all by default
        options = {
            'embed_icons': True,
            'align_images': True,
            'flatten_text': True,
        }

    if args.max_dimension < 1:
        safe_print("[ERROR] --max-dimension must be >= 1")
        sys.exit(1)
    if args.image_scale < 1:
        safe_print("[ERROR] --image-scale must be >= 1")
        sys.exit(1)

    success = finalize_project(args.project_dir, options, args.dry_run, args.quiet,
                               compress=args.compress,
                               max_dimension=args.max_dimension,
                               image_scale=args.image_scale)
    sys.exit(0 if success else 1)


if __name__ == '__main__':
    main()
