"""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_issues, diff_pipelines, diff_vulns, filter_events
from .models import Event, EventType, GitStatus, Repo, Vulnerability
from ..git.ops import (
    ahead_behind_ref, check_pull_status_sync, fetch_all, fetch_one,
    git_status, pull_safe_all, pull_safe_one,
)
from ..forge import resolve_token
from ..forge.github import GitHubProvider
from ..forge.gitlab import GitLabProvider
from ..forge.bitbucket import BitbucketProvider
from ..forge.provider import AuthenticationError, ForgeProvider


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 any ancestor = disabled (we're inside a sub-repo)
    parent = cwd.parent
    for _ in range(10):
        if parent == parent.parent:
            break
        if (parent / ".project-hub").is_dir():
            return WorkspaceState.DISABLED_SUB_REPO
        parent = parent.parent
    # Nothing found = dormant
    return WorkspaceState.DORMANT


_PROVIDER_CLASSES: dict[str, type[ForgeProvider]] = {
    "github": GitHubProvider,
    "gitlab": GitLabProvider,
    "bitbucket": BitbucketProvider,
}

_UNSUPPORTED_BY_FORGE_TYPE: dict[str, list[str]] = {
    ft: list(ForgeProvider.supported_features - cls.supported_features)
    for ft, cls in _PROVIDER_CLASSES.items()
}


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._forge_clients: dict[str, ForgeProvider] = {}
        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
        self._refresh_lock: asyncio.Lock = asyncio.Lock()
        self._git_statuses: dict[str, GitStatus] = {}
        self._default_divergence: dict[str, tuple[int, int]] = {}
        self._fetch_warnings: list[dict] = []  # transient, never persisted

    async def start(self, drop_cache: bool = False) -> None:
        """Initialize cache, discover repos, create forge 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 = await asyncio.to_thread(discover_repos, self.root, self.config)
        await asyncio.to_thread(self._init_forge_clients)

    def _init_forge_clients(self) -> None:
        """Create one ForgeProvider 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:
            repo_forge_type = next((r.forge_type for r in self.repos if r.forge_host == host), None)
            token = resolve_token(host, forge_type=repo_forge_type)
            if token:
                if repo_forge_type == "github":
                    self._forge_clients[host] = GitHubProvider(host, token)
                elif repo_forge_type == "bitbucket":
                    self._forge_clients[host] = BitbucketProvider(host, token)
                else:
                    self._forge_clients[host] = GitLabProvider(host, token)
            else:
                for r in self.repos:
                    if r.forge_host == host:
                        r.has_forge = False
        for host, client in self._forge_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."""
        async with self._refresh_lock:
            if self._refresh_running:
                return
            self._refresh_running = True
        try:
            await self._do_refresh(notify_callback)
        finally:
            async with self._refresh_lock:
                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)
        await self._refresh_pull_statuses()

        # 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,
        )
        stale_events = await self._detect_stale_events(stale_results)

        # Cache git status for read path (web fragments, MCP resources)
        new_statuses = {}
        for repo, st in zip(self.repos, stale_results):
            if not isinstance(st, Exception):
                new_statuses[repo.name] = st
        self._git_statuses = new_statuses

        # 2. Forge data (parallel, bounded concurrency)
        results = await self._fetch_all_forge_data(logger)

        # 2b. Diverged branch detection (after forge fetch so default_branch is known)
        diverged_events = await self._detect_diverged_events(stale_results)

        # 3. Diff with cache -> generate events
        all_events, totals = await self._process_forge_results(results, logger)
        all_events = stale_events + diverged_events + all_events

        logger.info(
            f"Refresh complete: {totals[0]} MRs, {totals[3]} issues, {totals[1]} pipelines, "
            f"{totals[2]} vulns, {len(all_events)} events"
        )

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

        await self.cache.set_metadata("last_refresh", datetime.now().isoformat())

        # DB maintenance: purge old seen events + orphaned repo data
        active_repos = {r.name for r in self.repos}
        counts = await self.cache.run_maintenance(active_repos)
        if any(v > 0 for v in counts.values()):
            logger.info("DB maintenance: %s", counts)

        await self.cache.checkpoint()

    async def _refresh_pull_statuses(self) -> None:
        """Compute and cache pull status for all repos in 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)

    async def _fetch_all_forge_data(self, logger) -> list:
        """Fetch forge data for all repos with bounded concurrency."""
        semaphore = asyncio.Semaphore(10)
        forge_tasks = []
        for repo in self.repos:
            if not repo.has_forge or repo.forge_host not in self._forge_clients:
                continue
            client = self._forge_clients[repo.forge_host]
            forge_tasks.append(self._fetch_forge_for_repo(repo, client, semaphore))

        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")
        return results

    async def _process_forge_results(self, results, logger):
        """Process forge fetch results: extract auth events, diff and store data.

        Returns (all_events, (total_mrs, total_pipelines, total_vulns, total_issues)).
        Also populates self._fetch_warnings with transient (non-persisted) warnings
        for repos where some data types failed to fetch.
        """
        all_events: list[Event] = []
        warnings: list[dict] = []
        total_mrs, total_pipelines, total_vulns, total_issues = 0, 0, 0, 0

        for result in results:
            if isinstance(result, BaseException):
                logger.error(f"Exception during forge fetch: {result}")
                continue
            if result is None:
                continue
            if result[0] is None:
                all_events.append(result[1])
                continue
            repo_name, mrs, pipelines, vulns, issues = result
            events, counts = await self._diff_and_store(repo_name, mrs, pipelines, vulns, issues, logger)
            all_events.extend(events)
            total_mrs += counts[0]
            total_pipelines += counts[1]
            total_vulns += counts[2]
            total_issues += counts[3]

            # Build transient warning if any data type failed
            failed = [name for name, val in [("MRs", mrs), ("pipelines", pipelines),
                                              ("vulns", vulns), ("issues", issues)] if val is None]
            if failed:
                warnings.append({
                    "id": None, "type": EventType.FETCH_PARTIAL_FAILURE.value,
                    "repo_name": repo_name,
                    "summary": f"Forge API error — stale data for: {', '.join(failed)}",
                    "detail": "", "created_at": datetime.now().isoformat(), "seen": False,
                })

        self._fetch_warnings = warnings
        return all_events, (total_mrs, total_pipelines, total_vulns, total_issues)

    async def _diff_and_store(self, repo_name, mrs, pipelines, vulns, issues, logger):
        """Diff new forge data against cache, store, return (events, counts).

        Any data type may be None (forge API error) — in that case, skip diff
        and store for that type so transient failures don't generate bogus events.
        """
        old = await self._load_old_state(repo_name)
        old_mrs, old_pipelines, old_vulns, old_issues = old

        logger.debug("%s: %d MRs, %d issues, %d pipelines, %d vulns",
                     repo_name,
                     len(mrs) if mrs is not None else -1,
                     len(issues) if issues is not None else -1,
                     len(pipelines) if pipelines is not None else -1,
                     len(vulns) if vulns is not None else -1)

        repo_events = self._diff_all(repo_name, mrs, pipelines, vulns, issues, old)
        repo_events.extend(await self._check_report_stale_events(repo_name, vulns))
        repo_events = self._filter_repo_events(
            repo_events, repo_name, mrs or old_mrs, pipelines or old_pipelines,
            issues or old_issues)

        await self._store_all(repo_name, mrs, pipelines, vulns, issues)

        return repo_events, (
            len(mrs) if mrs is not None else len(old_mrs),
            len(pipelines) if pipelines is not None else len(old_pipelines),
            len(vulns) if vulns is not None else len(old_vulns),
            len(issues) if issues is not None else len(old_issues),
        )

    async def _load_old_state(self, repo_name):
        """Fetch previous cache state for diffing."""
        return (
            await self.cache.get_mrs(repo_name),
            await self.cache.get_pipelines(repo_name),
            await self.cache.get_vulns(repo_name),
            await self.cache.get_issues(repo_name),
        )

    @staticmethod
    def _diff_all(repo_name, mrs, pipelines, vulns, issues, old):
        """Diff new data against old state for each data type, return events."""
        old_mrs, old_pipelines, old_vulns, old_issues = old
        events: list[Event] = []
        if mrs is not None:
            events.extend(diff_mrs(old_mrs, mrs, repo_name))
        if issues is not None:
            events.extend(diff_issues(old_issues, issues, repo_name))
        if pipelines is not None:
            events.extend(diff_pipelines(old_pipelines, pipelines, repo_name))
        if vulns is not None:
            events.extend(diff_vulns(old_vulns, vulns, repo_name))
        return events

    def _filter_repo_events(self, events, repo_name, mrs, pipelines, issues):
        """Apply watch filters to repo events."""
        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 ""
        return filter_events(
            events,
            self.config.watch_mrs_mode,
            self.config.watch_pipelines_mode,
            self.config.watch_vulns_mode,
            username,
            mrs, pipelines,
            issues_mode=self.config.watch_issues_mode,
            issues=issues,
            vulns_min_severity=self.config.watch_vulns_min_severity,
        )

    async def _store_all(self, repo_name, mrs, pipelines, vulns, issues):
        """Persist non-terminal items to cache. Skip types where fetch failed (None)."""
        _TERMINAL_STATES = {"merged", "closed"}
        if mrs is not None:
            await self.cache.store_mrs(repo_name, [m for m in mrs if m.state not in _TERMINAL_STATES])
        if pipelines is not None:
            await self.cache.store_pipelines(repo_name, pipelines)
        if vulns is not None:
            await self.cache.store_vulns(repo_name, vulns)
        if issues is not None:
            await self.cache.store_issues(repo_name, [i for i in issues if i.state not in _TERMINAL_STATES])

    async def _check_report_stale_events(self, repo_name, vulns) -> list[Event]:
        """Generate REPORT_STALE_TOO_LONG events and dismiss orphans."""
        events: list[Event] = []
        _48H = 48 * 3600
        already_alerted = await self.cache.get_events_by_type(EventType.REPORT_STALE_TOO_LONG.value)
        for v in (vulns or []):
            if v.report_stale and v.report_stale_since and (repo_name, v.name) not in already_alerted:
                dt = datetime.fromisoformat(v.report_stale_since)
                if (datetime.now() - dt).total_seconds() > _48H:
                    events.append(Event(
                        id=None,
                        type=EventType.REPORT_STALE_TOO_LONG,
                        repo_name=repo_name,
                        summary=f"Vulnerability no longer in default-branch scan (>48h): {v.name} ({v.severity})",
                        detail=v.name,
                        created_at=datetime.now(),
                    ))
        if vulns is not None:
            current_vuln_names = {v.name for v in vulns}
            stale_events = already_alerted  # reuse the query
            orphan_ids = [
                (ev_repo, ev_detail)
                for ev_repo, ev_detail in stale_events
                if ev_repo == repo_name and ev_detail not in current_vuln_names
            ]
            if orphan_ids:
                await self.cache.dismiss_events_by_type_and_detail(
                    EventType.REPORT_STALE_TOO_LONG.value, orphan_ids)
        return events

    async def _detect_stale_events(self, stale_results) -> list[Event]:
        """Detect newly stale branches and clean up resolved stale events."""
        already_stale = await self.cache.get_events_by_type(EventType.BRANCH_STALE.value)
        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:
                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(),
                ))
            elif not st.stale and st.branch and (repo.name, st.branch) in already_stale:
                await self.cache.delete_seen_events(EventType.BRANCH_STALE.value, repo.name, st.branch)
        return events

    def _collect_diverge_tasks(self, stale_results):
        """Build ahead_behind tasks for repos on non-default branches."""
        tasks = []
        task_repos = []
        for repo, st in zip(self.repos, stale_results):
            if isinstance(st, Exception) or st.stale or not st.branch:
                continue
            if not repo.default_branch or st.branch == repo.default_branch:
                continue
            task_repos.append((repo, st.branch))
            tasks.append(
                asyncio.to_thread(ahead_behind_ref, Path(repo.path), f"origin/{repo.default_branch}")
            )
        return tasks, task_repos

    async def _detect_diverged_events(self, stale_results) -> list[Event]:
        """Detect branches diverged from default and clean up resolved diverged events."""
        already_diverged = await self.cache.get_events_by_type(EventType.BRANCH_DIVERGED.value)
        tasks, task_repos = self._collect_diverge_tasks(stale_results)
        if not tasks:
            self._default_divergence = {}
            return []
        results = await asyncio.gather(*tasks, return_exceptions=True)
        events: list[Event] = []
        new_divergence: dict[str, tuple[int, int]] = {}
        for (repo, branch), ab in zip(task_repos, results):
            if isinstance(ab, BaseException):
                continue
            da, db = ab
            new_divergence[repo.name] = (da, db)
            is_diverged = da > 0 and db > 0
            was_diverged = (repo.name, branch) in already_diverged
            if is_diverged and not was_diverged:
                events.append(Event(
                    id=None,
                    type=EventType.BRANCH_DIVERGED,
                    repo_name=repo.name,
                    summary=f"Branch '{branch}' diverged from {repo.default_branch} (↑{da} ↓{db})",
                    detail=branch,
                    created_at=datetime.now(),
                ))
            elif not is_diverged and was_diverged:
                await self.cache.delete_seen_events(EventType.BRANCH_DIVERGED.value, repo.name, branch)
        self._default_divergence = new_divergence
        return events

    async def _resolve_vanished(self, client, repo, current, cached, fetch_fn):
        """Re-fetch items that were in cache but disappeared from the opened fetch."""
        current_iids = {item.iid for item in current}
        vanished_iids = [item.iid for item in cached if item.iid not in current_iids]
        if not vanished_iids:
            return
        resolved = await asyncio.to_thread(fetch_fn, repo.project_path, vanished_iids)
        for item in resolved:
            item.repo_name = repo.name
            current.append(item)

    async def _resolve_disappeared(self, client, repo, mrs, issues):
        """Re-fetch MRs/issues that were in cache but disappeared from opened fetch (closed/merged/deleted)."""
        cached_mrs = await self.cache.get_mrs(repo.name)
        cached_issues = await self.cache.get_issues(repo.name)
        await self._resolve_vanished(client, repo, mrs, cached_mrs, client.get_mrs_by_iids)
        await self._resolve_vanished(client, repo, issues, cached_issues, client.get_issues_by_iids)

    async def _refresh_nonterminal_pipelines(self, client, repo, pipelines):
        """Re-fetch cached non-terminal pipelines that fell out of the top-N API window.

        Pipelines with status like 'running' or 'pending' that aren't in the
        fresh API response might have completed. Re-fetch by ref to update them.
        """
        _TERMINAL = {"success", "failed", "canceled", "skipped"}
        fresh_ids = {p.id for p in pipelines}
        cached = await self.cache.get_pipelines(repo.name)
        stale_refs: set[str] = set()
        for p in cached:
            if p.id not in fresh_ids and p.status not in _TERMINAL:
                stale_refs.add(p.ref)
        for ref in stale_refs:
            refreshed = await asyncio.to_thread(
                client.list_pipelines, repo.project_path, ref, 5
            )
            for p in refreshed:
                if p.id not in fresh_ids:
                    p.repo_name = repo.name
                    pipelines.append(p)
                    fresh_ids.add(p.id)

    async def _ensure_mr_pipeline_coverage(self, client, repo, mrs, pipelines):
        """Fetch pipelines for MR branches missing from the top-N window."""
        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)

    async def _reconcile_vulns(self, client, repo, vulns, pipelines):
        """Reconcile vulns against pipeline findings.

        Feature branch: compare pipeline dates to determine branch_status.
        Default branch: mark as report_stale if absent from default pipeline findings
        (GitLab report hasn't caught up with the fix yet).
        """
        if not repo.default_branch:
            return
        try:
            st = self._git_statuses.get(repo.name)
            if not st or not st.branch:
                return

            if st.branch != repo.default_branch:
                await self._reconcile_feature_branch(client, repo, vulns, pipelines, st.branch)
            else:
                await self._reconcile_default_branch(client, repo, vulns, pipelines)
        except Exception as exc:
            import logging
            logging.getLogger(__name__).warning(
                "Vuln reconciliation failed for %s: %s", repo.name, exc)

    async def _reconcile_feature_branch(self, client, repo, vulns, pipelines, branch):
        """Feature branch reconciliation with pipeline date comparison."""
        branch_pip = next((p for p in pipelines if p.ref == branch), None)
        if not branch_pip:
            return

        branch_names, branch_findings = await asyncio.to_thread(
            client.get_pipeline_findings, repo.project_path, branch_pip.id, ref=branch)
        if not branch_names and not branch_findings:
            return

        default_pip = next((p for p in pipelines if p.ref == repo.default_branch), None)
        default_is_newer = (
            default_pip is not None
            and default_pip.created_at > branch_pip.created_at
        )

        # Default -> branch: check which default vulns are absent from branch
        vuln_names = {v.name for v in vulns}
        for v in vulns:
            if v.name not in branch_names:
                v.branch_status = "outdated_branch" if default_is_newer else "fixed"

        # Branch -> default: find branch-only findings
        default_names = set()
        if default_pip:
            default_names, _ = await asyncio.to_thread(
                client.get_pipeline_findings, repo.project_path, default_pip.id,
                ref=repo.default_branch)

        for f in branch_findings:
            if f.name not in vuln_names and f.name not in default_names:
                import hashlib
                fid = f"finding-{hashlib.sha256(f.name.encode()).hexdigest()[:12]}"
                vulns.append(Vulnerability(
                    id=fid,
                    name=f.name,
                    severity=f.severity,
                    state="detected",
                    source="",
                    web_url="",
                    repo_name=repo.name,
                    solution=f.solution,
                    branch_status="introduced" if default_is_newer else "outdated_default",
                ))

    async def _reconcile_default_branch(self, client, repo, vulns, pipelines):
        """Default branch: detect stale reports (vuln in API but not in latest scan)."""
        finding_ids, _ = await self._get_pipeline_findings(
            client, repo, pipelines, repo.default_branch)
        if not finding_ids:
            return
        old_vulns = await self.cache.get_vulns(repo.name)
        old_by_name = {v.name: v for v in old_vulns}
        now = datetime.now().isoformat()
        inactive = {"resolved", "dismissed"}
        for v in vulns:
            if v.state in inactive:
                continue
            if v.name not in finding_ids:
                v.report_stale = True
                old = old_by_name.get(v.name)
                if old and old.report_stale and old.report_stale_since:
                    v.report_stale_since = old.report_stale_since
                else:
                    v.report_stale_since = now

    async def _get_pipeline_findings(self, client, repo, pipelines, ref):
        """Return (finding_ids, findings) for a pipeline matching the given ref, or (None, [])."""
        pip = next((p for p in pipelines if p.ref == ref), None)
        if not pip:
            return None, []
        names, findings = await asyncio.to_thread(
            client.get_pipeline_findings, repo.project_path, pip.id, ref=ref,
        )
        return (names or None), findings

    async def _fetch_forge_for_repo(
        self,
        repo: Repo,
        client: ForgeProvider,
        semaphore: asyncio.Semaphore,
    ) -> tuple[str, list, list, list, list] | tuple[None, Event] | None:
        """Fetch MR/pipeline/vuln data for one repo with semaphore."""
        async with semaphore:
            try:
                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
                )
                if mrs is not None:
                    for mr in mrs:
                        mr.repo_name = repo.name
                pipelines = await asyncio.to_thread(
                    client.list_pipelines, repo.project_path
                )
                if pipelines is not None:
                    for p in pipelines:
                        p.repo_name = repo.name

                await self._ensure_mr_pipeline_coverage(client, repo, mrs or [], pipelines or [])
                await self._refresh_nonterminal_pipelines(client, repo, pipelines or [])

                vulns = await asyncio.to_thread(
                    client.get_vulnerabilities, repo.project_path
                )
                if vulns is not None:
                    for v in vulns:
                        v.repo_name = repo.name
                    await self._reconcile_vulns(client, repo, vulns, pipelines or [])

                issues = await asyncio.to_thread(
                    client.list_issues, repo.project_path
                )
                if issues is not None:
                    for i in issues:
                        i.repo_name = repo.name

                await self._resolve_disappeared(client, repo, mrs or [], issues or [])

                return repo.name, mrs, pipelines, vulns, issues
            except AuthenticationError:
                return None, Event(
                    id=None,
                    type=EventType.AUTH_EXPIRED,
                    repo_name=repo.name,
                    summary=f"Authentication failed for {repo.forge_host}",
                    detail="",
                    created_at=datetime.now(),
                )

    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()

    @staticmethod
    def _count_active_vulns(vulns, severity):
        inactive = {"resolved", "dismissed"}
        return sum(
            1 for v in vulns
            if v.severity == severity and v.state not in inactive
            and v.branch_status != "fixed" and not v.report_stale
        )

    @staticmethod
    def _enrich_git_status(entry, default_branch, git_statuses, default_divergence):
        cached = git_statuses.get(entry["name"])
        if cached is None:
            return
        entry["branch"] = cached.branch
        entry["ahead"] = cached.ahead
        entry["behind"] = cached.behind
        entry["stale"] = cached.stale
        entry["remote_branches"] = cached.remote_branches or []
        if default_branch and cached.branch and cached.branch != default_branch:
            da, db = default_divergence.get(entry["name"], (0, 0))
            entry["default_ahead"] = da
            entry["default_behind"] = db

    @staticmethod
    def _pick_pipeline_status(pipelines, branch, default_branch, stale):
        """Pick the most relevant pipeline status for a repo card.

        Prefers current branch pipeline, falls back to default branch,
        then latest overall. Skips current branch for stale repos.
        """
        if not pipelines:
            return None
        by_ref: dict[str, list] = {}
        for p in pipelines:
            by_ref.setdefault(p.ref, []).append(p)
        for ref_pipelines in by_ref.values():
            ref_pipelines.sort(key=lambda p: p.created_at or "", reverse=True)

        if branch and not stale and branch in by_ref:
            return by_ref[branch][0].status
        if default_branch and default_branch in by_ref:
            return by_ref[default_branch][0].status
        # Fallback: latest pipeline overall
        all_sorted = sorted(pipelines, key=lambda p: p.created_at or "", reverse=True)
        return all_sorted[0].status

    @staticmethod
    def _build_repo_entry(r, mrs, pipelines, vulns, issues, pull_statuses,
                          git_statuses, default_divergence) -> dict:
        """Build a dict for one repo combining forge data, git status, and pull state."""
        cached_st = git_statuses.get(r.name)
        branch = cached_st.branch if cached_st else None
        stale = cached_st.stale if cached_st else False
        entry = {
            **r.to_dict(),
            "mr_count": len(mrs),
            "issue_count": len(issues),
            "pipeline_status": Workspace._pick_pipeline_status(
                pipelines, branch, r.default_branch, stale),
            "vuln_critical": Workspace._count_active_vulns(vulns, "critical"),
            "vuln_high": Workspace._count_active_vulns(vulns, "high"),
            "branch": None, "ahead": 0, "behind": 0,
            "default_ahead": 0, "default_behind": 0,
            "stale": False,
            "pull_status": pull_statuses.get(r.name),
        }
        entry["unsupported_features"] = _UNSUPPORTED_BY_FORGE_TYPE.get(r.forge_type, [])
        Workspace._enrich_git_status(entry, r.default_branch, git_statuses, default_divergence)
        return entry

    @staticmethod
    def _group_by_repo(*lists):
        """Group items by repo_name into a dict of lists. Returns one defaultdict per input list."""
        from collections import defaultdict
        grouped = []
        for items in lists:
            d: dict[str, list] = defaultdict(list)
            for item in items:
                d[item.repo_name].append(item)
            grouped.append(d)
        return grouped

    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().
        """
        mrs = self.cache.get_mrs_sync(None)
        pipelines = self.cache.get_pipelines_sync(None)
        vulns = self.cache.get_vulns_sync(None)
        issues = self.cache.get_issues_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()

        repo_mrs, repo_pipelines, repo_vulns, repo_issues = self._group_by_repo(
            mrs, pipelines, vulns, issues
        )

        repos_data = [
            self._build_repo_entry(r, repo_mrs[r.name], repo_pipelines[r.name],
                                   repo_vulns[r.name], repo_issues[r.name], pull_statuses,
                                   self._git_statuses, self._default_divergence)
            for r in self.repos if Path(r.path).is_dir()
        ]

        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],
            "issues": [i.to_dict() for i in issues],
            "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 _refresh_git_cache(self, repo_names: list[str]) -> None:
        """Re-read git status for specific repos and update the in-memory cache."""
        repos = [r for r in self.repos if r.name in repo_names]
        results = await asyncio.gather(
            *(asyncio.to_thread(git_status, Path(r.path)) for r in repos),
            return_exceptions=True,
        )
        for repo, st in zip(repos, results):
            if not isinstance(st, Exception):
                self._git_statuses[repo.name] = st
                if repo.default_branch and st.branch and st.branch != repo.default_branch:
                    ab = await asyncio.to_thread(
                        ahead_behind_ref, Path(repo.path), f"origin/{repo.default_branch}"
                    )
                    self._default_divergence[repo.name] = ab
                else:
                    self._default_divergence.pop(repo.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))
            await self._refresh_git_cache([repo_name])

    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 + git 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)
        await self._refresh_git_cache([repo_name])
        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 + git 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)
        await self._refresh_git_cache([r.name for r in self.repos])
        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 = await asyncio.to_thread(git_status, Path(repo.path))
            except Exception:
                continue
            if not st.stale:
                continue
            proc = await asyncio.create_subprocess_exec(
                "git", "switch", "--", 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()})
        changed = [r["repo"] for r in results if r["status"] == "ok"]
        if changed:
            await self._refresh_git_cache(changed)
        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", "switch", "--", repo.default_branch,
            cwd=repo.path,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        _, stderr = await proc.communicate()
        if proc.returncode == 0:
            await self._refresh_git_cache([repo_name])
            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 | str) -> 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._forge_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 get_events_dict(self) -> list[dict]:
        """Return unseen events as dicts — lightweight, no git/forge data.

        Prepends transient fetch warnings (not persisted) so they appear at the
        top of the events panel as a banner.
        """
        events = self.cache.get_unseen_events_sync()
        return self._fetch_warnings + [e.to_dict() for e in events]

    def get_sync_status(self) -> str:
        """Return sync status string — lightweight, no I/O."""
        return self._sync_status

    def web_app_kwargs(self) -> dict:
        """Return kwargs dict for web.app.create_app()."""
        return {
            "get_state": self.get_state_dict,
            "get_events": self.get_events_dict,
            "get_sync_status": self.get_sync_status,
            "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."""
        async with self._refresh_lock:
            self.repos = await asyncio.to_thread(discover_repos, self.root, self.config)
            await asyncio.to_thread(self._init_forge_clients)
