"""`okstra migrate` 코어 — `<PROJECT_ROOT>/.project-docs/okstra/` 산출물을
`<PROJECT_ROOT>/.okstra/` 로 이전한다.

설계 원칙
---------
- `prepare_migration_plan(project_root)` 는 부수효과 없이 변경 항목만 수집.
- `apply_migration_plan(plan, *, dry_run=True)` 가 실제 실행. dry-run 이 default.
- 가드: `.okstra/` 이미 존재 / `.project-docs/okstra/` 없음 / git 미사용 fallback.
- 다음 네 가지 변경만 수행 (그 외 파일은 절대 건드리지 않음):
    1. `git mv .project-docs/okstra .okstra` (git 없으면 일반 `mv`).
    2. `.project-docs/` 가 비면 `rmdir .project-docs`.
    3. `.gitignore` 의 `.project-docs/okstra/` 항목을 `.okstra/` 로 교체.
    4. `~/.okstra/{recent,active}.jsonl` 와 `~/.okstra/worktrees/registry.json`
       의 해당 프로젝트 path 항목 갱신 (project_root 가 정확히 일치하는 row 만).
"""
from __future__ import annotations

import json
import os
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

from okstra_ctl.worktree import is_git_work_tree
from okstra_ctl.json_boundary import (
    JsonBoundaryError,
    load_owned_object,
    write_owned_object_atomic,
)
# 모듈 import 인 이유: 이 파일의 공개 함수들이 `okstra_home` 이라는 파라미터
# 이름을 쓰고 있어, 같은 이름의 함수를 직접 import 하면 가려진다.
from okstra_project import dirs as project_dirs
from okstra_project.dirs import (
    LEGACY_OKSTRA_DIR_NAME,
    LEGACY_OKSTRA_RELATIVE,
    OKSTRA_DIR_NAME,
    OKSTRA_RELATIVE,
)


class MigrationRefused(RuntimeError):
    """Raised when the plan cannot be prepared safely (pre-condition fail)."""


@dataclass
class MigrationPlan:
    """All work the migrator would perform, as a single inspectable record.

    Render to JSON via `to_dict()` for dry-run output.
    """

    project_root: Path
    source_dir: Path  # <project>/.project-docs/okstra
    target_dir: Path  # <project>/.okstra
    use_git: bool
    remove_empty_parent: bool  # True if .project-docs would be empty after move
    gitignore_path: Optional[Path]  # <project>/.gitignore if entry update needed
    registry_updates: list[dict] = field(default_factory=list)
    # Each registry entry: {"file": "<abs path>", "row_count": N, "scope": "..."}

    def to_dict(self) -> dict:
        return {
            "projectRoot": str(self.project_root),
            "sourceDir": str(self.source_dir),
            "targetDir": str(self.target_dir),
            "useGit": self.use_git,
            "removeEmptyParent": self.remove_empty_parent,
            "gitignorePath": str(self.gitignore_path) if self.gitignore_path else None,
            "registryUpdates": self.registry_updates,
        }


@dataclass
class MigrationResult:
    """What the apply step actually did. `dry_run=True` returns an unchanged copy
    of the plan with all `applied_*` flags set to False.
    """

    dry_run: bool
    moved: bool
    parent_removed: bool
    gitignore_updated: bool
    registry_rows_updated: int

    def to_dict(self) -> dict:
        return {
            "dryRun": self.dry_run,
            "moved": self.moved,
            "parentRemoved": self.parent_removed,
            "gitignoreUpdated": self.gitignore_updated,
            "registryRowsUpdated": self.registry_rows_updated,
        }


