"""Workspace orchestrator: discovery, refresh loop, forge integration, and repo actions."""

from __future__ import annotations

import asyncio
from datetime import datetime
from enum import Enum
from pathlib import Path

from .cache import Cache
from .config import Config, load_config, save_config
from .discovery import discover_repos
from .events import diff_mrs, diff_pipelines, diff_vulns, filter_events
from .models import Event, EventType, Repo
from ..git.ops import check_pull_status_sync, fetch_all, fetch_one, git_status, pull_safe_all, pull_safe_one
from ..gitlab.auth import resolve_token
from ..gitlab.client import AuthenticationError, GitLabClient


class WorkspaceState(str, Enum):
    """Dormancy states based on .project-hub / .no-project-hub presence."""
    ACTIVE = "active"
    DISABLED_OPT_OUT = "disabled_opt_out"    # .no-project-hub
    DISABLED_SUB_REPO = "disabled_sub_repo"  # parent has .project-hub
    DORMANT = "dormant"


def detect_state(cwd: Path) -> WorkspaceState:
    """Determine workspace state from filesystem markers in cwd and its parent."""
    # .no-project-hub wins over everything
    if (cwd / ".no-project-hub").exists():
        return WorkspaceState.DISABLED_OPT_OUT
    # .project-hub/ in CWD = active
    if (cwd / ".project-hub").is_dir():
        return WorkspaceState.ACTIVE
    # .project-hub/ in immediate parent = disabled (we're inside a sub-repo)
    parent = cwd.parent
    if parent != cwd and (parent / ".project-hub").is_dir():
        return WorkspaceState.DISABLED_SUB_REPO
    # Nothing found = dormant
    return WorkspaceState.DORMANT


