"""Cut an existing generated app over to Cloud auth identities.

Resolves the Git checkout from the Core project record (clone/pull of the
preview branch). `--repo` is an optional local override.

With no `--project-id`, every HTML/React/Nuxt project that has a GitHub repo
is scanned. Default is dry-run. `--apply` writes Cloud users, replaces local
BFF adapters, commits, and pushes unless `--no-push` or `--repo` is set.

Usage (P2HS-BE root, same .env as the API):

    python -m scripts.migrate_repo_cloud_auth
    python -m scripts.migrate_repo_cloud_auth --project-id <uuid>
    python -m scripts.migrate_repo_cloud_auth --project-id <uuid> --apply
    python -m scripts.migrate_repo_cloud_auth --project-id <uuid> --apply --data-export <file>
    python -m scripts.migrate_repo_cloud_auth --project-id <uuid> --repo <checkout> --apply
"""

from __future__ import annotations

import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Any
from uuid import UUID

from src.core.orm_import import import_all_orm_models

import_all_orm_models()

from src.domain.codegen.legacy_cloud_cutover import CutoverReport  # noqa: E402
from src.domain.codegen.legacy_user_store import (  # noqa: E402
    DiscoveredUser,
    RepoScan,
    normalize_role,
)
from src.domain.projects.models import Project  # noqa: E402


def _parse_user(raw: str) -> DiscoveredUser:
    fields: dict[str, str] = {}
    for part in raw.split(","):
        if "=" not in part:
            raise ValueError(f"Expected key=value in --user, got {raw!r}")
        key, value = part.split("=", 1)
        fields[key.strip().lower()] = value.strip()
    email = fields.get("email", "")
    if "@" not in email:
        raise ValueError(f"--user needs email=..., got {raw!r}")
    return DiscoveredUser(
        email=email.lower(),
        role=normalize_role(fields.get("role")),
        name=fields.get("name", ""),
        source="cli",
    )


def _validate_scope(*, project_ids: list[str], repo: str | None, extra_users: list[str]) -> None:
    if repo and len(project_ids) != 1:
        raise ValueError("--repo requires exactly one --project-id")
    if extra_users and len(project_ids) != 1:
        raise ValueError("--user requires exactly one --project-id")


def _needs_fix(report: CutoverReport) -> bool:
    scan = report.scan
    return bool(
        scan.local_route_files
        or scan.users
        or scan.seed_markers
        or not scan.record_adapter_present
        or not scan.auth_adapter_present
    )


def _print_report(
    report: CutoverReport,
    *,
    repo: Path,
    extra: list[DiscoveredUser],
    mutating: bool,
    pushed: str | None = None,
    error: str | None = None,
) -> None:
    print(f"\nProject {report.project_name!r} ({report.project_id})")
    print(f"type={report.project_type}")
    print(f"Repo {repo}")
    print(f"Mode {'APPLY' if mutating else 'DRY-RUN'}")
    if report.source_archive:
        print(f"Private source/data backup: {report.source_archive}")
    if error and not report.imported and not report.adapter:
        print(f"ERROR: {error}")
        return
    print(f"Local admin routes: {report.scan.local_route_files or '(none)'}")
    print(f"Seed markers: {report.scan.seed_markers or '(none)'}")
    print(f"Password-store files: {report.scan.password_store_files or '(none)'}")
    print(f"Record adapter present: {report.scan.record_adapter_present}")
    print(f"Auth adapter present: {report.scan.auth_adapter_present}")
    print(f"Discovered users: {len(report.scan.users)}")
    for user in report.scan.users:
        name = user.name or "-"
        print(f"  - {user.email} role={user.role} name={name} src={user.source}")
    if extra:
        print("CLI users:")
        for user in extra:
            print(f"  - {user.email}  role={user.role}")
    if report.imported:
        print("Import:")
        for item in report.imported:
            suffix = f" ({item.detail})" if item.detail else ""
            print(f"  [{item.status}] {item.email} as {item.role}{suffix}")
    if report.imported_records:
        print("Records:")
        for entity, count in report.imported_records.items():
            print(f"  [{entity}] {count}")
    if report.provisioned_entities:
        print("Entities:")
        for item in report.provisioned_entities:
            print(f"  [{item.get('action')}] {item.get('entity')}")
    if report.adapter:
        print(f"Adapter written: {report.adapter.get('written')}")
        print(f"Adapter backed up: {report.adapter.get('backed_up')}")
        print(f"Adapter skipped: {report.adapter.get('skipped')}")
    if pushed:
        print(f"Pushed: {pushed}")
    if error:
        print(f"ERROR: {error}")
    if report.dry_run:
        print("Needs cutover." if _needs_fix(report) else "Already on Cloud adapters.")