def prepare_migration_plan(
    project_root: Path,
    *,
    okstra_home: Optional[Path] = None,
) -> MigrationPlan:
    """Inspect `project_root` and report what the migration would do.

    Raises `MigrationRefused` when the migration cannot proceed safely:
    - `.okstra/` already exists (someone migrated, or a collision).
    - `.project-docs/okstra/` is missing (nothing to migrate).
    """
    project_root = Path(project_root).resolve()
    source = project_root / LEGACY_OKSTRA_RELATIVE
    target = project_root / OKSTRA_RELATIVE

    if not source.is_dir():
        raise MigrationRefused(
            f"no legacy {LEGACY_OKSTRA_DIR_NAME}/ under {project_root}. "
            f"Either this project was never set up with okstra, or it has "
            f"already been migrated. ({target}/ {'exists' if target.exists() else 'does not exist'}.)"
        )
    if target.exists():
        raise MigrationRefused(
            f"refusing to migrate: {target} already exists. "
            f"Remove it manually if you intend to overwrite, then re-run."
        )

    use_git = is_git_work_tree(project_root)
    parent = source.parent  # <project>/.project-docs
    remove_empty_parent = _would_be_empty_after_remove(parent, source)

    gitignore = project_root / ".gitignore"
    gitignore_path: Optional[Path] = None
    if gitignore.is_file():
        try:
            text = gitignore.read_text(encoding="utf-8")
        except OSError:
            text = ""
        # Detect using the same line predicate apply uses, so a previewed
        # gitignore_path always corresponds to an actual rewrite. A bare
        # substring match would flag comments or longer paths
        # (`.project-docs/okstra-archive/`) that _update_gitignore never touches.
        if any(_is_legacy_gitignore_line(line) for line in text.splitlines()):
            gitignore_path = gitignore

    registry_updates = _scan_registries(
        project_root, okstra_home=_resolve_okstra_home(okstra_home)
    )

    return MigrationPlan(
        project_root=project_root,
        source_dir=source,
        target_dir=target,
        use_git=use_git,
        remove_empty_parent=remove_empty_parent,
        gitignore_path=gitignore_path,
        registry_updates=registry_updates,
    )


def apply_migration_plan(
    plan: MigrationPlan,
    *,
    dry_run: bool = True,
    okstra_home: Optional[Path] = None,
) -> MigrationResult:
    """Execute the migration. When `dry_run=True` (default), returns the
    no-op result so callers can preview before opting in.

    Failure during any step raises and leaves the project in whatever state
    that step had reached. No automatic rollback — git mv is the only step
    that touches the working tree, and a manual `git mv` reverse run is
    trivial.
    """
    if dry_run:
        return MigrationResult(
            dry_run=True,
            moved=False,
            parent_removed=False,
            gitignore_updated=False,
            registry_rows_updated=0,
        )

    _do_move(plan)
    parent_removed = _maybe_remove_empty_parent(plan)
    gitignore_updated = _update_gitignore(plan)
    registry_rows_updated = _apply_registry_updates(
        plan, okstra_home=_resolve_okstra_home(okstra_home)
    )

    return MigrationResult(
        dry_run=False,
        moved=True,
        parent_removed=parent_removed,
        gitignore_updated=gitignore_updated,
        registry_rows_updated=registry_rows_updated,
    )


# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #


def _resolve_okstra_home(override: Optional[Path]) -> Path:
    return Path(override) if override is not None else project_dirs.okstra_home()


def _would_be_empty_after_remove(parent: Path, child: Path) -> bool:
    """Return True if `parent` would be empty after `child` is moved out."""
    if not parent.is_dir():
        return False
    try:
        entries = list(parent.iterdir())
    except OSError:
        return False
    return all(p == child for p in entries)


def _do_move(plan: MigrationPlan) -> None:
    """`git mv` when possible, else regular `os.rename`."""
    src = plan.source_dir
    dst = plan.target_dir
    if plan.use_git:
        rc = subprocess.run(
            ["git", "mv", str(src.relative_to(plan.project_root)), str(dst.relative_to(plan.project_root))],
            cwd=str(plan.project_root), capture_output=True, text=True, check=False,
        )
        if rc.returncode != 0:
            # Surface the actual git error so the user can decide. We do not
            # fall back to plain mv when git mv fails — the user explicitly
            # ran inside a git worktree and should see why the move was
            # rejected (untracked files, conflicting moves, etc.).
            raise MigrationRefused(
                f"git mv failed: {rc.stderr.strip() or rc.stdout.strip()}"
            )
        return
    shutil.move(str(src), str(dst))


