"""
Core sync orchestration logic.
"""

import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from persuade.backup import BackupError, BackupManager
from persuade.constants import FileCategory
from persuade.file_ops import (
    apply_file_changes,
    compute_changes,
    scan_source_directory,
)
from persuade.git_ops import (
    check_ignored_synced_files,
    update_gitignore,
    validate_gitignore_compliance,
)
from persuade.git_pr import (
    check_gh_available,
    create_pull_request,
    generate_pr_body,
    push_branch,
)
from persuade.git_workflow import (
    GitWorkflowResult,
    create_atomic_commits,
    create_sync_branch,
    get_default_branch,
)
from persuade.logging import get_logger
from persuade.marker_ops import (
    create_editable_marker,
    enable_shadow_mode,
    is_editable_mode_enabled,
    remove_editable_marker,
    validate_editable_environment,
)
from persuade.reporter import SyncReporter
from persuade.settings_merger import SettingsMerger
from persuade.state import PersuadeState
from persuade.validators import (
    DependencyValidator,
    ExecutablePermissionsValidator,
    JSONSyntaxValidator,
)
from persuade.version_detection import (
    VersionComparison,
    check_outdated_files,
    format_version_warning,
)

logger = get_logger()


@dataclass
class SyncOptions:
    """Options for sync operation."""

    dry_run: bool = False
    mode: str = "union"  # 'union' or 'strict'
    force: bool = False
    no_backup: bool = False
    exclude: list[str] | None = None
    verbose: bool = False
    quiet: bool = False
    shadow: bool = False  # Enable shadow mode (sync files but exclude from git)
    editable: bool = False  # Enable editable mode (symlink instead of copy)
    force_update: bool = False  # Force update COPY_IF_MISSING files
    git_commit: bool = False  # Create atomic git commits for synced files
    create_branch: bool = False  # Create a new branch for commits
    create_pr: bool = False  # Create a PR after commits

    def __post_init__(self) -> None:
        if self.exclude is None:
            self.exclude = []


@dataclass
class SyncResult:
    """Result of sync operation."""

    status: str  # 'success', 'partial', 'dry_run', 'cancelled', 'error'
    message: str = ""
    backup_path: Path | None = None
    files_synced: int = 0
    permissions_merged: int = 0
    changes: dict[str, Any] | None = None
    gitignore_additions: list[str] | None = None
    gitignore_violations: list[str] | None = None
    shadow_mode_enabled: bool = False
    shadow_untrack_commands: list[str] | None = None
    outdated_files: list[VersionComparison] | None = None
    git_workflow_result: GitWorkflowResult | None = None
    ignored_synced_files: list[str] | None = None