async def _checkout_project(project: Project) -> tuple[str, str]:
    from src.config import get_settings
    from src.core.database import async_session_factory
    from src.domain.projects.paths import get_project_repo_path
    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, run_git

    settings = get_settings()
    linked = getattr(project, "repo", None)
    if linked is None or not getattr(linked, "repo_full_name", None):
        raise RuntimeError("Project has no GitHub repo linked")
    preview_branch = (
        getattr(linked, "preview_branch", None) or settings.hosting_preview_branch or "preview"
    )
    default_branch = getattr(linked, "default_branch", None) or "main"
    repo_path = get_project_repo_path(settings.repo_workspace, project.workspace_id, project.id)
    git_dir = Path(repo_path) / ".git"
    if git_dir.is_dir():
        # Never reset/clean the only available copy of a legacy app's local data.
        rc, current, _ = await run_git(repo_path, "branch", "--show-current")
        if rc or (current or "").strip() != preview_branch:
            raise RuntimeError(
                "Existing checkout is not on the preview branch; inspect it before migration"
            )
        return repo_path, preview_branch
    installation_id = await resolve_installation_id_for_workspace(
        async_session_factory,
        project.workspace_id,
    )
    github = GitHubAppClient(installation_id=installation_id)
    clone_url = await github.get_authenticated_clone_url(linked.repo_full_name)
    await prepare_working_tree(
        repo_path,
        clone_url,
        branch=preview_branch,
        heal_missing_remote_from=default_branch,
    )
    return repo_path, preview_branch


async def _installation_clone_url(project: Project) -> str:
    from src.core.database import async_session_factory
    from src.integrations.github_client import GitHubAppClient
    from src.integrations.github_installation import resolve_installation_id_for_workspace

    linked = getattr(project, "repo", None)
    if linked is None or not getattr(linked, "repo_full_name", None):
        raise RuntimeError("Project has no GitHub repo linked")
    installation_id = await resolve_installation_id_for_workspace(
        async_session_factory,
        project.workspace_id,
    )
    github = GitHubAppClient(installation_id=installation_id)
    return await github.get_authenticated_clone_url(linked.repo_full_name)


async def _set_origin_url(repo_path: str, clone_url: str) -> None:
    from src.workers.repo_ops import run_git

    rc, _, _ = await run_git(repo_path, "remote", "get-url", "origin")
    if rc == 0:
        rc, _, err = await run_git(repo_path, "remote", "set-url", "origin", clone_url)
        if rc:
            raise RuntimeError(f"git remote set-url failed: {err}")
        return
    rc, _, err = await run_git(repo_path, "remote", "add", "origin", clone_url)
    if rc:
        raise RuntimeError(f"git remote add failed: {err}")


