"""Task: merge preview branch into main and trigger a production deployment.

Called when a user clicks the "Deploy" / "Publish" button in the frontend.

Flow:
1. Clone / sync repo on worker machine
2. Merge preview branch → main (fast-forward if possible, merge commit otherwise)
3. Push main to GitHub (triggers CF Pages production auto-build)
4. Create a ProjectDeploy record for the new main HEAD revision
5. Enqueue run_project_deploy to poll CF Pages + activate W4P routing
"""
from __future__ import annotations

from typing import Optional
from uuid import UUID

import structlog

from src.config import get_settings

logger = structlog.get_logger()


async def merge_and_deploy_to_production(
    ctx: dict,
    project_id: str,
    triggered_by: Optional[str] = None,
) -> dict:
    from src.core.database import async_session_factory
    from src.domain.deployments.repository import DeployRepository
    from src.domain.projects.repository import ProjectRepository
    from src.domain.revisions.repository import RevisionRepository
    from src.integrations.github_client import GitHubAppClient
    from src.integrations.github_installation import resolve_installation_id_for_workspace
    from src.workers.enqueue import enqueue_run_project_deploy
    from src.workers.repo_ops import (
        get_project_repo_path,
        merge_branch_into,
        prepare_working_tree,
        push_branch,
    )

    db_factory = ctx.get("db_factory", async_session_factory)
    settings = get_settings()
    pid = UUID(project_id)
    created_by = UUID(triggered_by) if triggered_by else None

    async with db_factory() as db:
        project = await ProjectRepository(db).get_with_repo(pid)
        if not project or not getattr(project, "repo", None):
            raise RuntimeError("Project or repo not found")

        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"
        workspace_id = project.workspace_id

    installation_id = await resolve_installation_id_for_workspace(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, pid)

    # Sync preview branch so we have the latest (heal if preview ref missing on origin)
    await prepare_working_tree(
        repo_path,
        clone_url,
        branch=preview_branch,
        heal_missing_remote_from=default_branch,
    )

    # Merge preview → main
    new_sha = await merge_branch_into(repo_path, preview_branch, default_branch)

    # Push main to GitHub (triggers CF Pages production build)
    await push_branch(repo_path, default_branch)

    logger.info(
        "preview_merged_to_main",
        project_id=project_id,
        sha=new_sha[:12],
        preview_branch=preview_branch,
        main_branch=default_branch,
    )

    # Create or find revision for new main HEAD
    async with db_factory() as db:
        revision_repo = RevisionRepository(db)
        rev = await revision_repo.get_by_git_sha(pid, new_sha)
        if not rev:
            rev = await revision_repo.create(
                project_id=pid,
                git_sha=new_sha,
                source="deploy_merge",
                commit_message=f"Merge {preview_branch} → {default_branch}",
                created_by=created_by,
            )

        deploy_repo = DeployRepository(db)
        deploy = await deploy_repo.create(
            pid,
            rev.id,
            "production",
            created_by=created_by,
            deployment_metadata={
                "triggered_by_merge": True,
                "preview_branch": preview_branch,
                "main_branch": default_branch,
            },
        )
        await db.commit()
        deploy_id = str(deploy.id)

    await _publish_to_customer_repo_if_configured(
        db_factory=db_factory,
        project_id=pid,
        repo_path=repo_path,
        default_branch=default_branch,
        git_name=settings.git_committer_name,
        git_email=settings.git_committer_email,
    )

    await enqueue_run_project_deploy(deploy_id)

    logger.info(
        "production_deploy_enqueued",
        project_id=project_id,
        deploy_id=deploy_id,
        sha=new_sha[:12],
    )
    return {"ok": True, "deploy_id": deploy_id, "sha": new_sha}