class Workspace:
    """Central orchestrator: manage repos, cache, forge clients, and refresh cycles."""

    def __init__(self, root: Path):
        self.root = root
        self.config: Config
        self.cache: Cache | None = None
        self.repos: list[Repo] = []
        self._gitlab_clients: dict[str, GitLabClient] = {}
        self._usernames: dict[str, str] = {}  # forge_host -> username
        self._sync_status: str = "idle"  # "idle" | "in_progress"
        self._refresh_running: bool = False
        self._refresh_task: asyncio.Task | None = None

    async def start(self, drop_cache: bool = False) -> None:
        """Initialize cache, discover repos, create GitLab clients."""
        self.config = load_config(self.root / ".project-hub" / "config.yaml")
        self.cache = Cache(self.root / ".project-hub" / "cache.db")
        await self.cache.initialize(drop_cache=drop_cache)
        self.repos = discover_repos(self.root, self.config)
        self._init_gitlab_clients()

    def _init_gitlab_clients(self) -> None:
        """Create one GitLabClient per unique forge host.

        Repos whose host has no token get downgraded to has_forge=False.
        Also resolves the authenticated username for each client.
        """
        hosts = {r.forge_host for r in self.repos if r.has_forge and r.forge_host}
        for host in hosts:
            token = resolve_token(host)
            if token:
                self._gitlab_clients[host] = GitLabClient(host, token)
            else:
                for r in self.repos:
                    if r.forge_host == host:
                        r.has_forge = False
        for host, client in self._gitlab_clients.items():
            username = client.get_username()
            if username:
                self._usernames[host] = username

    @property
    def sync_status(self) -> str:
        return self._sync_status

    async def refresh_once(self, notify_callback=None) -> None:
        """Single refresh cycle. No-op if already running."""
        if self._refresh_running:
            return
        self._refresh_running = True
        try:
            await self._do_refresh(notify_callback)
        finally:
            self._refresh_running = False

    async def _do_refresh(self, notify_callback=None) -> None:
        """Fetch git + forge data, diff against cache, store, notify."""
        import logging

        logger = logging.getLogger(__name__)

        self._sync_status = "in_progress"
        try:
            await self._do_refresh_inner(logger, notify_callback)
        finally:
            self._sync_status = "idle"

    async def _do_refresh_inner(self, logger, notify_callback=None) -> None:
        """Run the full refresh pipeline: fetch, pull status, stale detection, forge data, diff, store."""
        repo_paths = [Path(r.path) for r in self.repos]

        # 1. Git fetch all (parallel)
        logger.info(f"Starting git fetch for {len(repo_paths)} repos...")
        await fetch_all(repo_paths)
        logger.info("Git fetch completed")

        # 1b. Pull status computation (parallel)
        pull_results = await asyncio.gather(
            *(asyncio.to_thread(check_pull_status_sync, Path(r.path)) for r in self.repos)
        )
        for repo, ps in zip(self.repos, pull_results):
            if ps is not None:
                await self.cache.store_pull_status(repo.name, ps)

        # 1c. Stale branch detection (parallel)
        stale_results = await asyncio.gather(
            *(asyncio.to_thread(git_status, Path(r.path)) for r in self.repos),
            return_exceptions=True,
        )
        # Collect repos with newly stale branches (not already alerted, even if seen)
        already_stale = await self.cache.get_events_by_type(EventType.BRANCH_STALE.value)
        stale_events: list[Event] = []
        for repo, st in zip(self.repos, stale_results):
            if isinstance(st, Exception):
                continue
            if st.stale and st.branch and (repo.name, st.branch) not in already_stale:
                stale_events.append(Event(
                    id=None,
                    type=EventType.BRANCH_STALE,
                    repo_name=repo.name,
                    summary=f"Branch '{st.branch}' is stale (remote deleted)",
                    detail=st.branch,
                    created_at=datetime.now(),
                ))

        # 2. Forge data (parallel, bounded concurrency)
        semaphore = asyncio.Semaphore(10)
        auth_events: list[Event] = []
        forge_tasks = []

        for repo in self.repos:
            if not repo.has_forge or repo.forge_host not in self._gitlab_clients:
                continue
            client = self._gitlab_clients[repo.forge_host]
            forge_tasks.append(
                self._fetch_forge_for_repo(repo, client, semaphore, auth_events)
            )

        logger.info(f"Fetching forge data for {len(forge_tasks)} repos...")
        results = await asyncio.gather(*forge_tasks, return_exceptions=True)
        logger.info("Forge data fetch completed")

        # 3. Diff with cache -> generate events
        all_events: list[Event] = list(auth_events) + stale_events
        total_mrs, total_pipelines, total_vulns = 0, 0, 0

        for result in results:
            if isinstance(result, Exception):
                logger.error(f"Exception during forge fetch: {result}")
                continue
            if result is None:
                continue  # auth failure — already recorded in auth_events
            repo_name, mrs, pipelines, vulns = result
            events, counts = await self._diff_and_store(repo_name, mrs, pipelines, vulns, logger)
            all_events.extend(events)
            total_mrs += counts[0]
            total_pipelines += counts[1]
            total_vulns += counts[2]

        logger.info(
            f"Refresh complete: {total_mrs} MRs, {total_pipelines} pipelines, "
            f"{total_vulns} vulns, {len(all_events)} events"
        )

        if all_events:
            await self.cache.store_events(all_events)
            if notify_callback:
                await notify_callback()

        # Update last refresh timestamp + flush WAL to main DB
        await self.cache.set_metadata("last_refresh", datetime.now().isoformat())
        await self.cache.checkpoint()

    async def _diff_and_store(self, repo_name, mrs, pipelines, vulns, logger):
        """Diff new forge data against cache, store, return (events, (mr_count, pip_count, vuln_count))."""
        old_mrs = await self.cache.get_mrs(repo_name)
        old_pipelines = await self.cache.get_pipelines(repo_name)
        old_vulns = await self.cache.get_vulns(repo_name)

        logger.debug(f"{repo_name}: {len(mrs)} MRs, {len(pipelines)} pipelines, {len(vulns)} vulns")

        repo_events = (
            diff_mrs(old_mrs, mrs, repo_name)
            + diff_pipelines(old_pipelines, pipelines, repo_name)
            + diff_vulns(old_vulns, vulns, repo_name)
        )
        repo_obj = next((r for r in self.repos if r.name == repo_name), None)
        username = self._usernames.get(repo_obj.forge_host, "") if repo_obj and repo_obj.forge_host else ""
        repo_events = filter_events(
            repo_events,
            self.config.watch_mrs_mode,
            self.config.watch_pipelines_mode,
            self.config.watch_vulns_mode,
            username,
            mrs,
            pipelines,
        )

        await self.cache.store_mrs(repo_name, mrs)
        await self.cache.store_pipelines(repo_name, pipelines)
        await self.cache.store_vulns(repo_name, vulns)

        return repo_events, (len(mrs), len(pipelines), len(vulns))

    async def _fetch_forge_for_repo(
        self,
        repo: Repo,
        client: GitLabClient,
        semaphore: asyncio.Semaphore,
        auth_events: list[Event],
    ) -> tuple[str, list, list, list]:
        """Fetch MR/pipeline/vuln data for one repo with semaphore."""
        async with semaphore:
            try:
                # Fetch default branch (lightweight, one API call)
                if repo.default_branch is None:
                    db = await asyncio.to_thread(
                        client.get_default_branch, repo.project_path
                    )
                    if db:
                        repo.default_branch = db

                mrs = await asyncio.to_thread(
                    client.list_merge_requests, repo.project_path
                )
                for mr in mrs:
                    mr.repo_name = repo.name
                pipelines = await asyncio.to_thread(
                    client.list_pipelines, repo.project_path
                )
                for p in pipelines:
                    p.repo_name = repo.name

                # Ensure MR branches have pipeline coverage (they may fall
                # outside the top-20 window returned by the unfiltered call)
                seen_refs = {p.ref for p in pipelines}
                mr_branches = {m.source_branch for m in mrs} - seen_refs
                seen_ids = {p.id for p in pipelines}
                for branch in mr_branches:
                    extra = await asyncio.to_thread(
                        client.list_pipelines, repo.project_path, branch, 5
                    )
                    for p in extra:
                        if p.id not in seen_ids:
                            p.repo_name = repo.name
                            pipelines.append(p)
                            seen_ids.add(p.id)
                vulns = await asyncio.to_thread(
                    client.get_vulnerabilities, repo.project_path
                )
                for v in vulns:
                    v.repo_name = repo.name

                # Reconcile: mark vulns fixed in current branch
                if vulns and repo.default_branch:
                    try:
                        st = await asyncio.to_thread(git_status, Path(repo.path))
                        if st.branch and st.branch != repo.default_branch:
                            # Find latest pipeline for current branch
                            branch_pip = next(
                                (p for p in pipelines if p.ref == st.branch), None
                            )
                            if branch_pip:
                                finding_ids = await asyncio.to_thread(
                                    client.get_pipeline_findings,
                                    repo.project_path,
                                    branch_pip.id,
                                )
                                if finding_ids:
                                    for v in vulns:
                                        if v.name not in finding_ids:
                                            v.fixed_in_branch = True
                    except Exception:
                        pass  # reconciliation is best-effort

                return repo.name, mrs, pipelines, vulns
            except AuthenticationError:
                auth_events.append(
                    Event(
                        id=None,
                        type=EventType.AUTH_EXPIRED,
                        repo_name=repo.name,
                        summary=f"Authentication failed for {repo.forge_host}",
                        detail="",
                        created_at=datetime.now(),
                    )
                )
                return None  # skip diff+store — don't purge cache on auth failure

    async def refresh_loop(self, notify_callback=None) -> None:
        """Periodic refresh: immediate first cycle, then every cache_ttl seconds."""
        while True:
            await self.refresh_once(notify_callback)
            await asyncio.sleep(self.config.cache_ttl)

    async def stop(self) -> None:
        """Cancel refresh task and close cache."""
        if self._refresh_task:
            self._refresh_task.cancel()
            try:
                await self._refresh_task
            except asyncio.CancelledError:
                pass
        if self.cache is not None:
            await self.cache.close()

    def get_state(self) -> dict:
        """Return current workspace state for the web UI."""
        return {"repos": self.repos, "ready": True}

    @staticmethod
    def _build_repo_entry(r, mrs, pipelines, vulns, pull_statuses) -> dict:
        """Build a dict for one repo combining forge data, git status, and pull state."""
        from ..git.ops import git_status, ahead_behind_ref
        inactive = {"resolved", "dismissed"}
        entry = {
            **r.to_dict(),
            "mr_count": len(mrs),
            "pipeline_status": pipelines[0].status if pipelines else None,
            "vuln_critical": sum(
                1 for v in vulns
                if v.severity == "critical" and v.state not in inactive and not v.fixed_in_branch
            ),
            "vuln_high": sum(
                1 for v in vulns
                if v.severity == "high" and v.state not in inactive and not v.fixed_in_branch
            ),
            "branch": None, "ahead": 0, "behind": 0,
            "default_ahead": 0, "default_behind": 0,
            "stale": False,
            "pull_status": pull_statuses.get(r.name),
        }
        try:
            git_st = git_status(Path(entry["path"]))
            entry["branch"] = git_st.branch
            entry["ahead"] = git_st.ahead
            entry["behind"] = git_st.behind
            entry["stale"] = git_st.stale
            if r.default_branch and git_st.branch and git_st.branch != r.default_branch:
                da, db = ahead_behind_ref(Path(entry["path"]), f"origin/{r.default_branch}")
                entry["default_ahead"] = da
                entry["default_behind"] = db
        except Exception:
            pass
        return entry

    def get_state_dict(self) -> dict:
        """Return workspace state dict for the web UI (standalone mode).

        Includes repo list plus cached MR/pipeline/vuln/event data.
        Cache reads use synchronous sqlite3 — this method is intended
        to be called from a sync context passed to create_app().
        """
        from collections import defaultdict

        mrs = self.cache.get_mrs_sync(None)
        pipelines = self.cache.get_pipelines_sync(None)
        vulns = self.cache.get_vulns_sync(None)
        events = self.cache.get_unseen_events_sync()
        pull_statuses = self.cache.get_all_pull_statuses_sync()
        pinned = self.cache.get_pinned_sync()
        repo_order = self.cache.get_repo_order_sync()

        # Group by repo
        repo_mrs: dict[str, list] = defaultdict(list)
        repo_pipelines: dict[str, list] = defaultdict(list)
        repo_vulns: dict[str, list] = defaultdict(list)
        for mr in mrs:
            repo_mrs[mr.repo_name].append(mr)
        for p in pipelines:
            repo_pipelines[p.repo_name].append(p)
        for v in vulns:
            repo_vulns[v.repo_name].append(v)

        # Build per-repo entries
        repos_data = [
            self._build_repo_entry(r, repo_mrs[r.name], repo_pipelines[r.name],
                                   repo_vulns[r.name], pull_statuses)
            for r in self.repos if Path(r.path).is_dir()
        ]

        # Sort: pinned first, then custom order, then alphabetical
        def sort_key(name):
            if name in pinned:
                return (0, pinned.index(name))
            if repo_order and name in repo_order:
                return (1, repo_order.index(name))
            return (1, 999, name)

        repos_data.sort(key=lambda r: sort_key(r["name"]))

        return {
            "ready": True,
            "repos": repos_data,
            "sync_status": self._sync_status,
            "usernames": dict(self._usernames),
            "pinned": pinned,
            "repo_order": repo_order,
            "excluded": self.config.exclude,
            "forges": self.config.forges,
            "mrs": [mr.to_dict() for mr in mrs],
            "pipelines": [p.to_dict() for p in pipelines],
            "vulns": [v.to_dict() for v in vulns],
            "events": [e.to_dict() for e in events],
            "last_refresh": self.cache.get_metadata_sync("last_refresh"),
        }

    # --- Per-repo actions ---

    def _find_repo(self, name: str):
        return next((r for r in self.repos if r.name == name), None)

    async def fetch_repo(self, repo_name: str) -> None:
        """Run git fetch on a single repo by name."""
        repo = self._find_repo(repo_name)
        if repo:
            await fetch_one(Path(repo.path))

    async def pull_repo(self, repo_name: str) -> dict:
        """Pull a single repo safely and refresh its pull status cache."""
        repo = self._find_repo(repo_name)
        if not repo:
            return {"error": f"repo {repo_name} not found"}
        result = await pull_safe_one(Path(repo.path), self.config.pull_strategy)
        # Refresh pull_status cache after pull
        ps = await asyncio.to_thread(check_pull_status_sync, Path(repo.path))
        if ps is not None:
            await self.cache.store_pull_status(repo.name, ps)
        return {"repo": repo_name, "status": result.status.value, "message": result.message}

    async def pull_all(self) -> list[dict]:
        """Pull all repos safely."""
        repo_paths = [Path(r.path) for r in self.repos]
        results = await pull_safe_all(repo_paths, strategy=self.config.pull_strategy)
        # Refresh pull_status cache for all repos
        ps_results = await asyncio.gather(
            *(asyncio.to_thread(check_pull_status_sync, Path(r.path)) for r in self.repos)
        )
        for repo, ps in zip(self.repos, ps_results):
            if ps is not None:
                await self.cache.store_pull_status(repo.name, ps)
        return [
            {"repo": r.repo, "status": r.status.value, "message": r.message}
            for r in results
        ]

    async def checkout_default_all(self) -> list[dict]:
        """Checkout default branch on all stale repos."""
        results = []
        for repo in self.repos:
            if not repo.default_branch:
                continue
            try:
                st = git_status(Path(repo.path))
            except Exception:
                continue
            if not st.stale:
                continue
            proc = await asyncio.create_subprocess_exec(
                "git", "checkout", repo.default_branch,
                cwd=repo.path,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            _, stderr = await proc.communicate()
            if proc.returncode == 0:
                results.append({"repo": repo.name, "status": "ok", "branch": repo.default_branch})
            else:
                results.append({"repo": repo.name, "status": "error", "message": stderr.decode().strip()})
        return results

    async def checkout_default_one(self, repo_name: str) -> dict:
        """Checkout default branch on a single repo."""
        repo = self._find_repo(repo_name)
        if not repo:
            return {"error": f"repo {repo_name} not found"}
        if not repo.default_branch:
            return {"error": f"no default branch known for {repo_name}"}
        proc = await asyncio.create_subprocess_exec(
            "git", "checkout", repo.default_branch,
            cwd=repo.path,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        _, stderr = await proc.communicate()
        if proc.returncode == 0:
            return {"repo": repo_name, "status": "ok", "branch": repo.default_branch}
        return {"repo": repo_name, "status": "error", "message": stderr.decode().strip()}

    async def get_pipeline_jobs_web(self, repo_name: str, pipeline_id: int) -> list[dict]:
        """Fetch pipeline jobs for the web UI via the forge client."""
        repo = self._find_repo(repo_name)
        if not repo or not repo.has_forge:
            return []
        client = self._gitlab_clients.get(repo.forge_host)
        if not client:
            return []
        jobs = await asyncio.to_thread(client.get_pipeline_jobs, repo.project_path, pipeline_id)
        return [j.to_dict() for j in jobs]

    def web_app_kwargs(self) -> dict:
        """Return kwargs dict for web.app.create_app()."""
        return dict(
            get_state=self.get_state_dict,
            trigger_refresh=self.refresh_once,
            dismiss_event=lambda eid: self.cache.mark_events_seen([eid]),
            dismiss_all_events=self.cache.mark_all_events_seen,
            fetch_repo=self.fetch_repo,
            pull_repo=self.pull_repo,
            pull_all=self.pull_all,
            checkout_default_all=self.checkout_default_all,
            checkout_default_one=self.checkout_default_one,
            get_pipeline_jobs=self.get_pipeline_jobs_web,
            pin_repo=self.cache.pin_repo,
            unpin_repo=self.cache.unpin_repo,
            save_repo_order=self.cache.save_repo_order,
            exclude_repo=self.exclude_repo,
            include_repo=self.include_repo,
            rescan=self.rescan,
        )

    # --- Repo management ---

    async def exclude_repo(self, repo_name: str) -> None:
        """Add a repo to the exclude list and rescan the workspace."""
        if repo_name not in self.config.exclude:
            self.config.exclude.append(repo_name)
            save_config(self.root / ".project-hub" / "config.yaml", self.config)
        await self.rescan()

    async def include_repo(self, repo_name: str) -> None:
        """Remove a repo from the exclude list and rescan the workspace."""
        self.config.exclude = [r for r in self.config.exclude if r != repo_name]
        save_config(self.root / ".project-hub" / "config.yaml", self.config)
        await self.rescan()

    async def rescan(self) -> None:
        """Re-discover repos and reinitialize forge clients."""
        self.repos = discover_repos(self.root, self.config)
        await asyncio.to_thread(self._init_gitlab_clients)