def sync_automation(source_path: Path, target_path: Path, options: SyncOptions) -> SyncResult:
    """
    Main sync orchestration function.

    Args:
        source_path: Path to source template repository
        target_path: Path to target project
        options: Sync options

    Returns:
        SyncResult with statistics and status
    """
    reporter = SyncReporter(verbose=options.verbose)

    # Validate paths
    source_claude = source_path / ".claude"
    target_claude = target_path / ".claude"

    if not source_claude.exists():
        reporter.print_error(f"Source .claude/ directory not found: {source_claude}")
        return SyncResult(
            status="error",
            message=f"Source .claude/ directory not found: {source_claude}",
        )

    if not target_path.exists():
        reporter.print_error(f"Target path does not exist: {target_path}")
        return SyncResult(status="error", message=f"Target path does not exist: {target_path}")

    # Validate editable mode
    if options.editable:
        try:
            validate_editable_environment()
        except ValueError as e:
            reporter.print_error(str(e))
            return SyncResult(status="error", message=str(e))

        # Editable mode is incompatible with shadow mode
        if options.shadow:
            error_msg = "Editable mode and shadow mode cannot be used together"
            reporter.print_error(error_msg)
            return SyncResult(status="error", message=error_msg)

    # Check dependencies
    if not options.quiet:
        dep_validator = DependencyValidator()
        dep_result = dep_validator.validate()
        if not dep_result.valid:
            reporter.print_error(f"Missing required dependencies: {', '.join(dep_result.issues)}")
            return SyncResult(status="error", message="Missing required dependencies")

    # Fresh install vs update
    is_fresh_install = not target_claude.exists()

    if is_fresh_install and not options.quiet:
        reporter.print_success(f"Fresh installation - creating .claude/ directory in {target_path}")

    # Scan source files
    try:
        source_files = scan_source_directory(source_path, options.exclude)
    except (OSError, ValueError) as e:
        reporter.print_error(f"Failed to scan source directory: {e}")
        return SyncResult(status="error", message=str(e))

    # Compute what would change (pass editable flag for mode transition detection)
    changes = compute_changes(
        source_path,
        target_path,
        source_files,
        strict=False,
        use_symlinks=options.editable,
        force_update=options.force_update,
    )

    # Merge settings.json
    merger = SettingsMerger()
    source_settings_path = source_claude / "settings.json"
    target_settings_path = target_claude / "settings.json"

    settings_diff = {}

    if source_settings_path.exists():
        try:
            with open(source_settings_path) as f:
                source_settings = json.load(f)

            # If target has settings.json, merge; otherwise use source
            if target_settings_path.exists():
                with open(target_settings_path) as f:
                    target_settings = json.load(f)

                # Merge based on mode
                merged_settings = merger.merge(source_settings, target_settings, mode=options.mode)

                # Calculate diff for display
                settings_diff = merger.compute_diff(
                    source_settings, target_settings, mode=options.mode
                )
            else:
                merged_settings = source_settings
                settings_diff = {
                    "permissions_added": source_settings.get("permissions", {}).get("allow", []),
                    "permissions_removed": [],
                    "hooks_added": list(source_settings.get("hooks", {}).keys()),
                }

        except json.JSONDecodeError as e:
            reporter.print_error(f"Failed to parse settings.json: {e}")
            return SyncResult(status="error", message=str(e))
        except (OSError, ValueError) as e:
            reporter.print_error(f"Settings merge failed: {e}")
            return SyncResult(status="error", message=str(e))
    else:
        merged_settings = None

    # Check for outdated COPY_IF_MISSING files (version detection)
    # Always check for outdated files (even with force_update) so users see what's being updated
    outdated_files: list[VersionComparison] = []
    if not is_fresh_install:
        # Find COPY_IF_MISSING files that exist in target
        copy_if_missing_files = [
            rel_path
            for rel_path, file_info in source_files.items()
            if file_info.category == FileCategory.COPY_IF_MISSING
        ]

        if copy_if_missing_files:
            outdated_files = check_outdated_files(source_path, target_path, copy_if_missing_files)

            # Display version warnings
            if outdated_files and not options.quiet:
                warning_msg = format_version_warning(outdated_files)
                reporter.print_warning(warning_msg)

    # Display diff
    if not options.quiet and (changes.has_changes() or settings_diff):
        reporter.print_diff(changes, settings_diff)

    # Dry run - stop here
    if options.dry_run:
        return SyncResult(
            status="dry_run",
            message="Dry run completed - no changes made",
            files_synced=len(changes.added) + len(changes.modified),
            permissions_merged=len(settings_diff.get("permissions_added", [])),
            changes={"files": changes, "settings": settings_diff},
            outdated_files=outdated_files,
        )

    # Create backup (unless disabled or fresh install)
    backup_path = None
    if not options.no_backup and not is_fresh_install:
        if not options.quiet:
            logger.info("Creating backup")

        backup_mgr = BackupManager(target_path)
        try:
            backup_path = backup_mgr.create_backup()
            if not options.quiet:
                reporter.print_success(f"Backup created: {backup_path.name}")
        except BackupError as e:
            reporter.print_error(f"Backup failed: {e}")
            if not options.force:
                return SyncResult(status="error", message=str(e))
            else:
                logger.info("Continuing without backup (--force enabled)")

    # Apply changes
    try:
        # Apply file changes
        if not options.quiet:
            mode_desc = "symlinks" if options.editable else "files"
            logger.info(f"Applying {mode_desc}")

        apply_file_changes(
            source_path, target_path, changes, source_files, use_symlinks=options.editable
        )

        # Track synced files in state for precise git exclude patterns
        # This enables the state system to know exactly what was synced for:
        # 1. Precise shadow mode exclusion (only exclude what we synced)
        # 2. Session continuity
        try:
            state = PersuadeState.load(target_path)
            for rel_path, file_info in source_files.items():
                # Determine actual destination path
                dest_path = file_info.destination or rel_path
                state.sync.add_entry(rel_path, dest_path)
            state.save(target_path)
            logger.debug("sync_state_updated", files_tracked=len(source_files))
        except OSError as e:
            # State tracking failure is non-fatal - sync still succeeded
            logger.warning("sync_state_update_failed", error=str(e))
            if not options.quiet:
                reporter.print_warning(
                    f"Could not save state (future syncs may not track destinations): {e}"
                )

        # Write merged settings.json
        if merged_settings is not None:
            target_claude.mkdir(parents=True, exist_ok=True)
            with open(target_settings_path, "w") as f:
                json.dump(merged_settings, f, indent=2)
                f.write("\n")

        # Build list of synced destinations for shadow/editable mode exclude file
        synced_destinations: list[str] = []
        if options.shadow or options.editable:
            for rel_path, file_info in source_files.items():
                dest_path = file_info.destination or rel_path
                synced_destinations.append(dest_path)

        # Update file exclusions and editable marker
        shadow_untrack_commands = None
        if options.editable:
            # Enable editable mode - symlink files and exclude from git (like shadow mode)
            if not options.quiet:
                logger.info("Enabling editable mode")
            gitignore_additions, shadow_untrack_commands = enable_shadow_mode(
                target_path, synced_destinations
            )
            gitignore_violations: list[str] = []  # Editable mode handles all .claude/ files

            # Create editable marker file
            create_editable_marker(target_path, source_path)
        elif options.shadow:
            # Enable shadow mode - sync files but keep them out of git
            if not options.quiet:
                logger.info("Enabling shadow mode")
            gitignore_additions, shadow_untrack_commands = enable_shadow_mode(
                target_path, synced_destinations
            )
            gitignore_violations = []  # Shadow mode handles all .claude/ files
        else:
            # Normal mode - update gitignore for required entries only
            if not options.quiet:
                logger.info("Updating .gitignore")
            gitignore_additions = update_gitignore(target_path)

            # If previously in editable mode, remove marker
            if is_editable_mode_enabled(target_path):
                remove_editable_marker(target_path)

            # Validate gitignore compliance
            gitignore_violations = validate_gitignore_compliance(target_path)

        # Validate result
        if not options.quiet:
            logger.info("Validating")

        json_validator = JSONSyntaxValidator()
        json_result = json_validator.validate(target_path)

        if not json_result.valid:
            reporter.print_error(f"Validation failed after sync: {', '.join(json_result.issues)}")

            # Attempt rollback
            if backup_path and not options.no_backup:
                logger.info("Attempting rollback")
                try:
                    backup_mgr.restore_backup(backup_path.name)
                    reporter.print_success("Rollback completed")
                except OSError as rollback_error:
                    reporter.print_error(f"Rollback failed: {rollback_error}")

            return SyncResult(status="error", message="Validation failed")

        # Check executable permissions
        exec_validator = ExecutablePermissionsValidator()
        exec_result = exec_validator.validate(target_path)

        if not exec_result.valid:
            if not options.quiet:
                logger.info("Fixing executable permissions")
            exec_validator.fix(target_path)

        # Git workflow: check for ignored files, create commits, branch, PR
        git_workflow_result: GitWorkflowResult | None = None
        ignored_synced_files: list[str] | None = None

        # Git commit workflow is only supported in normal mode (not shadow or editable).
        # - Shadow mode: Files are excluded via .git/info/exclude, committing would be contradictory
        # - Editable mode: Files are symlinks, which may cause issues with git tracking
        #   and the working tree may not be in a clean state for atomic commits
        if options.git_commit and not options.shadow and not options.editable:
            # Import here to avoid circular dependencies
            from persuade.commit_categorization import categorize_added_and_modified

            # Get all synced files
            all_synced_files = list(changes.added) + list(changes.modified)

            # Check which files are gitignored by target project
            ignored_synced_files = check_ignored_synced_files(target_path, all_synced_files)

            if ignored_synced_files and not options.quiet:
                reporter.print_warning(
                    f"{len(ignored_synced_files)} synced files are gitignored by your project"
                )
                reporter.print_ignored_files_warning(
                    ignored_synced_files, target_path, will_force_add=True
                )

            # Create branch if requested
            if options.create_branch:
                branch_result = create_sync_branch(target_path)
                if not branch_result.success:
                    reporter.print_error(f"Branch creation failed: {branch_result.error}")
                    return SyncResult(
                        status="error",
                        message=branch_result.message,
                        backup_path=backup_path,
                    )
                git_workflow_result = branch_result
                if not options.quiet:
                    logger.info(f"Created branch: {branch_result.branch_name}")

            # Categorize files for atomic commits
            commit_groups = categorize_added_and_modified(
                list(changes.added), list(changes.modified)
            )

            if commit_groups:
                # Determine if we need force-add (for gitignored files)
                force_add = len(ignored_synced_files or []) > 0

                # Create atomic commits
                commit_result = create_atomic_commits(
                    target_path, commit_groups, force_add=force_add
                )

                if git_workflow_result:
                    git_workflow_result.commits_created = commit_result.commits_created
                    # Propagate error if commit creation failed
                    if not commit_result.success:
                        git_workflow_result.error = commit_result.error
                else:
                    git_workflow_result = commit_result

                if not options.quiet:
                    if commit_result.commits_created > 0:
                        logger.info(f"Created {commit_result.commits_created} atomic commit(s)")
                    elif not commit_result.success:
                        reporter.print_error(f"Commit creation failed: {commit_result.error}")

            # Create PR if requested - but only if we have commits to PR
            if options.create_pr and git_workflow_result and git_workflow_result.branch_name:
                # Bug fix: Don't create PR if no commits were created
                # This prevents the confusing "No commits between main and branch" error
                if git_workflow_result.commits_created == 0:
                    error_msg = (
                        "Cannot create PR: no commits were created. "
                        f"Reason: {git_workflow_result.error or 'Files may be gitignored'}"
                    )
                    reporter.print_error(error_msg)
                    return SyncResult(
                        status="error",
                        message=error_msg,
                        backup_path=backup_path,
                        git_workflow_result=git_workflow_result,
                        ignored_synced_files=ignored_synced_files,
                    )

                # Check gh is available
                if not check_gh_available():
                    reporter.print_error(
                        "gh CLI is required for --pr flag. Install: https://cli.github.com/"
                    )
                    return SyncResult(
                        status="error",
                        message="gh CLI not available",
                        backup_path=backup_path,
                        git_workflow_result=git_workflow_result,
                    )

                # Push branch first
                push_result = push_branch(target_path, git_workflow_result.branch_name)
                if not push_result.success:
                    reporter.print_error(f"Push failed: {push_result.error}")
                    return SyncResult(
                        status="error",
                        message=push_result.message,
                        backup_path=backup_path,
                        git_workflow_result=git_workflow_result,
                    )

                # Generate PR body
                pr_body = generate_pr_body(commit_groups, len(ignored_synced_files or []))

                # Create PR
                default_branch = get_default_branch(target_path)
                pr_result = create_pull_request(
                    target_path,
                    title="chore: update persuade configuration",
                    body=pr_body,
                    base_branch=default_branch,
                )

                if pr_result.success:
                    git_workflow_result.pr_url = pr_result.pr_url
                    if not options.quiet:
                        logger.info(f"Created PR: {pr_result.pr_url}")
                else:
                    reporter.print_error(f"PR creation failed: {pr_result.error}")
                    return SyncResult(
                        status="error",
                        message=f"PR creation failed: {pr_result.error}",
                        backup_path=backup_path,
                        git_workflow_result=git_workflow_result,
                        ignored_synced_files=ignored_synced_files,
                    )

        # Determine final status based on git workflow results
        # If git_commit was requested but failed (no commits created), this is a partial success
        git_workflow_failed = (
            options.git_commit
            and git_workflow_result
            and not git_workflow_result.success
            and git_workflow_result.commits_created == 0
        )

        if git_workflow_failed and git_workflow_result is not None:
            # Files were synced but git workflow failed
            status = "partial"
            message = f"Sync completed but git workflow failed: {git_workflow_result.error}"
            if not options.quiet:
                reporter.print_warning(
                    "Sync completed with warnings",
                    details={
                        "Files synced": len(changes.added) + len(changes.modified),
                        "Git workflow": f"Failed - {git_workflow_result.error}",
                    },
                )
        else:
            # Full success
            status = "success"
            message = "Sync completed successfully"
            if not options.quiet:
                reporter.print_success(
                    "Sync completed successfully!",
                    details={
                        "Files synced": len(changes.added) + len(changes.modified),
                        "Permissions merged": len(settings_diff.get("permissions_added", [])),
                        "Backup": backup_path.name if backup_path else "None",
                    },
                    gitignore_additions=gitignore_additions,
                    gitignore_violations=gitignore_violations,
                    target_path=target_path,
                    shadow_mode_enabled=options.shadow or options.editable,
                    shadow_untrack_commands=shadow_untrack_commands,
                )

        return SyncResult(
            status=status,
            message=message,
            backup_path=backup_path,
            files_synced=len(changes.added) + len(changes.modified),
            permissions_merged=len(settings_diff.get("permissions_added", [])),
            changes={"files": changes, "settings": settings_diff},
            gitignore_additions=gitignore_additions,
            gitignore_violations=gitignore_violations,
            shadow_mode_enabled=options.shadow or options.editable,
            shadow_untrack_commands=shadow_untrack_commands,
            git_workflow_result=git_workflow_result,
            ignored_synced_files=ignored_synced_files,
        )

    except (OSError, ValueError, BackupError) as e:
        reporter.print_error(f"Sync failed: {e}", exception=e if options.verbose else None)

        # Attempt rollback
        if backup_path and not options.no_backup:
            logger.info("Attempting rollback")
            try:
                backup_mgr = BackupManager(target_path)
                backup_mgr.restore_backup(backup_path.name)
                reporter.print_success("Rollback completed")
            except OSError as rollback_error:
                reporter.print_error(f"Rollback failed: {rollback_error}")

        return SyncResult(status="error", message=str(e), backup_path=backup_path)
