"""Repair and redeploy generated W4P applications.

This is a targeted repair path for existing generated HTML/React/Nuxt projects
whose frontend still calls legacy/auth routes but whose deployed W4P Worker does
not contain the current generated adapters.

The repair reuses the existing runtime migration task, which:

* installs the current auth, magic-link, contact, records, and file adapters;
* preserves ``POST /api/contact`` when the existing repository is a legacy app;
* rewrites stale generated auth/contact paths;
* commits and pushes the preview branch; and
* queues preview provisioning.

It then repairs the deployment workflow with runtime-adapter synchronization so
the preview-first promotion path can rebuild the W4P Worker. It does not modify
customer data directly and does not submit application forms.

Preview first:

    python scripts/repair_w4p_generated_apps.py --project PROJECT_UUID --dry-run

One project:

    python scripts/repair_w4p_generated_apps.py --project PROJECT_UUID

Workspace, in controlled batches:

    python scripts/repair_w4p_generated_apps.py --workspace WORKSPACE_UUID \
        --batch-size 2 --batch-delay-seconds 30

All generated projects, in controlled batches:

    python scripts/repair_w4p_generated_apps.py --all-workspaces \
        --batch-size 2 --batch-delay-seconds 30

Run this on the API/worker server with the production database, Redis, GitHub,
Cloud, and Cloudflare credentials. Keep ``arq src.workers.main.WorkerSettings``
running for the queued preview/deploy jobs.
"""

from __future__ import annotations

import argparse
import asyncio
import json
import sys
from collections.abc import Sequence
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

SUPPORTED_PROJECT_TYPES = ("html", "react_app", "nuxt_app")


def _prepare_orm_models() -> None:
    """Register every ORM model before SQLAlchemy builds foreign keys.

    The ARQ worker performs this bootstrap before opening the application
    database.  This standalone repair command must do the same; otherwise a
    partial import set can map ``projects.active_session_id`` before the
    ``prompt_sessions`` table is present in metadata.
    """

    from src.core.orm_import import import_all_orm_models

    import_all_orm_models()


async def _select_projects(
    *,
    project_ids: Sequence[str] = (),
    workspace_id: str | None = None,
) -> list[dict[str, str]]:
    from sqlalchemy import and_, select

    from src.core.database import async_session_factory
    from src.domain.projects.models import Project, ProjectRepo

    requested = {str(value) for value in project_ids}
    async with async_session_factory() as db:
        stmt = (
            select(Project.id, Project.workspace_id, Project.name, Project.type)
            .join(ProjectRepo, ProjectRepo.project_id == Project.id)
            .where(
                and_(
                    Project.deleted_at.is_(None),
                    Project.status.in_(("ready", "creating")),
                    Project.type.in_(SUPPORTED_PROJECT_TYPES),
                )
            )
            .order_by(Project.created_at.asc())
        )
        if workspace_id:
            stmt = stmt.where(Project.workspace_id == workspace_id)
        if requested:
            stmt = stmt.where(Project.id.in_(requested))
        rows = (await db.execute(stmt)).all()

    selected = [
        {
            "project_id": str(project_id),
            "workspace_id": str(project_workspace_id),
            "name": str(name or ""),
            "type": str(project_type or ""),
        }
        for project_id, project_workspace_id, name, project_type in rows
    ]
    if requested:
        found = {row["project_id"] for row in selected}
        missing = sorted(requested - found)
        if missing:
            raise ValueError(
                "project not found, unsupported, deleted, not ready, or has no repo: "
                + ", ".join(missing)
            )
    return selected


async def _repair_project(project_id: str) -> dict:
    from src.core.database import async_session_factory
    from src.workers.tasks.fix_deploy_workflows import fix_deploy_workflows
    from src.workers.tasks.migrate_runtime_primitives import migrate_runtime_primitives

    ctx = {"db_factory": async_session_factory}
    runtime = await migrate_runtime_primitives(ctx, project_id=project_id)
    if runtime.get("errors") or runtime.get("pack_blocked"):
        return {
            "project_id": project_id,
            "status": "failed",
            "runtime": runtime,
            "workflow": None,
        }

    workflow = await fix_deploy_workflows(
        ctx,
        project_id=project_id,
        sync_runtime_adapters=True,
    )
    status = "completed" if not workflow.get("errors") else "failed"
    return {
        "project_id": project_id,
        "status": status,
        "runtime": runtime,
        "workflow": workflow,
    }


async def _run(
    projects: list[dict[str, str]],
    *,
    batch_size: int,
    batch_delay_seconds: float,
    dry_run: bool,
) -> list[dict]:
    if dry_run:
        return [{**project, "status": "dry_run"} for project in projects]

    output: list[dict] = []
    for offset in range(0, len(projects), batch_size):
        batch = projects[offset : offset + batch_size]
        for project in batch:
            try:
                output.append(await _repair_project(project["project_id"]))
            except Exception as exc:  # keep later projects independent
                output.append(
                    {
                        "project_id": project["project_id"],
                        "status": "failed",
                        "error": str(exc)[:2000],
                    }
                )
        if offset + batch_size < len(projects) and batch_delay_seconds:
            await asyncio.sleep(batch_delay_seconds)
    return output


async def _main_async(args: argparse.Namespace) -> tuple[list[dict[str, str]], list[dict]]:
    # Keep discovery and repair in one event loop; SQLAlchemy's async pool is
    # loop-bound on some Python/asyncpg versions.
    _prepare_orm_models()
    selected = await _select_projects(
        project_ids=args.project_ids or (),
        workspace_id=args.workspace,
    )
    result = await _run(
        selected,
        batch_size=args.batch_size,
        batch_delay_seconds=args.batch_delay_seconds,
        dry_run=args.dry_run,
    )
    return selected, result


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    selector = parser.add_mutually_exclusive_group(required=True)
    selector.add_argument("--project", action="append", dest="project_ids", help="Project UUID; repeatable.")
    selector.add_argument("--workspace", help="Repair all supported generated projects in one workspace.")
    selector.add_argument("--all-workspaces", action="store_true", help="Repair all supported generated projects.")
    parser.add_argument("--dry-run", action="store_true", help="Print selected projects without changing repositories or queueing jobs.")
    parser.add_argument("--batch-size", type=int, default=2, help="Projects per batch (default: 2).")
    parser.add_argument("--batch-delay-seconds", type=float, default=30.0, help="Pause between batches (default: 30).")
    args = parser.parse_args()

    if args.batch_size < 1:
        parser.error("--batch-size must be at least 1")
    if args.batch_delay_seconds < 0:
        parser.error("--batch-delay-seconds cannot be negative")

    selected, result = asyncio.run(_main_async(args))
    print(json.dumps({"selected": len(selected), "projects": result}, indent=2, default=str))
    if any(row.get("status") == "failed" for row in result):
        raise SystemExit(1)


if __name__ == "__main__":
    main()
