"""Repair software-job outputs written under the duplicated legacy user path.

The command is dry-run by default. It reads its database URL only from the
explicit ``ZCBOT_MIGRATION_DB_URL`` environment variable and never loads .env.
Run schema migration 0033 before applying this repair.
"""
# ruff: noqa: I001
from __future__ import annotations

import argparse
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from uuid import UUID

from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified

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

from core.software_contracts import DEFAULT_CAPABILITIES, get_contract
from core.software_jobs import software_job_output_path
from core.storage.models import Artifact, SoftwareJob, Task
from core.paths import from_db_path


@dataclass(frozen=True)
class RepairPlan:
    job_id: UUID
    user_root: Path
    working_dir: Path
    legacy_dir: Path
    output_dir: Path
    move_legacy_dir: bool
    metadata_moves: tuple[tuple[Path, Path], ...]


def _user_root(working_dir: Path, user_id: UUID) -> Path:
    expected = str(user_id)
    for candidate in (working_dir, *working_dir.parents):
        if candidate.name == expected and candidate.parent.name == "users":
            return candidate.resolve()
    raise RuntimeError(f"task working_dir has no user root for {user_id}")


def _within(root: Path, target: Path) -> Path:
    resolved = target.resolve()
    resolved.relative_to(root.resolve())
    return resolved


def build_plan(job: SoftwareJob, task: Task) -> RepairPlan | None:
    capability = getattr(job, "capability", DEFAULT_CAPABILITIES[0])
    contract = get_contract(capability)
    working_dir = from_db_path(task.working_dir).resolve()
    user_root = _user_root(working_dir, job.user_id)
    output_dir = _within(
        user_root, working_dir / contract.output_namespace / str(job.job_id)
    )
    legacy_dir = _within(
        user_root,
        user_root / Path(task.working_dir) / contract.output_namespace / str(job.job_id),
    )
    legacy_exists = legacy_dir.is_dir()
    output_exists = output_dir.is_dir()
    if legacy_exists and output_exists:
        raise RuntimeError(f"job {job.job_id}: legacy and target output directories both exist")
    if not legacy_exists and not output_exists:
        print(f"[WARN] job {job.job_id}: output directory is missing")
        return None
    source_dir = legacy_dir if legacy_exists else output_dir
    metadata_moves: list[tuple[Path, Path]] = []
    for item in job.artifact_manifest or []:
        output_id = str((item or {}).get("source_artifact_id") or "")
        if not output_id or contract.output_spec(output_id).publish:
            continue
        source = source_dir / str((item or {}).get("filename") or "")
        destination = source_dir / software_job_output_path(capability, output_id)
        if source == destination or not source.exists():
            continue
        if destination.exists():
            raise RuntimeError(f"job {job.job_id}: metadata destination already exists")
        metadata_moves.append((source, destination))
    return RepairPlan(
        job_id=job.job_id,
        user_root=user_root,
        working_dir=working_dir,
        legacy_dir=legacy_dir,
        output_dir=output_dir,
        move_legacy_dir=legacy_exists,
        metadata_moves=tuple(metadata_moves),
    )


def apply_files(plan: RepairPlan) -> None:
    for _, destination in plan.metadata_moves:
        destination.parent.mkdir(parents=True, exist_ok=True)
    for source, destination in plan.metadata_moves:
        os.replace(source, destination)
    if plan.move_legacy_dir:
        plan.output_dir.parent.mkdir(parents=True, exist_ok=True)
        os.replace(plan.legacy_dir, plan.output_dir)


def update_rows(session: Session, job: SoftwareJob, task: Task, plan: RepairPlan) -> int:
    capability = getattr(job, "capability", DEFAULT_CAPABILITIES[0])
    contract = get_contract(capability)
    artifact_count = 0
    manifest = [dict(item) for item in (job.artifact_manifest or [])]
    task_prefix = plan.working_dir.relative_to(plan.user_root).as_posix()
    for item in manifest:
        output_id = str(item.get("source_artifact_id") or "")
        if not output_id:
            continue
        path = (
            f"{contract.output_namespace}/{job.job_id}/"
            f"{software_job_output_path(capability, output_id)}"
        )
        item["path"] = path
        raw_artifact_id = item.get("artifact_id")
        if not raw_artifact_id:
            continue
        try:
            artifact_id = UUID(str(raw_artifact_id))
        except ValueError:
            continue
        artifact = session.get(Artifact, artifact_id)
        if artifact is None or artifact.user_id != job.user_id:
            raise RuntimeError(f"job {job.job_id}: artifact {artifact_id} is missing")
        artifact.current_path = f"{task_prefix}/{path}"
        if contract.output_spec(output_id).publish:
            artifact.software_job_id = job.job_id
        artifact_count += 1
    job.artifact_manifest = manifest
    flag_modified(job, "artifact_manifest")
    return artifact_count


def repair(session: Session, *, apply: bool, job_id: UUID | None = None) -> tuple[int, int]:
    statement = (
        select(SoftwareJob, Task)
        .join(Task, Task.task_id == SoftwareJob.task_id)
        .where(SoftwareJob.status == "succeeded")
        .order_by(SoftwareJob.created_at, SoftwareJob.job_id)
    )
    if job_id is not None:
        statement = statement.where(SoftwareJob.job_id == job_id)
    rows = session.execute(statement).all()
    prepared: list[tuple[SoftwareJob, Task, RepairPlan]] = []
    for job, task in rows:
        plan = build_plan(job, task)
        if plan is None:
            continue
        print(
            f"[INFO] job={job.job_id} move_dir={plan.move_legacy_dir} "
            f"metadata_moves={len(plan.metadata_moves)} target={plan.output_dir}"
        )
        prepared.append((job, task, plan))
    if not apply:
        return len(prepared), 0
    updated_artifacts = 0
    for job, task, plan in prepared:
        apply_files(plan)
        updated_artifacts += update_rows(session, job, task, plan)
    session.flush()
    return len(prepared), updated_artifacts


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--apply", action="store_true", help="apply filesystem and database changes")
    parser.add_argument("--job-id", type=UUID, help="limit repair to one software job")
    args = parser.parse_args()
    database_url = os.environ.get("ZCBOT_MIGRATION_DB_URL", "").strip()
    if not database_url:
        print("[ERR] ZCBOT_MIGRATION_DB_URL is required", file=sys.stderr)
        return 2
    engine = create_engine(database_url, pool_pre_ping=True, future=True)
    try:
        with Session(engine, future=True) as session:
            try:
                jobs, artifacts = repair(
                    session, apply=args.apply, job_id=args.job_id
                )
                if args.apply:
                    session.commit()
                else:
                    session.rollback()
            except Exception:
                session.rollback()
                raise
    finally:
        engine.dispose()
    mode = "applied" if args.apply else "validated"
    print(f"[OK] {mode} jobs: {jobs}")
    print(f"[OK] updated artifacts: {artifacts}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
