"""Idempotent workspace/project migration onto the W4P runtime.

This is the orchestration entry point for legacy generated applications.  It
keeps Core authoritative: project ownership, hosting target, the project's
already-decided composition, capability reconciliation, Cloud bootstrap,
authorization projection, env propagation, and the repository/runtime adapter
migration all remain Core-owned.  Cloud continues to own the runtime data and
execution fabric.

The composition phase installs packs the GenerationPlan (or Discovery, when no
plan exists) already selected.  It does not invent product blocks for a project
that never selected them.  Missing, blocked, and version-stale packs are
reinstalled so Cloud entities, functions, and the current catalog composition
match Core.  Cookie-session and port adapters are written by the runtime
primitive phase after that install.

The task is intentionally resumable.  Each phase is safe to repeat and a
failure in one project does not prevent the remaining selected projects from
being attempted.
"""
from __future__ import annotations

from uuid import UUID

import structlog
from sqlalchemy import and_, select

logger = structlog.get_logger()


async def migrate_workspace_to_w4p(
    ctx: dict,
    workspace_id: str,
    project_ids: list[str] | None = None,
    dry_run: bool = False,
) -> dict:
    """Migrate all active workspace projects, or an explicit project subset.

    ``project_ids`` is always checked against ``workspace_id`` before any
    mutation.  ``dry_run`` returns the selected inventory without changing DB,
    Cloud, GitHub, or deployment state.
    """
    from src.core.database import async_session_factory
    from src.domain.projects.models import Project, ProjectRepo

    db_factory = ctx.get("db_factory") or async_session_factory
    workspace_uuid = UUID(workspace_id)
    requested = {UUID(item) for item in (project_ids or [])}

    async with db_factory() as db:
        stmt = (
            select(Project, ProjectRepo)
            .outerjoin(ProjectRepo, ProjectRepo.project_id == Project.id)
            .where(
                and_(
                    Project.workspace_id == workspace_uuid,
                    Project.deleted_at.is_(None),
                    Project.status.in_(["ready", "creating"]),
                )
            )
            .order_by(Project.created_at.asc())
        )
        rows = list((await db.execute(stmt)).all())

    if requested:
        found = {project.id for project, _repo in rows}
        missing = sorted(str(pid) for pid in requested - found)
        if missing:
            raise ValueError(
                "project_ids_not_in_workspace_or_not_active:" + ",".join(missing)
            )
        rows = [(project, repo) for project, repo in rows if project.id in requested]

    result = {
        "workspace_id": workspace_id,
        "dry_run": dry_run,
        "selected": len(rows),
        "completed": 0,
        "errors": 0,
        "projects": [],
    }
    logger.info(
        "migrate_workspace_to_w4p_start",
        workspace_id=workspace_id,
        total=len(rows),
        selected_project_count=len(requested) or None,
        dry_run=dry_run,
    )

    if dry_run:
        from src.domain.capabilities.operator_reconciliation import (
            build_reconciliation_plan,
            composition_repair_plan,
        )

        projects = []
        async with db_factory() as db:
            for project, repo in rows:
                active = getattr(project, "primary_hosting_provider", None)
                entry = {
                    "project_id": str(project.id),
                    "name": project.name,
                    "type": project.type,
                    "project_ref": project.project_ref,
                    "repo": getattr(repo, "repo_full_name", None),
                    "hosting_provider": active,
                }
                if project.type in {"html", "react_app", "nuxt_app"}:
                    from src.domain.hosting.profile import (
                        ApplicationHostingRequirements,
                        plan_hosting_migration,
                    )
                    from src.domain.hosting.project_provider import W4P_STATIC

                    source = active or "cloudflare_pages"
                    planned = plan_hosting_migration(
                        source,
                        W4P_STATIC,
                        ApplicationHostingRequirements(project.type),
                    )
                    entry["hosting_migration"] = {
                        "active_provider": planned.active_provider,
                        "migration_target": planned.migration_target,
                        "status": planned.compatibility.status,
                        "commits_active_provider": False,
                    }
                try:
                    repair = composition_repair_plan(
                        await build_reconciliation_plan(db, project.id)
                    )
                    entry["composition"] = {
                        "source": repair.get("source"),
                        "app_blocks": repair.get("app_blocks") or [],
                        "desired_feature_packs": repair.get("desired_feature_packs") or [],
                        "install_packs": [
                            action["pack_slug"] for action in repair.get("actions") or []
                        ],
                        "composition_errors": repair.get("composition_errors") or [],
                    }
                except Exception as exc:
                    entry["composition_error"] = str(exc)[:500]
                projects.append(entry)
        result["projects"] = projects
        return result

    from src.domain.hosting.migration_state import (
        allow_destination_deploy,
        begin_or_resume_cohort,
        mark_destination_prepared,
        mark_migration_failed,
        note_stage,
    )
    from src.domain.hosting.project_provider import W4P_STATIC
    from src.workers.tasks.cloud_sync import sync_project_to_cloud
    from src.workers.tasks.env_sync import sync_project_env_vars
    from src.workers.tasks.fix_deploy_workflows import fix_deploy_workflows
    from src.workers.tasks.migrate_runtime_primitives import migrate_runtime_primitives
    from src.workers.tasks.reconcile_project_capabilities import reconcile_project_capabilities

    for project, repo in rows:
        pid = str(project.id)
        phase = {"project_id": pid, "name": project.name, "phases": {}}
        failed_stage = "discover"
        try:
            async def _save(mutate, project_id=project.id) -> None:
                async with db_factory() as db:
                    row = await db.get(Project, project_id)
                    if row is None or row.workspace_id != workspace_uuid:
                        raise ValueError("project_workspace_boundary_changed")
                    mutate(row)
                    await db.commit()

            async def _remember(detail: str) -> None:
                def mutate(row) -> None:
                    row.hosting_migration = note_stage(
                        row.hosting_migration, "prepare", detail=detail
                    )
                await _save(mutate)

            def _open_destination(row) -> None:
                noted = note_stage(row.hosting_migration, "prepare", detail="env_sync")
                row.hosting_migration = allow_destination_deploy(noted)

            def _mark_prepared(row) -> None:
                row.hosting_migration = mark_destination_prepared(row.hosting_migration)

            # Persist the target before downstream tasks run.  Do not change
            # domains/routes here; activation remains guarded by W4P health.
            # hosting_migration keeps the previous provider. A failed repair
            # does not count as cutover, and ordinary deploys stay on the source.
            async with db_factory() as db:
                live = await db.get(Project, project.id)
                if live is None or live.workspace_id != workspace_uuid:
                    raise ValueError("project_workspace_boundary_changed")
                source = (live.primary_hosting_provider or "").strip() or "cloudflare_pages"
                state = begin_or_resume_cohort(
                    live.hosting_migration,
                    source=source,
                    target=W4P_STATIC,
                    rollback={
                        "live_hostname": live.live_hostname,
                        "preview_hostname": live.preview_hostname,
                    },
                )
                live.hosting_migration = state
                live.primary_hosting_provider = W4P_STATIC
                await db.commit()
            phase["operation_id"] = state["operation_id"]
            phase["previous_provider"] = state.get("previous_provider")
            phase["phases"]["hosting_target"] = "done"
            failed_stage = "cloud_bootstrap"

            cloud = await sync_project_to_cloud(ctx, pid)
            phase["phases"]["cloud_bootstrap"] = cloud.get("status", "done")
            if cloud.get("status") == "skipped":
                raise RuntimeError(cloud.get("reason") or "cloud_sync_skipped")
            await _remember("cloud_bootstrap")

            # Cloud project row must exist before pack install provisions entities.
            failed_stage = "composition"
            composition = await _apply_project_composition(db_factory, project.id)
            phase["phases"]["composition"] = composition
            await _remember("composition")

            failed_stage = "authz"
            authz = await _provision_project_authz(db_factory, project.id)
            phase["phases"]["authz"] = authz
            if authz.get("status") == "retry":
                raise RuntimeError(authz.get("reason") or "authz_retry")
            await _remember("authz")

            failed_stage = "capabilities"
            caps = await reconcile_project_capabilities(ctx, project_id=pid)
            phase["phases"]["capabilities"] = caps
            if caps.get("errors"):
                raise RuntimeError("capability_reconciliation_failed")
            await _remember("capabilities")

            failed_stage = "runtime_primitives"
            runtime = await migrate_runtime_primitives(ctx, project_id=pid)
            phase["phases"]["runtime_primitives"] = runtime
            if runtime.get("errors"):
                raise RuntimeError("runtime_primitives_migration_failed")
            await _remember("runtime_primitives")

            failed_stage = "env_sync"
            await sync_project_env_vars(ctx, project_id=pid)
            phase["phases"]["env_sync"] = "done"
            await _save(_open_destination)

            failed_stage = "workflows"
            workflows = await fix_deploy_workflows(
                ctx, project_id=pid, sync_runtime_adapters=True
            )
            phase["phases"]["workflows"] = workflows
            if workflows.get("errors"):
                raise RuntimeError("deploy_workflow_repair_failed")
            await _save(_mark_prepared)

            phase["status"] = "destination_prepared"
            phase["cutover_verified"] = False
            result["completed"] += 1
        except Exception as exc:
            error = str(exc)[:2000]
            phase["status"] = "error"
            phase["error"] = error
            result["errors"] += 1
            try:
                def _fail(row, stage=failed_stage, detail=error) -> None:
                    row.hosting_migration = mark_migration_failed(
                        row.hosting_migration, stage, detail
                    )
                await _save(_fail)
            except Exception:
                logger.error(
                    "migrate_workspace_to_w4p_state_update_failed",
                    project_id=pid,
                    exc_info=True,
                )
            logger.error(
                "migrate_workspace_to_w4p_project_failed",
                workspace_id=workspace_id,
                project_id=pid,
                error=str(exc),
                exc_info=True,
            )
        result["projects"].append(phase)

    logger.info(
        "migrate_workspace_to_w4p_done",
        **{k: v for k, v in result.items() if k != "projects"},
    )
    return result