def _maybe_remove_empty_parent(plan: MigrationPlan) -> bool:
    if not plan.remove_empty_parent:
        return False
    parent = plan.source_dir.parent
    try:
        if parent.is_dir() and not any(parent.iterdir()):
            parent.rmdir()
            return True
    except OSError:
        return False
    return False


def _is_legacy_gitignore_line(line: str) -> bool:
    """A gitignore line that names exactly the legacy okstra entry.

    Comment lines and longer paths (`.project-docs/okstra-archive/`) that merely
    contain the legacy prefix are intentionally excluded — they are not the
    ignore rule the migration rewrites.
    """
    stripped = line.strip()
    return stripped == LEGACY_OKSTRA_DIR_NAME or stripped == f"{LEGACY_OKSTRA_DIR_NAME}/"


def _update_gitignore(plan: MigrationPlan) -> bool:
    if plan.gitignore_path is None:
        return False
    text = plan.gitignore_path.read_text(encoding="utf-8")
    new_lines = []
    changed = False
    for line in text.splitlines(keepends=True):
        if _is_legacy_gitignore_line(line):
            indent = line[: len(line) - len(line.lstrip())]
            new_lines.append(f"{indent}{OKSTRA_DIR_NAME}/\n")
            changed = True
        else:
            new_lines.append(line)
    if not changed:
        return False
    plan.gitignore_path.write_text("".join(new_lines), encoding="utf-8")
    return True


def _mentions_project_path(haystack: str, project_str: str) -> bool:
    """haystack 이 project_root 를 '경로로서' 언급하는지.

    `project_str in haystack` 단순 substring 은 `/x/app` 가 `/x/app-v2/...` 에도
    매칭되어 형제 prefix 프로젝트의 행을 잘못 잡는다. 경로 구분자(`/`)나 JSON
    닫는 따옴표(`"`) 같은 경계가 뒤따를 때만 매칭해 prefix 오인을 막는다.
    """
    return f"{project_str}/" in haystack or f'{project_str}"' in haystack


def _scan_registries(project_root: Path, *, okstra_home: Path) -> list[dict]:
    """Find okstra-home registry files that reference this project's old path."""
    project_str = str(project_root)
    legacy_marker = str(project_root / LEGACY_OKSTRA_RELATIVE)
    updates: list[dict] = []
    for name in ("recent.jsonl", "active.jsonl"):
        path = okstra_home / name
        if not path.is_file():
            continue
        try:
            lines = path.read_text(encoding="utf-8").splitlines()
        except OSError:
            continue
        count = sum(
            1 for ln in lines
            if legacy_marker in ln
            or (_mentions_project_path(ln, project_str) and LEGACY_OKSTRA_DIR_NAME in ln)
        )
        if count > 0:
            updates.append({"file": str(path), "rowCount": count, "scope": name})
    registry = okstra_home / "worktrees" / "registry.json"
    if registry.is_file():
        try:
            data = load_owned_object(registry, artifact="worktree registry")
        except JsonBoundaryError:
            data = {}
        text = json.dumps(data, ensure_ascii=False)
        if LEGACY_OKSTRA_DIR_NAME in text and _mentions_project_path(text, project_str):
            updates.append({"file": str(registry), "rowCount": text.count(LEGACY_OKSTRA_DIR_NAME), "scope": "worktrees-registry"})
    return updates