async def _publish_to_customer_repo_if_configured(
    *,
    db_factory,
    project_id: UUID,
    repo_path: str,
    default_branch: str,
    git_name: str,
    git_email: str,
) -> None:
    """Overlay sanitized managed-main files to every configured customer mirror target."""
    errors: list[str] = []

    for provider, publish in (
        ("github", _publish_to_github_customer_repo_if_configured),
        ("gitlab", _publish_to_gitlab_customer_repo_if_configured),
        ("bitbucket", _publish_to_bitbucket_customer_repo_if_configured),
        ("azure_devops", _publish_to_azure_customer_repo_if_configured),
    ):
        try:
            await publish(
                db_factory=db_factory,
                project_id=project_id,
                repo_path=repo_path,
                default_branch=default_branch,
                git_name=git_name,
                git_email=git_email,
            )
        except Exception as exc:
            errors.append(f"{provider}: {exc}")

    if errors:
        raise RuntimeError("Customer mirror publish failed: " + "; ".join(errors))


async def _publish_to_github_customer_repo_if_configured(
    *,
    db_factory,
    project_id: UUID,
    repo_path: str,
    default_branch: str,
    git_name: str,
    git_email: str,
) -> None:
    import os
    import tempfile

    from src.domain.github_user_mirror.repository import (
        ProjectUserMirrorRepository,
        WorkspaceUserGithubInstallationRepository,
    )
    from src.domain.projects.repository import ProjectRepository
    from src.integrations.github_user_app_client import GitHubUserAppClient
    from src.workers.repo_ops import clone_repo

    async with db_factory() as db:
        project = await ProjectRepository(db).get_by_id(project_id)
        mirror_repo = ProjectUserMirrorRepository(db)
        mirror = await mirror_repo.get_by_project_id(project_id)
        if not project or not mirror or not mirror.enabled:
            await db.commit()
            return
        inst = await WorkspaceUserGithubInstallationRepository(db).get_by_id(
            mirror.workspace_user_github_installation_id
        )
        if not inst or inst.suspended_at is not None:
            await mirror_repo.update_push_result(project_id, last_error="GitHub installation is missing or suspended.")
            await db.commit()
            return
        installation_id = inst.github_installation_id
        target_repo = mirror.target_repo_full_name
        target_branch = mirror.target_default_branch or default_branch or "main"
        source_root_dir = (getattr(project, "source_root_dir", None) or "/").strip().replace("\\", "/")
        source_root_dir = "" if source_root_dir in ("", "/", ".") else source_root_dir.strip("/")
        await db.commit()

    gh = GitHubUserAppClient()
    clone_url = await gh.get_authenticated_clone_url(installation_id, target_repo)
    try:
        with tempfile.TemporaryDirectory(prefix="p2hs-publish-") as tmp:
            await clone_repo(clone_url, tmp, branch=target_branch)
            destination = os.path.join(tmp, *source_root_dir.split("/")) if source_root_dir else tmp
            os.makedirs(destination, exist_ok=True)
            _overlay_publish_tree(repo_path, destination)
            sha = await _commit_and_push_publishable(
                tmp,
                git_name=git_name,
                git_email=git_email,
                branch=target_branch,
                add_error="git add customer repo failed",
                commit_error="git commit customer repo failed",
            )
    except Exception as exc:
        async with db_factory() as db:
            await ProjectUserMirrorRepository(db).update_push_result(project_id, last_error=str(exc)[:2000])
            await db.commit()
        raise

    async with db_factory() as db:
        await ProjectUserMirrorRepository(db).update_push_result(
            project_id,
            last_push_sha=sha,
            last_error=None,
        )
        await db.commit()


