import io
import json
import platform
import traceback
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional

from mashumaro.config import BaseConfig
from mashumaro.mixins.json import DataClassJSONMixin
from py_app_dev.core.exceptions import UserNotificationException
from py_app_dev.core.logging import logger
from py_app_dev.core.scoop_wrapper import ScoopFileElement, ScoopWrapper
from pypeline.domain.pipeline import PipelineStep

from yanga.domain.config import ScoopManifest
from yanga.domain.execution_context import ExecutionContext


@dataclass
class ScoopInstallExecutionInfo(DataClassJSONMixin):
    install_dirs: list[Path] = field(default_factory=list)
    env_vars: dict[str, Any] = field(default_factory=dict)

    class Config(BaseConfig):
        # Make sure to omit None values during serialization
        omit_none = True

    @classmethod
    def from_json_file(cls, file_path: Path) -> "ScoopInstallExecutionInfo":
        try:
            result = cls.from_dict(json.loads(file_path.read_text()))
        except Exception as e:
            output = io.StringIO()
            traceback.print_exc(file=output)
            raise UserNotificationException(output.getvalue()) from e
        return result

    def to_json_string(self) -> str:
        return json.dumps(self.to_dict(), indent=2)

    def to_json_file(self, file_path: Path) -> None:
        file_path.parent.mkdir(parents=True, exist_ok=True)
        file_path.write_text(self.to_json_string())


def create_scoop_wrapper() -> ScoopWrapper:
    return ScoopWrapper()