async def _commit_and_push(
    repo_path: str,
    branch: str,
    *,
    push: bool,
    adapter: dict | None = None,
    clone_url: str | None = None,
) -> str | None:
    from src.config import get_settings
    from src.workers.repo_ops import push_repo, run_git

    settings = get_settings()
    if not (Path(repo_path) / ".git").is_dir():
        if push:
            raise RuntimeError("Cannot push: checkout is not a git repository")
        return None
    rc, porcelain, _ = await run_git(repo_path, "status", "--porcelain")
    if rc != 0:
        raise RuntimeError("Cannot inspect migration checkout")

    async def checked(*arguments):
        code, _, _ = await run_git(repo_path, *arguments)
        if code:
            raise RuntimeError(f"Migration git {arguments[0]} failed; not pushing")

    if (porcelain or "").strip():
        await checked("config", "user.name", settings.git_committer_name)
        await checked("config", "user.email", "noreply@p2hs.com")
        paths = list((adapter or {}).get("written") or [])
        for backup in (adapter or {}).get("backed_up") or []:
            original = backup.removesuffix(".p2hs-local.bak")
            code, _, _ = await run_git(repo_path, "ls-files", "--error-unmatch", "--", original)
            if code == 0:
                paths.append(original)
        if paths:
            rc, staged, _ = await run_git(repo_path, "diff", "--cached", "--name-only")
            if rc or (staged or "").strip():
                raise RuntimeError("Commit existing staged changes separately before migration")
            await checked("add", "--", *sorted(set(paths)))
            code, _, _ = await run_git(repo_path, "diff", "--cached", "--quiet")
            if code == 1:
                await checked(
                    "commit",
                    "-m",
                    "chore: wire generated app to P2HS Cloud\n\nAuthored by P2HS only",
                )
            elif code != 0:
                raise RuntimeError("Cannot verify staged migration changes")
    if not push:
        return None
    if not clone_url:
        raise RuntimeError("GitHub App clone URL is required to push")
    await _set_origin_url(repo_path, clone_url)
    await push_repo(repo_path, branch=branch, force_with_lease=True)
    return branch


async def _run(args: argparse.Namespace) -> int:
    if getattr(args, "export_plan", None):
        return await _export_only(args)

    from src.core.database import async_session_factory
    from src.domain.codegen.legacy_cloud_cutover import cut_over_generated_repo
    from src.domain.projects.repository import ProjectRepository

    project_ids = [UUID(value) for value in (args.project_id or [])]
    data_export = getattr(args, "data_export", None)
    if args.apply or args.install_adapter:
        if len(project_ids) != 1:
            print("Route cutover requires one --project-id.", file=sys.stderr)
            return 2
    if data_export and len(project_ids) != 1:
        raise ValueError("--data-export requires exactly one --project-id")
    try:
        _validate_scope(
            project_ids=[str(item) for item in project_ids],
            repo=args.repo,
            extra_users=args.user or [],
        )
    except ValueError as exc:
        print(str(exc), file=sys.stderr)
        return 2

    extra = [_parse_user(item) for item in (args.user or [])]
    import_dry_run = not (args.apply or args.import_users)
    adapter_dry_run = not (args.apply or args.install_adapter)
    ensure_auth = args.apply or args.ensure_auth
    mutating = not import_dry_run or not adapter_dry_run or ensure_auth
    push = args.push if args.push is not None else (mutating and not args.repo)

    async with async_session_factory() as db:
        projects_repo = ProjectRepository(db)
        if project_ids:
            projects: list[Project] = []
            missing: list[UUID] = []
            for project_id in project_ids:
                project = await projects_repo.get_with_repo(project_id)
                if not project or project.deleted_at is not None:
                    missing.append(project_id)
                    continue
                projects.append(project)
            if missing:
                print(
                    "Project not found: " + ", ".join(str(item) for item in missing),
                    file=sys.stderr,
                )
                return 1
        else:
            projects = await projects_repo.list_pages_projects_with_repos()

        # The cutover resolves and validates each workspace's actual Cloud instance.

        snapshots = [
            {
                "project": project,
                "id": str(project.id),
                "name": str(project.name),
                "type": str(project.type),
            }
            for project in projects
        ]

        summaries: list[dict[str, Any]] = []
        failures = 0
        needs_fix = 0
        for item in snapshots:
            project = item["project"]
            snapshot = item
            error = None
            pushed = None
            report = None
            repo_path = args.repo or ""
            effective_export = data_export
            try:
                if args.repo:
                    repo_path = str(Path(args.repo).resolve())
                    if not Path(repo_path).is_dir():
                        raise RuntimeError(f"Repo path not found: {repo_path}")
                    branch = (
                        getattr(getattr(project, "repo", None), "preview_branch", None) or "preview"
                    )
                else:
                    repo_path, branch = await _checkout_project(project)
                effective_export = data_export
                if (args.apply or args.install_adapter) and not effective_export:
                    effective_export = await _auto_export_live_data(
                        db, project, repo_path, args
                    )
                report = await cut_over_generated_repo(
                    db=db,
                    project=project,
                    repo_path=repo_path,
                    import_dry_run=import_dry_run,
                    adapter_dry_run=adapter_dry_run,
                    ensure_auth=ensure_auth,
                    extra_users=extra,
                    data_export=effective_export,
                    retire_shadow_stores=not adapter_dry_run,
                )
                if any(row.status == "error" for row in report.imported):
                    raise RuntimeError("Cloud import failed; migration is incomplete")
                if mutating:
                    await db.commit()
                    try:
                        from src.workers.tasks.env_sync import sync_project_env_vars

                        await sync_project_env_vars({}, project_id=str(project.id))
                        clone_url = await _installation_clone_url(project) if push else None
                        pushed = await _commit_and_push(
                            repo_path,
                            branch,
                            push=push,
                            adapter=report.adapter,
                            clone_url=clone_url,
                        )
                    except Exception as exc:
                        error = str(exc)
                        failures += 1
            except Exception as exc:
                error = str(exc)
                failures += 1
                if mutating:
                    try:
                        await db.rollback()
                    except Exception:
                        pass
                if report is None:
                    report = CutoverReport(
                        project_id=snapshot["id"],
                        project_name=snapshot["name"],
                        project_type=snapshot["type"],
                        scan=RepoScan(
                            users=[],
                            local_route_files=[],
                            seed_markers=[],
                            password_store_files=[],
                            record_adapter_present=False,
                            auth_adapter_present=False,
                        ),
                        dry_run=not mutating,
                    )

            assert report is not None
            if error is None and _needs_fix(report):
                needs_fix += 1
            if args.json:
                payload = report.as_dict()
                payload["error"] = error
                payload["pushed"] = pushed
                payload["repo_path"] = repo_path or None
                payload["data_export"] = effective_export
                summaries.append(payload)
            else:
                _print_report(
                    report,
                    repo=Path(repo_path) if repo_path else Path("."),
                    extra=extra,
                    mutating=mutating,
                    pushed=pushed,
                    error=error,
                )

        if mutating:
            try:
                await db.commit()
            except Exception:
                await db.rollback()

    if args.json:
        print(
            json.dumps(
                {
                    "mode": "APPLY" if mutating else "DRY-RUN",
                    "scanned": len(projects),
                    "needs_fix": needs_fix,
                    "errors": failures,
                    "projects": summaries,
                },
                indent=2,
            )
        )
    else:
        print(
            f"\nScanned {len(projects)} project(s). Need cutover: {needs_fix}. Errors: {failures}."
        )
        if not mutating:
            print("Re-run with --apply to provision auth, upsert users,")
            print("replace the local BFF, commit, and push the preview branch.")
            print("Imported users sign in with Cloud magic link;")
            print("local passwords are not copied.")
    return 1 if failures else 0