async def _publish_to_gitlab_customer_repo_if_configured(
    *,
    db_factory,
    project_id: UUID,
    repo_path: str,
    default_branch: str,
    git_name: str,
    git_email: str,
) -> None:
    import os
    import tempfile

    from src.core.security.encryption import decrypt_value
    from src.domain.gitlab_user_mirror.repository import (
        ProjectUserGitlabMirrorRepository,
        WorkspaceUserGitlabConnectionRepository,
    )
    from src.domain.projects.repository import ProjectRepository
    from src.integrations.gitlab_user_client import GitLabUserClient
    from src.workers.repo_ops import clone_repo

    async with db_factory() as db:
        project = await ProjectRepository(db).get_by_id(project_id)
        mirror_repo = ProjectUserGitlabMirrorRepository(db)
        mirror = await mirror_repo.get_by_project_id(project_id)
        if not project or not mirror or not mirror.enabled:
            await db.commit()
            return
        conn = await WorkspaceUserGitlabConnectionRepository(db).get_by_id(
            mirror.workspace_user_gitlab_connection_id
        )
        if not conn or conn.revoked_at is not None:
            await mirror_repo.update_push_result(project_id, last_error="GitLab connection is missing or revoked.")
            await db.commit()
            return
        access_token = decrypt_value(conn.access_token_ciphertext)
        target_repo = mirror.target_project_full_path
        target_branch = mirror.target_default_branch or default_branch or "main"
        source_root_dir = (getattr(project, "source_root_dir", None) or "/").strip().replace("\\", "/")
        source_root_dir = "" if source_root_dir in ("", "/", ".") else source_root_dir.strip("/")
        await db.commit()

    gl = GitLabUserClient(access_token=access_token)
    clone_url = gl.authenticated_clone_url(target_repo)
    try:
        with tempfile.TemporaryDirectory(prefix="p2hs-publish-gitlab-") as tmp:
            await clone_repo(clone_url, tmp, branch=target_branch)
            destination = os.path.join(tmp, *source_root_dir.split("/")) if source_root_dir else tmp
            os.makedirs(destination, exist_ok=True)
            _overlay_publish_tree(repo_path, destination)
            sha = await _commit_and_push_publishable(
                tmp,
                git_name=git_name,
                git_email=git_email,
                branch=target_branch,
                add_error="git add GitLab repo failed",
                commit_error="git commit GitLab repo failed",
            )
    except Exception as exc:
        async with db_factory() as db:
            await ProjectUserGitlabMirrorRepository(db).update_push_result(
                project_id,
                last_error=str(exc)[:2000],
            )
            await db.commit()
        raise

    async with db_factory() as db:
        await ProjectUserGitlabMirrorRepository(db).update_push_result(
            project_id,
            last_push_sha=sha,
            last_error=None,
        )
        await db.commit()


async def _publish_to_bitbucket_customer_repo_if_configured(
    *,
    db_factory,
    project_id: UUID,
    repo_path: str,
    default_branch: str,
    git_name: str,
    git_email: str,
) -> None:
    import os
    import tempfile

    from src.core.security.encryption import decrypt_value
    from src.domain.bitbucket_user_mirror.repository import (
        ProjectUserBitbucketMirrorRepository,
        WorkspaceUserBitbucketConnectionRepository,
    )
    from src.domain.projects.repository import ProjectRepository
    from src.integrations.bitbucket_user_client import BitbucketUserClient
    from src.workers.repo_ops import clone_repo

    async with db_factory() as db:
        project = await ProjectRepository(db).get_by_id(project_id)
        mirror_repo = ProjectUserBitbucketMirrorRepository(db)
        mirror = await mirror_repo.get_by_project_id(project_id)
        if not project or not mirror or not mirror.enabled:
            await db.commit()
            return
        conn = await WorkspaceUserBitbucketConnectionRepository(db).get_by_id(
            mirror.workspace_user_bitbucket_connection_id
        )
        if not conn or conn.revoked_at is not None:
            await mirror_repo.update_push_result(
                project_id,
                last_error="Bitbucket connection is missing or revoked.",
            )
            await db.commit()
            return
        access_token = decrypt_value(conn.access_token_ciphertext)
        target_repo = mirror.target_repo_full_name
        target_branch = mirror.target_default_branch or default_branch or "main"
        source_root_dir = (getattr(project, "source_root_dir", None) or "/").strip().replace("\\", "/")
        source_root_dir = "" if source_root_dir in ("", "/", ".") else source_root_dir.strip("/")
        await db.commit()

    bb = BitbucketUserClient(access_token=access_token)
    clone_url = bb.authenticated_clone_url(target_repo)
    try:
        with tempfile.TemporaryDirectory(prefix="p2hs-publish-bitbucket-") as tmp:
            await clone_repo(clone_url, tmp, branch=target_branch)
            destination = os.path.join(tmp, *source_root_dir.split("/")) if source_root_dir else tmp
            os.makedirs(destination, exist_ok=True)
            _overlay_publish_tree(repo_path, destination)
            sha = await _commit_and_push_publishable(
                tmp,
                git_name=git_name,
                git_email=git_email,
                branch=target_branch,
                add_error="git add Bitbucket repo failed",
                commit_error="git commit Bitbucket repo failed",
            )
    except Exception as exc:
        async with db_factory() as db:
            await ProjectUserBitbucketMirrorRepository(db).update_push_result(
                project_id,
                last_error=str(exc)[:2000],
            )
            await db.commit()
        raise

    async with db_factory() as db:
        await ProjectUserBitbucketMirrorRepository(db).update_push_result(
            project_id,
            last_push_sha=sha,
            last_error=None,
        )
        await db.commit()


