"""Service for interacting with the openspec CLI."""

import asyncio
import json
from datetime import datetime
from pathlib import Path

from hangar.models.openspec import (
    OpenSpecArtifactContent,
    OpenSpecChangeList,
    OpenSpecChangeSummary,
    OpenSpecChangeStatus,
)
from hangar.validation import validate_change_name, validate_path_containment


class OpenSpecService:
    """Wraps the openspec CLI for spec browsing."""

    def __init__(self, project_path: Path) -> None:
        self.project_path = project_path
        self.changes_dir = project_path / "openspec" / "changes"
        self.archive_dir = project_path / "openspec" / "changes" / "archive"

    async def _run_openspec(self, *args: str) -> str:
        """Run an openspec CLI command and return stdout."""
        proc = await asyncio.create_subprocess_exec(
            "openspec",
            *args,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            cwd=self.project_path,
        )
        stdout, stderr = await proc.communicate()

        if proc.returncode != 0:
            error_msg = stderr.decode().strip()
            raise RuntimeError(f"openspec {' '.join(args)} failed: {error_msg}")

        return stdout.decode()

    @staticmethod
    def _extract_json(raw: str) -> dict | list:
        """Extract JSON from openspec output (may contain non-JSON prefix lines)."""
        # Find the first { or [ character
        for i, ch in enumerate(raw):
            if ch in "{[":
                try:
                    return json.loads(raw[i:])
                except json.JSONDecodeError as e:
                    raise RuntimeError(
                        f"Invalid JSON in openspec output: {raw[i:i+200]}"
                    ) from e
        raise ValueError(f"No JSON found in output: {raw[:200]}")

    async def list_changes(self) -> OpenSpecChangeList:
        """List all changes — both active (from CLI) and archived (from filesystem)."""
        output = await self._run_openspec("list", "--json")
        data = self._extract_json(output)
        result = OpenSpecChangeList.from_json(data)

        # Mark active changes explicitly
        for change in result.changes:
            change.is_archived = False

        # Scan archive directory for archived changes
        if self.archive_dir.is_dir():
            for entry in sorted(self.archive_dir.iterdir()):
                if entry.is_dir() and not entry.name.startswith("."):
                    archived = OpenSpecChangeSummary(
                        name=entry.name,
                        completedTasks=0,
                        totalTasks=0,
                        status="archived",
                        is_archived=True,
                    )
                    # Try to read last modified time from the directory
                    try:
                        archived.last_modified = datetime.fromtimestamp(
                            entry.stat().st_mtime
                        )
                    except OSError:
                        pass
                    result.changes.append(archived)

        return result

    async def show_change(self, name: str) -> OpenSpecChangeStatus:
        """Show change status via openspec status --change <name> --json."""
        output = await self._run_openspec("status", "--change", name, "--json")
        data = self._extract_json(output)
        return OpenSpecChangeStatus.model_validate(data)

    def _resolve_change_dir(self, change_name: str) -> Path:
        """Resolve a change directory, checking both active and archive locations."""
        validate_change_name(change_name)
        active_dir = self.changes_dir / change_name
        if active_dir.is_dir():
            return active_dir
        archive_dir = self.archive_dir / change_name
        if archive_dir.is_dir():
            return archive_dir
        # Default to active dir (will fail naturally if not found)
        return active_dir

    async def read_artifact(self, change_name: str, artifact_path: str) -> OpenSpecArtifactContent:
        """Read an artifact's markdown content from disk (checks both active and archive)."""
        change_dir = self._resolve_change_dir(change_name)

        # Handle glob patterns like specs/**/*.md by finding all matching files
        if "*" in artifact_path:
            import glob

            # Validate the non-glob prefix stays contained
            safe_prefix = artifact_path.split("*")[0]
            if safe_prefix:
                validate_path_containment(change_dir, safe_prefix)

            pattern = str(change_dir / artifact_path)
            files = sorted(glob.glob(pattern, recursive=True))
            content = ""
            for f in files:
                file_path = Path(f)
                # Verify each matched file is within the change dir
                validate_path_containment(change_dir, str(file_path.relative_to(change_dir.resolve())))
                relative = file_path.relative_to(change_dir)
                file_content = file_path.read_text()
                content += f"<!-- {relative} -->\n{file_content}\n\n"
        else:
            validate_path_containment(change_dir, artifact_path)
            file_path = change_dir / artifact_path
            if not file_path.exists():
                content = ""
            else:
                content = file_path.read_text()

        # Derive artifact ID from path
        artifact_id = artifact_path.split("/")[0].replace(".md", "")

        return OpenSpecArtifactContent(
            id=artifact_id,
            output_path=artifact_path,
            status="done" if content else "missing",
            content=content,
        )

    # Known artifact IDs and their typical file paths
    _KNOWN_ARTIFACT_PATHS = {
        "proposal": "proposal.md",
        "design": "design.md",
        "specs": "specs/**/*.md",
        "tasks": "tasks.md",
    }

    async def read_all_artifacts(self, change_name: str) -> list[OpenSpecArtifactContent]:
        """Read all artifacts for a change.

        Tries the openspec CLI first (for active changes), then falls back
        to filesystem scanning (for archived or CLI-incompatible changes).
        """
        try:
            status = await self.show_change(change_name)
            artifacts = []
            for artifact in status.artifacts:
                content = await self.read_artifact(change_name, artifact.output_path)
                artifacts.append(content)
            return artifacts
        except RuntimeError:
            # CLI failed (e.g. archived change with date-prefix name)
            # Fall back to filesystem scan
            return self._read_artifacts_from_disk(change_name)

    def _read_artifacts_from_disk(self, change_name: str) -> list[OpenSpecArtifactContent]:
        """Read artifacts by scanning the change directory on disk."""
        change_dir = self._resolve_change_dir(change_name)
        artifacts = []
        for artifact_id, path_pattern in self._KNOWN_ARTIFACT_PATHS.items():
            try:
                content_obj = self._read_artifact_sync(change_dir, artifact_id, path_pattern)
                if content_obj.content:
                    artifacts.append(content_obj)
            except Exception:
                continue
        return artifacts

    @staticmethod
    def _read_artifact_sync(
        change_dir: Path, artifact_id: str, path_pattern: str
    ) -> OpenSpecArtifactContent:
        """Read a single artifact from disk (sync, no CLI)."""
        if "*" in path_pattern:
            import glob

            pattern = str(change_dir / path_pattern)
            files = sorted(glob.glob(pattern, recursive=True))
            content = ""
            for f in files:
                file_path = Path(f)
                relative = file_path.relative_to(change_dir)
                content += f"<!-- {relative} -->\n{file_path.read_text()}\n\n"
        else:
            file_path = change_dir / path_pattern
            content = file_path.read_text() if file_path.exists() else ""

        return OpenSpecArtifactContent(
            id=artifact_id,
            output_path=path_pattern,
            status="done" if content else "missing",
            content=content,
        )
