"""
koci Pipeline Parser

Parses koci.yml files into Pipeline objects with full validation.
"""

import os
from typing import Optional

import yaml
from jsonschema import validate, ValidationError as JsonSchemaValidationError

from .models import (
    Pipeline, Stage, Job, Step, Service, Artifact, MatrixConfig,
    ReportConfig, CoverageConfig
)
from .schema import KOCI_SCHEMA
from .exc import KociError


class ParserError(KociError):
    """Error during pipeline parsing."""
    pass


class ValidationError(KociError):
    """Error during pipeline validation."""
    pass


class PipelineParser:
    """
    Parser for koci.yml pipeline files.

    Handles:
    - YAML parsing and schema validation
    - Converting raw data to model objects
    - Matrix expansion
    - Dependency graph validation
    """

    DEFAULT_FILENAMES = ['koci.yml', 'koci.yaml', '.koci.yml', '.koci.yaml']

    def __init__(self, filepath: Optional[str] = None):
        """
        Initialize parser.

        Args:
            filepath: Path to koci.yml file. If None, searches current directory.
        """
        self.filepath = filepath
        self.raw_data: dict = {}
        self.pipeline: Optional[Pipeline] = None

    def find_pipeline_file(self, directory: str = '.') -> str:
        """
        Find the pipeline file in the given directory.

        Args:
            directory: Directory to search in

        Returns:
            Path to the pipeline file

        Raises:
            ParserError: If no pipeline file is found
        """
        if self.filepath:
            if os.path.exists(self.filepath):
                return self.filepath
            raise ParserError(f"Pipeline file not found: {self.filepath}")

        for filename in self.DEFAULT_FILENAMES:
            path = os.path.join(directory, filename)
            if os.path.exists(path):
                return path

        raise ParserError(
            f"No pipeline file found. Looked for: {', '.join(self.DEFAULT_FILENAMES)}"
        )

    def parse(self, directory: str = '.') -> Pipeline:
        """
        Parse the pipeline file.

        Args:
            directory: Directory to search for pipeline file

        Returns:
            Parsed Pipeline object
        """
        filepath = self.find_pipeline_file(directory)

        # Load YAML
        try:
            with open(filepath, 'r') as f:
                self.raw_data = yaml.safe_load(f)
        except yaml.YAMLError as e:
            raise ParserError(f"Invalid YAML in {filepath}: {e}")

        if not self.raw_data:
            raise ParserError(f"Empty pipeline file: {filepath}")

        # Validate against schema
        self._validate_schema()

        # Build pipeline object
        self.pipeline = self._build_pipeline()

        # Validate semantic rules
        self._validate_semantics()

        return self.pipeline

    def parse_string(self, content: str) -> Pipeline:
        """
        Parse pipeline from a string.

        Args:
            content: YAML content as string

        Returns:
            Parsed Pipeline object
        """
        try:
            self.raw_data = yaml.safe_load(content)
        except yaml.YAMLError as e:
            raise ParserError(f"Invalid YAML: {e}")

        if not self.raw_data:
            raise ParserError("Empty pipeline content")

        self._validate_schema()
        self.pipeline = self._build_pipeline()
        self._validate_semantics()

        return self.pipeline

    def _validate_schema(self):
        """Validate raw data against JSON schema."""
        try:
            validate(instance=self.raw_data, schema=KOCI_SCHEMA)
        except JsonSchemaValidationError as e:
            # Make error message more user-friendly
            path = ' -> '.join(str(p) for p in e.absolute_path) if e.absolute_path else 'root'
            raise ValidationError(f"Schema validation error at '{path}': {e.message}")

    def _build_pipeline(self) -> Pipeline:
        """Build Pipeline object from raw data."""
        data = self.raw_data

        # Parse stage names
        stage_names = data.get('stages', [])

        # Parse services per stage
        services_by_stage = self._parse_services(data.get('services', {}))

        # Parse jobs
        jobs_by_stage = self._parse_jobs(data.get('jobs', {}), data.get('defaults', {}))

        # Build Stage objects
        stages = []
        for stage_name in stage_names:
            stage = Stage(
                name=stage_name,
                jobs=jobs_by_stage.get(stage_name, []),
                services=services_by_stage.get(stage_name, [])
            )
            stages.append(stage)

        # Build Pipeline
        pipeline = Pipeline(
            name=data.get('name', 'koci-pipeline'),
            version=str(data.get('version', '1')),
            stages=stages,
            env=data.get('env', {}),
            defaults=data.get('defaults', {}),
            on=data.get('on', {})
        )

        return pipeline

    def _parse_services(self, services_data: dict) -> dict[str, list[Service]]:
        """Parse services by stage."""
        result = {}

        for stage_name, services in services_data.items():
            result[stage_name] = []
            for svc_data in services:
                service = Service(
                    image=svc_data['image'],
                    name=svc_data.get('name'),
                    env=svc_data.get('env', {}),
                    ports=svc_data.get('ports', []),
                    volumes=svc_data.get('volumes', []),
                    options=svc_data.get('options', []),
                    healthcheck=svc_data.get('healthcheck')
                )
                result[stage_name].append(service)

        return result

    def _parse_jobs(self, jobs_data: dict, defaults: dict) -> dict[str, list[Job]]:
        """Parse jobs grouped by stage."""
        result: dict[str, list[Job]] = {}

        for job_name, job_data in jobs_data.items():
            job = self._parse_job(job_name, job_data, defaults)

            stage = job.stage
            if stage not in result:
                result[stage] = []
            result[stage].append(job)

        return result

    def _parse_job(self, name: str, data: dict, defaults: dict) -> Job:
        """Parse a single job."""
        # Apply defaults
        image = data.get('image', defaults.get('image', 'alpine:latest'))
        working_dir = data.get('working_directory', defaults.get('working_directory', '/workspace'))
        timeout = data.get('timeout_minutes', defaults.get('timeout_minutes', 60))

        # Parse steps
        steps = []
        for step_data in data.get('steps', []):
            step = self._parse_step(step_data)
            steps.append(step)

        # Parse artifacts
        artifacts = None
        if 'artifacts' in data:
            art_data = data['artifacts']
            artifacts = Artifact(
                paths=art_data.get('paths', []),
                consume=art_data.get('consume', []),
                name=art_data.get('name'),
                retention_days=art_data.get('retention_days', 7)
            )

        # Parse matrix
        matrix = None
        if 'matrix' in data:
            matrix = MatrixConfig(
                variables=data['matrix'],
                include=data.get('matrix_include', []),
                exclude=data.get('matrix_exclude', [])
            )

        # Parse reports
        reports = None
        if 'reports' in data:
            reports = self._parse_reports(data['reports'])

        job = Job(
            name=name,
            stage=data['stage'],
            image=image,
            steps=steps,
            needs=data.get('needs', []),
            artifacts=artifacts,
            reports=reports,
            matrix=matrix,
            when=data.get('when'),
            condition=data.get('if'),
            env=data.get('env', {}),
            working_directory=working_dir,
            timeout_minutes=timeout,
            continue_on_error=data.get('continue_on_error', False),
            retry=data.get('retry', 0),
            parallel=data.get('parallel', False),
        )

        return job

    def _parse_reports(self, data: dict) -> ReportConfig:
        """Parse reports configuration."""
        junit = data.get('junit')

        coverage = None
        if 'coverage' in data:
            cov_data = data['coverage']
            if isinstance(cov_data, str):
                # Simple form: coverage: path/to/file.xml
                coverage = CoverageConfig(path=cov_data)
            else:
                # Full form with options
                coverage = CoverageConfig(
                    path=cov_data['path'],
                    format=cov_data.get('format', 'auto'),
                    threshold=cov_data.get('threshold'),
                )

        return ReportConfig(junit=junit, coverage=coverage)

    def _parse_step(self, data: dict) -> Step:
        """Parse a single step."""
        # Handle shorthand: just a string means run command
        if isinstance(data, str):
            return Step(run=data)

        return Step(
            run=data['run'],
            name=data.get('name'),
            working_directory=data.get('working_directory'),
            env=data.get('env', {}),
            shell=data.get('shell'),
            continue_on_error=data.get('continue_on_error', False),
            timeout_minutes=data.get('timeout_minutes')
        )

    def _validate_semantics(self):
        """Validate semantic rules beyond schema."""
        if not self.pipeline:
            return

        errors = []

        # Check all jobs reference valid stages
        stage_names = set(self.pipeline.get_stage_names())
        for job in self.pipeline.get_all_jobs():
            if job.stage not in stage_names:
                errors.append(f"Job '{job.name}' references unknown stage '{job.stage}'")

        # Check all 'needs' reference valid jobs
        all_job_names = set(j.name for j in self.pipeline.get_all_jobs())
        for job in self.pipeline.get_all_jobs():
            for needed in job.needs:
                if needed not in all_job_names:
                    errors.append(f"Job '{job.name}' needs unknown job '{needed}'")

        # Check artifact consumption references valid jobs
        for job in self.pipeline.get_all_jobs():
            if job.artifacts:
                for consume_from in job.artifacts.consume:
                    if consume_from not in all_job_names:
                        errors.append(
                            f"Job '{job.name}' consumes artifacts from "
                            f"unknown job '{consume_from}'"
                        )

        # Check for circular dependencies
        circular = self._detect_circular_dependencies()
        if circular:
            errors.append(f"Circular dependency detected: {' -> '.join(circular)}")

        # Check 'needs' jobs run before (in same or earlier stage)
        stage_order = {name: i for i, name in enumerate(self.pipeline.get_stage_names())}
        for job in self.pipeline.get_all_jobs():
            job_stage_idx = stage_order.get(job.stage, 999)
            for needed in job.needs:
                needed_job = self.pipeline.get_job(needed)
                if needed_job:
                    needed_stage_idx = stage_order.get(needed_job.stage, 999)
                    if needed_stage_idx > job_stage_idx:
                        errors.append(
                            f"Job '{job.name}' (stage '{job.stage}') needs job "
                            f"'{needed}' which runs in later stage '{needed_job.stage}'"
                        )

        if errors:
            raise ValidationError("Pipeline validation failed:\n  - " + "\n  - ".join(errors))

    def _detect_circular_dependencies(self) -> Optional[list[str]]:
        """Detect circular dependencies in job graph."""
        if not self.pipeline:
            return None

        # Build adjacency list
        graph = {}
        for job in self.pipeline.get_all_jobs():
            graph[job.name] = job.needs.copy()

        # DFS to detect cycle
        visited = set()
        rec_stack = set()
        path = []

        def dfs(node: str) -> Optional[list[str]]:
            visited.add(node)
            rec_stack.add(node)
            path.append(node)

            for neighbor in graph.get(node, []):
                if neighbor not in visited:
                    result = dfs(neighbor)
                    if result:
                        return result
                elif neighbor in rec_stack:
                    # Found cycle
                    cycle_start = path.index(neighbor)
                    return path[cycle_start:] + [neighbor]

            path.pop()
            rec_stack.remove(node)
            return None

        for node in graph:
            if node not in visited:
                cycle = dfs(node)
                if cycle:
                    return cycle

        return None


def parse_pipeline(filepath: Optional[str] = None, directory: str = '.') -> Pipeline:
    """
    Convenience function to parse a pipeline.

    Args:
        filepath: Optional explicit path to pipeline file
        directory: Directory to search for pipeline file

    Returns:
        Parsed Pipeline object
    """
    parser = PipelineParser(filepath)
    return parser.parse(directory)


def parse_pipeline_string(content: str) -> Pipeline:
    """
    Convenience function to parse a pipeline from string.

    Args:
        content: YAML content

    Returns:
        Parsed Pipeline object
    """
    parser = PipelineParser()
    return parser.parse_string(content)