async def _authorization_header(args: argparse.Namespace, db, project_id: str) -> dict[str, str]:
    if args.source_authorization_env:
        value = os.environ.get(args.source_authorization_env, "")
        if not value:
            raise ValueError("Source authorization environment variable is empty")
        return {"Authorization": value}
    from src.domain.codegen.legacy_export_auth import mint_workspace_owner_export_token

    token = await mint_workspace_owner_export_token(db, project_id)
    return {"Authorization": f"Bearer {token}"}


async def _auto_export_live_data(
    db, project: Project, repo_path: str, args: argparse.Namespace
) -> str:
    import httpx
    from src.domain.codegen.legacy_data_export import (
        default_export_dir,
        export_project_live_data,
        preview_origin_for_project,
    )

    origin = preview_origin_for_project(project)
    headers = await _authorization_header(args, db, str(project.id))
    directory = args.export_dir or default_export_dir(str(project.id))
    async with httpx.AsyncClient(headers=headers, timeout=60, follow_redirects=False) as client:
        path, snapshot = await export_project_live_data(
            project_id=str(project.id),
            origin=origin,
            repo_path=repo_path,
            client=client,
            export_dir=directory,
        )
    if not args.json:
        counts = {key: item["count"] for key, item in snapshot["collections"].items()}
        print(f"Live export: {path}")
        print(f"sha256: {snapshot['sha256']}")
        print(f"counts: {counts}")
        users = snapshot["collections"].get("users") or {}
        if users.get("count") == 0:
            print(
                "Live /api/admin/users is the Cloud adapter without Worker credentials; "
                "staff identities will come from the checkout scan and workspace members."
            )
    return str(path)


