"""Core-mediated source-bundle and result-bundle helpers for desktop execution."""
from __future__ import annotations

import asyncio
import os
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import Any
from uuid import UUID

from src.config import get_settings
from src.core.exceptions import NotFoundError, ValidationError
from src.domain.codegen.repository import CodegenRunRepository
from src.domain.projects.paths import get_project_repo_path
from src.domain.projects.repository import ProjectRepository
from src.integrations.github_client import GitHubAppClient
from src.integrations.github_installation import resolve_installation_id_for_workspace
from src.workers.repo_ops import prepare_working_tree, push_repo, run_git

_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".next", ".nuxt", ".turbo"}


def _zip_repo_tree(repo_path: str, bundle_path: str) -> None:
    with zipfile.ZipFile(bundle_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
        for root, dirs, files in os.walk(repo_path):
            dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
            for filename in files:
                if filename.endswith(".pyc"):
                    continue
                full_path = os.path.join(root, filename)
                rel_path = os.path.relpath(full_path, repo_path)
                parts = Path(rel_path).parts
                if any(part in _SKIP_DIRS for part in parts):
                    continue
                zf.write(full_path, rel_path)


def _clear_repo_worktree(repo_path: str) -> None:
    for name in os.listdir(repo_path):
        if name == ".git":
            continue
        target = os.path.join(repo_path, name)
        if os.path.isdir(target) and not os.path.islink(target):
            shutil.rmtree(target, ignore_errors=False)
        else:
            os.unlink(target)


def _extract_repo_bundle(bundle_path: str, repo_path: str) -> None:
    with zipfile.ZipFile(bundle_path) as zf:
        for info in zf.infolist():
            normalized = info.filename.replace("\\", "/")
            # Check the leading slash / drive letter *before* stripping it --
            # rel_name.strip("/") below would otherwise turn "/etc/passwd"
            # into "etc/passwd", which looks relative and slips past the
            # is_absolute() check below (which only ever sees the stripped,
            # already-relative-looking name).
            if normalized.startswith("/") or (len(normalized) > 1 and normalized[1] == ":"):
                raise ValidationError("Result bundle contains an unsafe path")
            rel_name = normalized.strip("/")
            if not rel_name:
                continue
            parts = Path(rel_name).parts
            if any(part in ("..", ".git") for part in parts) or Path(rel_name).is_absolute():
                raise ValidationError("Result bundle contains an unsafe path")
            target = Path(repo_path, *parts)
            if info.is_dir():
                target.mkdir(parents=True, exist_ok=True)
                continue
            target.parent.mkdir(parents=True, exist_ok=True)
            with zf.open(info, "r") as src, open(target, "wb") as dst:
                shutil.copyfileobj(src, dst)


class DeviceBundleService:
    def __init__(self, *, db_factory) -> None:
        self._db_factory = db_factory

    async def build_source_bundle(self, run_id: UUID) -> tuple[str, str]:
        settings = get_settings()
        async with self._db_factory() as db:
            run = await CodegenRunRepository(db).get_by_id(run_id)
            if not run:
                raise NotFoundError("Run not found")
            project = await ProjectRepository(db).get_with_repo(run.project_id)
            if not project or not getattr(project, "repo", None):
                raise ValidationError("Project repo not found")
            workspace_id = project.workspace_id
            repo_full_name = project.repo.repo_full_name
            default_branch = project.repo.default_branch or "main"
            preview_branch = (
                getattr(project.repo, "preview_branch", None)
                or settings.hosting_preview_branch
                or "preview"
            )

        installation_id = await resolve_installation_id_for_workspace(self._db_factory, workspace_id)
        github = GitHubAppClient(installation_id=installation_id)
        clone_url = await github.get_authenticated_clone_url(repo_full_name)
        repo_path = get_project_repo_path(settings.repo_workspace, workspace_id, run.project_id)
        await prepare_working_tree(
            repo_path,
            clone_url,
            branch=preview_branch,
            heal_missing_remote_from=default_branch,
        )

        fd, bundle_path = tempfile.mkstemp(prefix=f"p2hs-source-{run_id}-", suffix=".zip")
        os.close(fd)
        await asyncio.to_thread(_zip_repo_tree, repo_path, bundle_path)
        return bundle_path, f"p2hs-source-{run_id}.zip"

    async def apply_result_bundle(
        self,
        *,
        run_id: UUID,
        result_bundle_path: str,
        summary: str | None = None,
    ) -> dict[str, Any]:
        settings = get_settings()
        async with self._db_factory() as db:
            run = await CodegenRunRepository(db).get_by_id(run_id)
            if not run:
                raise NotFoundError("Run not found")
            project = await ProjectRepository(db).get_with_repo(run.project_id)
            if not project or not getattr(project, "repo", None):
                raise ValidationError("Project repo not found")
            workspace_id = project.workspace_id
            repo_full_name = project.repo.repo_full_name
            default_branch = project.repo.default_branch or "main"
            preview_branch = (
                getattr(project.repo, "preview_branch", None)
                or settings.hosting_preview_branch
                or "preview"
            )

        installation_id = await resolve_installation_id_for_workspace(self._db_factory, workspace_id)
        github = GitHubAppClient(installation_id=installation_id)
        clone_url = await github.get_authenticated_clone_url(repo_full_name)
        repo_path = get_project_repo_path(settings.repo_workspace, workspace_id, run.project_id)
        await prepare_working_tree(
            repo_path,
            clone_url,
            branch=preview_branch,
            heal_missing_remote_from=default_branch,
        )

        await asyncio.to_thread(_clear_repo_worktree, repo_path)
        await asyncio.to_thread(_extract_repo_bundle, result_bundle_path, repo_path)

        rc, status_out, status_err = await run_git(repo_path, "status", "--porcelain")
        if rc != 0:
            raise RuntimeError(f"git status failed: {status_err}")
        if not status_out.strip():
            raise ValidationError("Uploaded result bundle contained no file changes")

        from src.workers.repo_ops import stage_publishable_and_assert

        staged = await stage_publishable_and_assert(repo_path)
        if not staged:
            raise ValidationError("Uploaded result bundle contained no publishable file changes")

        commit_message = f"p2hs: apply codegen run {str(run_id)[:8]}"
        rc, _, commit_err = await run_git(repo_path, "commit", "-m", commit_message)
        if rc != 0:
            raise RuntimeError(f"git commit failed: {commit_err}")

        rc, sha_out, sha_err = await run_git(repo_path, "rev-parse", "HEAD")
        if rc != 0:
            raise RuntimeError(f"git rev-parse HEAD failed: {sha_err}")
        commit_sha = (sha_out or "").strip()
        if not commit_sha:
            raise RuntimeError("git rev-parse HEAD returned an empty SHA")

        from src.domain.codegen.provision_manifest import load_manifest_from_repo_path
        from src.domain.codegen.provision_gate import wait_for_provisioning
        from src.domain.codegen.backend_guard import validate_backend_repo
        from src.domain.codegen.email_templates_manifest import (
            load_manifest_from_repo_path as load_email_templates_manifest,
        )

        await asyncio.to_thread(validate_backend_repo, repo_path)
        declaration = await asyncio.to_thread(load_manifest_from_repo_path, repo_path)
        if declaration:
            await wait_for_provisioning(self._db_factory, run_id, declaration)
        # Email template copy is never a push/deploy blocker — an unapplied override just
        # means the platform default copy keeps being used until it's processed, unlike
        # provisioning (which the generated app's own code depends on existing).
        email_templates_declaration = await asyncio.to_thread(load_email_templates_manifest, repo_path)
        await push_repo(repo_path, branch=preview_branch, force_with_lease=True)
        return {
            "commit_sha": commit_sha,
            "branch": preview_branch,
            "summary": summary or "Codegen completed.",
            "pending_provision": declaration,
            "pending_email_templates": email_templates_declaration,
        }