def _apply_registry_updates(plan: MigrationPlan, *, okstra_home: Path) -> int:
    """Rewrite registry files in place. Replaces `.project-docs/okstra` with
    `.okstra` only on lines that also mention this project's root, so we
    never touch other projects' rows.
    """
    project_str = str(plan.project_root)
    total = 0
    for name in ("recent.jsonl", "active.jsonl"):
        path = okstra_home / name
        if not path.is_file():
            continue
        try:
            text = path.read_text(encoding="utf-8")
        except OSError:
            continue
        new_lines: list[str] = []
        changed = 0
        for line in text.splitlines(keepends=True):
            if _mentions_project_path(line, project_str) and LEGACY_OKSTRA_DIR_NAME in line:
                new_lines.append(line.replace(LEGACY_OKSTRA_DIR_NAME, OKSTRA_DIR_NAME))
                changed += 1
            else:
                new_lines.append(line)
        if changed:
            path.write_text("".join(new_lines), encoding="utf-8")
            total += changed
    registry = okstra_home / "worktrees" / "registry.json"
    if registry.is_file():
        try:
            data = load_owned_object(registry, artifact="worktree registry")
        except JsonBoundaryError:
            data = {}
        # Only mutate when this project's root appears anywhere in the file —
        # otherwise leave it alone (covers the case where registry.json
        # contains an unrelated `.project-docs/okstra` literal in someone
        # else's row).
        text = json.dumps(data, ensure_ascii=False)
        if _mentions_project_path(text, project_str) and LEGACY_OKSTRA_DIR_NAME in text:
            updated, changed = _replace_in_project_rows(data, project_str)
            if changed:
                write_owned_object_atomic(
                    registry, updated, artifact="worktree registry"
                )
                total += changed
    return total


def _replace_in_project_rows(
    data: dict[str, object], project_str: str
) -> tuple[dict[str, object], int]:
    """For registry.json: replace `.project-docs/okstra` with `.okstra` only
    inside rows that mention `project_str`. registry.json is structured JSON
    (top-level object with task-key → row), so we operate at the JSON level
    to avoid clobbering unrelated rows.
    """
    updated = dict(data)
    changed = 0
    for key, row in data.items():
        row_text = json.dumps(row, ensure_ascii=False)
        if not _mentions_project_path(row_text, project_str) or LEGACY_OKSTRA_DIR_NAME not in row_text:
            continue
        new_row_text = row_text.replace(LEGACY_OKSTRA_DIR_NAME, OKSTRA_DIR_NAME)
        if new_row_text != row_text:
            updated[key] = json.loads(new_row_text)
            changed += row_text.count(LEGACY_OKSTRA_DIR_NAME) - new_row_text.count(
                LEGACY_OKSTRA_DIR_NAME
            )
    return updated, changed


def main(argv: Optional[list[str]] = None) -> int:
    """`python3 -m okstra_ctl.migrate [--apply] [--cwd <dir>] [--quiet]`.

    기본은 dry-run 미리보기(plan JSON 출력 후 종료). 거부 가드에 걸리면
    stderr 메시지와 함께 exit 1 — `bin/okstra` 의 `migrate` 서브커맨드가
    그대로 forward 한다.
    """
    import argparse
    import sys

    p = argparse.ArgumentParser(prog="okstra migrate")
    p.add_argument("--apply", action="store_true",
                   help="실제 실행 (기본: dry-run 미리보기)")
    p.add_argument("--cwd", default=".",
                   help="프로젝트 루트 (기본: 현재 디렉토리)")
    p.add_argument("--quiet", action="store_true", help="한 줄 JSON 만 출력")
    args = p.parse_args(argv)

    try:
        plan = prepare_migration_plan(Path(args.cwd))
    except MigrationRefused as exc:
        print(f"migrate refused: {exc}", file=sys.stderr)
        return 1
    result = apply_migration_plan(plan, dry_run=not args.apply)
    payload = {"plan": plan.to_dict(), "result": result.to_dict()}
    if args.quiet:
        print(json.dumps(payload, ensure_ascii=False))
    else:
        print(json.dumps(payload, indent=2, ensure_ascii=False))
        if result.dry_run:
            print("dry-run only — re-run with --apply to execute.",
                  file=sys.stderr)
    return 0


if __name__ == "__main__":
    import sys

    sys.exit(main())