async def _export_only(args: argparse.Namespace) -> int:
    """Read-only live export; deliberately separate from commits or Cloud writes."""
    import httpx
    from src.core.database import async_session_factory
    from src.domain.codegen.legacy_data_export import export_live_data, save_export

    if len(args.project_id or []) != 1 or not args.export_dir:
        raise ValueError("--export-plan requires one --project-id and --export-dir outside Git")
    if any((args.apply, args.import_users, args.install_adapter, args.ensure_auth, args.push)):
        raise ValueError("Export must finish separately before an apply or push")
    plan = json.loads(Path(args.export_plan).read_text(encoding="utf-8-sig"))
    if args.source_authorization_env:
        headers = await _authorization_header(args, None, args.project_id[0])
    else:
        async with async_session_factory() as db:
            try:
                headers = await _authorization_header(args, db, args.project_id[0])
                await db.commit()
            except Exception:
                await db.rollback()
                raise
    async with httpx.AsyncClient(headers=headers, timeout=60, follow_redirects=False) as client:
        snapshot = await export_live_data(plan, args.project_id[0], client=client)
    path = save_export(snapshot, args.export_dir)
    print(
        json.dumps(
            {
                "export": str(path),
                "sha256": snapshot["sha256"],
                "coverage": snapshot["coverage"],
                "counts": {key: item["count"] for key, item in snapshot["collections"].items()},
            },
            indent=2,
        )
    )
    return 0


def main() -> None:
    parser = argparse.ArgumentParser(
        description=(
            "Import local generated-app users into Cloud and wire "
            "/api/admin/users to Cloud auth. Omit --project-id to scan every "
            "HTML/React/Nuxt project. Omit --repo to clone/pull from GitHub."
        ),
    )
    parser.add_argument(
        "--project-id",
        action="append",
        help="Core project UUID (repeatable). Omit to scan every Pages project.",
    )
    parser.add_argument(
        "--repo",
        help="Optional local checkout. Requires exactly one --project-id.",
    )
    parser.add_argument(
        "--apply",
        action="store_true",
        help=(
            "Export live preview data, import it to Cloud, replace local routes, "
            "commit, and push. Pass --data-export to skip the live capture."
        ),
    )
    parser.add_argument(
        "--ensure-auth",
        action="store_true",
        help="Provision Cloud auth config/keys/owner even without --apply",
    )
    parser.add_argument(
        "--import-users",
        action="store_true",
        help="Upsert discovered users even without --apply",
    )
    parser.add_argument(
        "--install-adapter",
        action="store_true",
        help="Replace local admin-users BFF even without --apply",
    )
    parser.add_argument(
        "--user",
        action="append",
        help="Extra identity email=a@b.com,role=admin,name=Name (repeatable; one project)",
    )
    parser.add_argument("--json", action="store_true", help="Print a JSON report")
    parser.add_argument("--export-plan", help="Read-only live API export plan JSON; no cutover")
    parser.add_argument(
        "--data-export",
        help=(
            "Verified live export. If omitted with --apply, "
            "Core captures the preview automatically."
        ),
    )
    parser.add_argument("--export-dir", help="Private output directory outside Git repositories")
    parser.add_argument(
        "--source-authorization-env",
        help=(
            "Optional env var with a live Authorization header. "
            "If omitted, Core mints a short-lived Cloud JWT for the workspace owner."
        ),
    )
    parser.add_argument(
        "--skip-cloud",
        action="store_true",
        help="Allow adapter writes without Cloud credentials",
    )
    parser.add_argument(
        "--push",
        dest="push",
        action="store_true",
        default=None,
        help="Push the preview branch after --apply (default when Core cloned the repo)",
    )
    parser.add_argument(
        "--no-push",
        dest="push",
        action="store_false",
        help="Commit locally but do not push",
    )
    args = parser.parse_args()
    raise SystemExit(asyncio.run(_run(args)))


if __name__ == "__main__":
    main()