async def _publish_to_azure_customer_repo_if_configured(
    *,
    db_factory,
    project_id: UUID,
    repo_path: str,
    default_branch: str,
    git_name: str,
    git_email: str,
) -> None:
    import os
    import tempfile

    from src.domain.azure_user_mirror.repository import (
        ProjectUserAzureMirrorRepository,
        WorkspaceUserAzureConnectionRepository,
    )
    from src.domain.azure_user_mirror.service import resolve_azure_connection_client
    from src.domain.projects.repository import ProjectRepository
    from src.integrations.azure_devops_client import split_azure_repo_full_name
    from src.workers.repo_ops import clone_repo

    async with db_factory() as db:
        project = await ProjectRepository(db).get_by_id(project_id)
        mirror_repo = ProjectUserAzureMirrorRepository(db)
        mirror = await mirror_repo.get_by_project_id(project_id)
        if not project or not mirror or not mirror.enabled:
            await db.commit()
            return
        conn_repo = WorkspaceUserAzureConnectionRepository(db)
        conn = await conn_repo.get_by_id(
            mirror.workspace_user_azure_connection_id
        )
        if not conn or conn.revoked_at is not None:
            await mirror_repo.update_push_result(project_id, last_error="Azure DevOps connection is missing or revoked.")
            await db.commit()
            return
        client = await resolve_azure_connection_client(conn, conn_repo)
        target_repo = mirror.target_repo_full_name
        target_branch = mirror.target_default_branch or default_branch or "main"
        source_root_dir = (getattr(project, "source_root_dir", None) or "/").strip().replace("\\", "/")
        source_root_dir = "" if source_root_dir in ("", "/", ".") else source_root_dir.strip("/")
        await db.commit()

    az_project, az_repo = split_azure_repo_full_name(target_repo)
    clone_url = client.authenticated_clone_url(az_project, az_repo)
    try:
        with tempfile.TemporaryDirectory(prefix="p2hs-publish-azure-") as tmp:
            await clone_repo(clone_url, tmp, branch=target_branch)
            destination = os.path.join(tmp, *source_root_dir.split("/")) if source_root_dir else tmp
            os.makedirs(destination, exist_ok=True)
            _overlay_publish_tree(repo_path, destination)
            sha = await _commit_and_push_publishable(
                tmp,
                git_name=git_name,
                git_email=git_email,
                branch=target_branch,
                add_error="git add Azure repo failed",
                commit_error="git commit Azure repo failed",
            )
    except Exception as exc:
        async with db_factory() as db:
            await ProjectUserAzureMirrorRepository(db).update_push_result(
                project_id, last_error=str(exc)[:2000]
            )
            await db.commit()
        raise

    async with db_factory() as db:
        await ProjectUserAzureMirrorRepository(db).update_push_result(
            project_id, last_push_sha=sha, last_error=None
        )
        await db.commit()