class ScoopInstall(PipelineStep[ExecutionContext]):
    def __init__(self, execution_context: ExecutionContext, group_name: str, config: Optional[dict[str, Any]] = None) -> None:
        super().__init__(execution_context, group_name, config)
        self.logger = logger.bind()
        self.artifacts_locator = execution_context.create_artifacts_locator()
        self._collected_dependencies: Optional[ScoopManifest] = None
        self.execution_info = ScoopInstallExecutionInfo()
        # One needs to keep track of the installed apps to get the required paths
        # even if the step does not need to run.
        self.execution_info_file = self.output_dir.joinpath("scoop_install_exec_info.json")

    @property
    def output_dir(self) -> Path:
        if self.execution_context.variant:
            return self.execution_context.create_artifacts_locator().variant_build_dir
        else:
            return self.artifacts_locator.build_dir

    def get_name(self) -> str:
        return self.__class__.__name__

    @property
    def install_dirs(self) -> list[Path]:
        return self.execution_info.install_dirs

    @property
    def scoop_manifest_file(self) -> Path:
        """Generated scoopfile.json file path in variant-specific directory."""
        return self.artifacts_locator.variant_build_dir.joinpath("scoopfile.json")

    @property
    def global_scoop_manifest_file(self) -> Path:
        """Global scoopfile.json file path (if exists)."""
        return self.project_root_dir.joinpath("scoopfile.json")

    def _collect_dependencies(self) -> ScoopManifest:
        """Collect Scoop dependencies from global, platform, and variant configurations."""
        collected_manifest = ScoopManifest()

        # Start with global scoopfile.json if it exists
        if self.global_scoop_manifest_file.exists():
            try:
                global_manifest_file = ScoopManifest.from_file(self.global_scoop_manifest_file)

                # Merge buckets
                self._merge_buckets(collected_manifest, global_manifest_file.buckets)

                # Merge apps
                self._merge_apps(collected_manifest, global_manifest_file.apps)

            except Exception as e:
                self.logger.warning(f"Failed to parse global scoopfile.json: {e}")

        # Add platform dependencies
        if self.execution_context.platform and self.execution_context.platform.scoop_manifest:
            platform_manifest = self.execution_context.platform.scoop_manifest

            # Merge buckets
            self._merge_buckets(collected_manifest, platform_manifest.buckets)

            # Merge apps
            self._merge_apps(collected_manifest, platform_manifest.apps)

        # Add variant dependencies
        if self.execution_context.variant and self.execution_context.variant.scoop_manifest:
            variant_manifest = self.execution_context.variant.scoop_manifest

            # Merge buckets
            self._merge_buckets(collected_manifest, variant_manifest.buckets)

            # Merge apps
            self._merge_apps(collected_manifest, variant_manifest.apps)

        return collected_manifest

    def _merge_buckets(self, target_manifest: ScoopManifest, source_buckets: list[ScoopFileElement]) -> None:
        """Merge buckets, handling conflicts when same name has different sources."""
        for bucket in source_buckets:
            # Check if a bucket with this name already exists
            existing_bucket = next((b for b in target_manifest.buckets if b.name == bucket.name), None)

            if existing_bucket is None:
                # No conflict, add the bucket
                target_manifest.buckets.append(bucket)
            elif existing_bucket.source != bucket.source:
                # Conflict: same name, different source
                self.logger.warning(
                    f"Bucket '{bucket.name}' defined multiple times with different sources:\n"
                    f"  Existing: {existing_bucket.source}\n"
                    f"  New: {bucket.source}\n"
                    f"  Keeping existing definition."
                )
                # Keep the first definition (existing_bucket), ignore the new one

    def _merge_apps(self, target_manifest: ScoopManifest, source_apps: list[ScoopFileElement]) -> None:
        """Merge apps, avoiding duplicates."""
        for app in source_apps:
            if app not in target_manifest.apps:
                target_manifest.apps.append(app)

    def _generate_scoop_manifest(self, manifest: ScoopManifest) -> None:
        """Generate scoopfile.json file from collected dependencies."""
        if not manifest.buckets and not manifest.apps:
            self.logger.info("No Scoop dependencies found. Skipping scoopfile.json generation.")
            return

        # Ensure build directory exists
        self.scoop_manifest_file.parent.mkdir(parents=True, exist_ok=True)
        self.scoop_manifest_file.write_text(manifest.to_json_string())

        self.logger.info(f"Generated scoopfile.json with {len(manifest.buckets)} buckets and {len(manifest.apps)} apps")

    def run(self) -> int:
        self.logger.debug(f"Run {self.get_name()} step. Output dir: {self.output_dir}")

        if platform.system() != "Windows":
            self.logger.warning(f"ScoopInstall step is only supported on Windows. Skipping. Current platform: {platform.system()}")
            return 0

        try:
            # Collect dependencies from all sources
            self._collected_dependencies = self._collect_dependencies()

            # Generate the combined scoopfile.json file
            self._generate_scoop_manifest(self._collected_dependencies)

            # Only proceed with scoop commands if we have dependencies
            if not self._collected_dependencies.apps:
                self.logger.info("No Scoop dependencies to install.")
                return 0

            # Install scoop dependencies using the generated manifest file
            installed_apps = create_scoop_wrapper().install(self.scoop_manifest_file)

            self.logger.debug("Installed apps:")
            for app in installed_apps:
                self.logger.debug(f" - {app.name} ({app.version})")
                self.execution_info.install_dirs.extend(app.get_all_required_paths())
                # Collect environment variables from each app
                self.execution_info.env_vars.update(app.env_vars)

            # Save execution info for dependency tracking
            self.execution_info.to_json_file(self.execution_info_file)

        except Exception as e:
            raise UserNotificationException(f"Failed to install scoop dependencies: {e}") from e

        return 0

    def get_inputs(self) -> list[Path]:
        inputs = []

        # Add global scoopfile.json if it exists
        if self.global_scoop_manifest_file.exists():
            inputs.append(self.global_scoop_manifest_file)

        # Add user config files (platform and variant configs are in there)
        inputs.extend(self.execution_context.user_config_files)

        return inputs

    def get_outputs(self) -> list[Path]:
        outputs = [self.scoop_manifest_file, self.execution_info_file]

        # Add tracked install directories if available (after run() has been called)
        if self.execution_info.install_dirs:
            outputs.extend(self.execution_info.install_dirs)

        return outputs

    def update_execution_context(self) -> None:
        if self.execution_info_file.exists():
            execution_info = ScoopInstallExecutionInfo.from_json_file(self.execution_info_file)
            # Make the list unique and keep the order
            unique_paths = list(dict.fromkeys(execution_info.install_dirs))
            # Update the install directories for the subsequent steps
            self.execution_context.add_install_dirs(unique_paths)
            if execution_info.env_vars:
                self.execution_context.add_env_vars(execution_info.env_vars)