async def _apply_project_composition(db_factory, project_id: UUID) -> dict:
    """Install the composition Core already decided for this project.

    GenerationPlan is authoritative. Discovery is only the fallback for projects
    that predate a persisted plan. Nothing outside that desired set is installed.
    """
    from src.domain.capabilities.operator_reconciliation import (
        apply_reconciliation,
        build_reconciliation_plan,
        composition_repair_plan,
    )

    async with db_factory() as db:
        repair = composition_repair_plan(await build_reconciliation_plan(db, project_id))
        if repair.get("composition_errors"):
            raise RuntimeError(
                "composition_invalid:" + ",".join(repair["composition_errors"])
            )
        if not repair["actions"]:
            return {
                "status": "current",
                "source": repair.get("source"),
                "desired_feature_packs": repair.get("desired_feature_packs") or [],
                "installed": [],
            }
        applied = await apply_reconciliation(db, project_id, repair)
    if applied.get("blocked"):
        failed = next(
            (item for item in reversed(applied.get("applied") or []) if not item.get("ok")),
            {},
        )
        detail = failed.get("errors") or failed.get("status") or "blocked"
        raise RuntimeError(f"composition_install_blocked:{detail}"[:500])
    return {
        "status": "applied",
        "source": repair.get("source"),
        "desired_feature_packs": repair.get("desired_feature_packs") or [],
        "installed": [item.get("pack_slug") for item in applied.get("applied") or []],
    }


async def _provision_project_authz(db_factory, project_id: UUID) -> dict:
    """Project staff membership into Cloud and enter authz shadow when it is safe.

    Enforcement stays off. A blocked drift report is returned to the caller and
    does not by itself discard the composition or backend migration. A retry
    means Cloud could not be read, so the caller stops this project.
    """
    from src.config import get_settings
    from src.domain.project_auth.provisioning_v2 import provision_authz_v2

    settings = get_settings()
    async with db_factory() as db:
        outcome = await provision_authz_v2(
            db,
            project_id,
            cloud_base_url=settings.cloud_control_api_url,
            cloud_token=settings.cloud_internal_token,
        )
    return {
        "status": outcome.status,
        "projection_state": outcome.projection_state,
        "reason": outcome.reason,
    }