async def _commit_and_push_publishable(
    repo_dir: str,
    *,
    git_name: str,
    git_email: str,
    branch: str,
    add_error: str,
    commit_error: str,
    commit_message: str = "[p2hs] Publish managed changes",
) -> str:
    from src.workers.repo_ops import push_branch, run_git, stage_publishable_and_assert

    await run_git(repo_dir, "config", "user.email", git_email)
    await run_git(repo_dir, "config", "user.name", git_name)
    try:
        staged = await stage_publishable_and_assert(repo_dir)
    except Exception as exc:
        raise RuntimeError(f"{add_error}: {exc}") from exc
    if not staged:
        return (await run_git(repo_dir, "rev-parse", "HEAD"))[1].strip()
    rc, out, err = await run_git(repo_dir, "commit", "-m", commit_message)
    combined = f"{out}\n{err}".lower()
    if rc != 0 and "nothing to commit" in combined:
        return (await run_git(repo_dir, "rev-parse", "HEAD"))[1].strip()
    if rc != 0:
        raise RuntimeError(f"{commit_error}: {err or out}")
    await push_branch(repo_dir, branch)
    return (await run_git(repo_dir, "rev-parse", "HEAD"))[1].strip()


def _overlay_publish_tree(source: str, destination: str) -> None:
    import os
    import shutil

    for root, dirs, files in os.walk(source):
        rel_dir = os.path.relpath(root, source)
        parts = [] if rel_dir == "." else rel_dir.split(os.sep)
        dirs[:] = [d for d in dirs if not _skip_publish_path(parts + [d], is_dir=True)]
        for filename in files:
            rel_parts = parts + [filename]
            src = os.path.join(root, filename)
            if _skip_publish_path(rel_parts, is_dir=False, source_file=src):
                continue
            dst = os.path.join(destination, *rel_parts)
            os.makedirs(os.path.dirname(dst), exist_ok=True)
            shutil.copy2(src, dst)


def _skip_publish_path(
    parts: list[str], *, is_dir: bool, source_file: str | None = None
) -> bool:
    from src.domain.codegen.repo_export import (
        is_allowlisted_path,
        is_forbidden_path,
        is_platform_owned_path,
        scan_text_for_leaks,
    )

    lowered = [p.lower() for p in parts]
    if not parts:
        return False
    # Single source of truth for what P2HS owns (run payloads, imported-source snapshots,
    # uploads, the codegen runner and workflows): never copied into a customer repository.
    if is_platform_owned_path(parts):
        return True
    if lowered == ["p2hs.platform.json"]:
        return True
    if lowered == [".cursor", "instructions.md"]:
        return True
    if lowered in ([".cursorrules"], ["claude.md"]):
        return True
    if (
        lowered[0] == ".github"
        and len(lowered) >= 3
        and lowered[1] == "workflows"
        and lowered[2] in {"deploy.yml", "p2hs-codegen.yml"}
    ):
        return True
    if lowered[-1].startswith(".env") and lowered[-1] not in {".env.example", ".env.sample"}:
        return True
    if is_dir and lowered[-1] in {"node_modules", "dist", "build", ".next", ".nuxt", ".turbo"}:
        return True
    if not is_dir:
        rel = "/".join(parts).replace("\\", "/")
        if is_forbidden_path(rel) or not is_allowlisted_path(rel):
            return True
        if source_file:
            try:
                with open(source_file, encoding="utf-8") as handle:
                    text = handle.read()
            except (OSError, UnicodeDecodeError):
                text = ""
            if text and scan_text_for_leaks(rel, text):
                return True
    return False
