#!/usr/bin/env python3
"""OKSTRA follow-up task spawner.

Reads the ``followUpTasks[]`` array from a final-report ``data.json``
(the JSON SSOT for the final-report markdown) and creates stub task
directories for rows whose ``autoSpawn`` is ``yes`` AND whose ``origin``
is not the same-task-key ``phase-continuation`` marker.

Idempotent: rows whose target directory already exists are reported as
``existing`` and skipped. Existing directories are NEVER mutated.

Output: writes new directories under
``<project_root>/.okstra/tasks/<task-group>/<new-task-id>/`` with:
- ``task-manifest.json`` — minimal manifest (schemaVersion 1.0,
  currentStatus ``todo``, workflow.currentPhase = suggestedTaskType,
  workflow.currentPhaseState ``not-started``, parentTaskKey /
  spawnedFromReport recorded under ``relatedTasks``).
- ``instruction-set/task-brief.md`` — stub brief naming the parent and
  copying the Reason / Scope cells from the data.json row.
- ``task-index.md`` — short human-readable summary.

The script DOES NOT call the okstra runtime; it produces just enough
on-disk state for the next user-driven entry — ``/okstra-run
task-key=<new-key> task-type=<suggested>`` inside a Claude Code session,
or ``scripts/okstra.sh --task-key <new-key> --task-type <suggested>``
in a separate terminal — to pick the follow-up up and re-render a fully
canonical manifest on first execution.

Usage:
    python3 scripts/okstra-spawn-followups.py \\
        <final-report-data.json> \\
        --project-root <abs-path> \\
        --task-group <group-slug> \\
        --parent-task-key <parent-task-key> \\
        [--dry-run]

Exit codes:
    0  — at least one follow-up evaluated (including ``skipped`` /
         ``existing`` only)
    1  — invocation / parsing failure, or any row failed validation
"""
from __future__ import annotations

import argparse
import datetime as dt
import json
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from okstra_ctl import next_phase  # noqa: E402
from okstra_ctl.final_report_paths import final_report_markdown_path  # noqa: E402
from okstra_ctl.paths import task_manifest_file  # noqa: E402
from okstra_ctl.workflow import PHASE_SEQUENCE  # noqa: E402
from okstra_project.dirs import tasks_root  # noqa: E402


SLUG_RE = re.compile(r"[^a-zA-Z0-9-]+")

ALLOWED_TASK_TYPES = set(PHASE_SEQUENCE)
ALLOWED_ORIGINS = {
    "phase-continuation",
    "out-of-plan",
    "verifier-concern",
    "scope-boundary",
    "open-question",
    "manual",
}
# Origins that point at the SAME task-key (next phase) and therefore
# must never spawn a new task directory — the user advances via
# /okstra-run.
NON_SPAWNING_ORIGINS = {"phase-continuation"}


def _slugify(value: str) -> str:
    value = value.strip()
    value = SLUG_RE.sub("-", value)
    value = re.sub(r"-+", "-", value)
    return value.strip("-").lower()


def _validate_row(row: dict) -> tuple[bool, str]:
    origin = (row.get("origin") or "").strip()
    if origin not in ALLOWED_ORIGINS:
        return False, f"invalid origin: {origin!r}"
    task_type = (row.get("suggestedTaskType") or "").strip()
    if task_type not in ALLOWED_TASK_TYPES:
        return False, f"invalid suggestedTaskType: {task_type!r}"
    if not (row.get("title") or "").strip():
        return False, "title is empty"
    if not (row.get("reason") or "").strip():
        return False, "reason is empty"
    if not (row.get("newTaskId") or "").strip():
        return False, "newTaskId is empty"
    return True, ""


def _write_manifest(path: Path, payload: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(
        json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )


def _data_to_report_path(data_path: Path) -> Path:
    """Derive the markdown sibling path used in spawned manifests for
    the `parentReportPath` field. Falls back to replacing the suffix with
    ``.md`` when the conventional suffix is not recognised.
    """
    return final_report_markdown_path(data_path)


def _project_id_of(task_key: str) -> str:
    """`project-id:task-group:task-id` 의 첫 세그먼트, 아니면 "".

    A follow-up belongs to its parent's project by construction, so the new
    task-key inherits that segment rather than re-deriving it from disk.
    Omitting it is not a cosmetic difference: `okstra_project.state.
    parse_task_key` raises on a two-segment key, and every catalog reader goes
    through it, so one `<group>/<id>` entry took down `list_project_tasks` for
    the whole project — the wizard could not render its first screen.
    """
    parts = (task_key or "").split(":")
    if len(parts) != 3 or not all(parts):
        return ""
    return parts[0]


def _spawn_one(
    *,
    project_root: Path,
    project_id: str,
    task_group: str,
    parent_task_key: str,
    parent_report_relative: str,
    row: dict,
    dry_run: bool,
) -> tuple[str, str]:
    """Returns (status, target_relative_path).

    status ∈ {created, existing, skipped, invalid}
    """
    ok, why = _validate_row(row)
    if not ok:
        return ("invalid", why)

    new_task_id = _slugify(row["newTaskId"])
    if not new_task_id:
        return ("invalid", "newTaskId slug is empty after normalisation")

    task_root = (
        tasks_root(project_root)
        / _slugify(task_group)
        / new_task_id
    )
    rel = task_root.relative_to(project_root).as_posix()
    if task_root.exists():
        return ("existing", rel)
    if dry_run:
        return ("created", rel)

    suggested = row["suggestedTaskType"].strip()
    title = row["title"].strip()
    scope = (row.get("scope") or "").strip()
    reason = row["reason"].strip()
    origin = row["origin"].strip()
    priority = (row.get("priority") or "P1").strip()
    ticket_id = (row.get("ticketId") or "").strip()
    new_task_key = f"{project_id}:{task_group}:{new_task_id}"
    now = dt.datetime.now(dt.timezone.utc).isoformat()

    spawned_meta: dict = {
        "parentTaskKey": parent_task_key,
        "parentReportPath": parent_report_relative,
        "origin": origin,
        "rowId": row.get("id", ""),
        "priority": priority,
        "spawnedAt": now,
    }
    if ticket_id:
        spawned_meta["ticketId"] = ticket_id

    manifest_payload = {
        "schemaVersion": "1.0",
        "taskGroup": task_group,
        "taskId": new_task_id,
        "taskKey": new_task_key,
        "taskGroupPathSegment": _slugify(task_group),
        "taskIdPathSegment": new_task_id,
        "taskType": suggested,
        "workCategory": "unknown",
        "currentStatus": "todo",
        "spawnedFromFollowUp": spawned_meta,
        "relatedTasks": [
            {"taskKey": parent_task_key, "relation": "parent-followup-source"},
        ],
        "workflow": {
            "currentPhase": suggested,
            "currentPhaseState": "not-started",
            "nextRecommendedPhase": next_phase.make(
                suggested, next_phase.STATUS_READY, "follow-up 으로 생성됨"
            ),
            "phaseStates": {},
            "awaitingApproval": False,
        },
    }
    _write_manifest(task_manifest_file(task_root), manifest_payload)

    brief_path = task_root / "instruction-set" / "task-brief.md"
    brief_path.parent.mkdir(parents=True, exist_ok=True)
    ticket_line = f"- Ticket ID: `{ticket_id}`\n" if ticket_id else ""
    brief_body = (
        f"# Follow-up Task Brief — {new_task_key}\n\n"
        f"- Spawned from: `{parent_task_key}`\n"
        f"{ticket_line}"
        f"- Origin: `{origin}`\n"
        f"- Source report row: `{row.get('id', '')}` in `{parent_report_relative}`\n"
        f"- Suggested task-type: `{suggested}`\n"
        f"- Priority: `{priority}`\n"
        f"- Spawned at: `{now}`\n\n"
        f"## Title\n\n{title}\n\n"
        f"## Scope (files / areas)\n\n{scope or '_(미지정)_'}\n\n"
        f"## Reason / Why deferred from parent run\n\n{reason}\n\n"
        f"## Next step\n\n"
        f"이 stub은 사용자가 정식 진입할 때 자동 갱신됩니다. 다음 명령 중 하나로 시작하세요:\n\n"
        f"- Claude Code 세션 안: `/okstra-run task-key={new_task_key} task-type={suggested}`\n"
        f"- 별도 터미널: `scripts/okstra.sh --task-key {new_task_key} --task-type {suggested}`\n"
    )
    brief_path.write_text(brief_body, encoding="utf-8")

    index_path = task_root / "task-index.md"
    index_body = (
        f"# {new_task_key} — Follow-up Task (todo)\n\n"
        f"- Parent: `{parent_task_key}`\n"
        f"{ticket_line}"
        f"- Suggested task-type: `{suggested}`\n"
        f"- Origin: `{origin}`\n"
        f"- Priority: `{priority}`\n"
        f"- Spawned from report: `{parent_report_relative}`\n"
        f"- Stub brief: `instruction-set/task-brief.md`\n"
        f"- Status: `todo`\n\n"
        f"이 task는 자동 생성된 follow-up stub입니다. 정식 진입 시 manifest가 재렌더링됩니다.\n"
    )
    index_path.write_text(index_body, encoding="utf-8")

    return ("created", rel)


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(
        description="Spawn follow-up task stubs from a final-report data.json.",
    )
    parser.add_argument(
        "data_file",
        type=Path,
        help="Path to the final-report data.json (the JSON SSOT).",
    )
    parser.add_argument("--project-root", type=Path, required=True)
    parser.add_argument(
        "--task-group",
        required=True,
        help="Task-group slug of the parent task.",
    )
    parser.add_argument("--parent-task-key", required=True)
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Parse and validate only; do not write files.",
    )
    args = parser.parse_args(argv)

    if not args.data_file.exists():
        print(f"data.json not found: {args.data_file}", file=sys.stderr)
        return 1

    project_id = _project_id_of(args.parent_task_key)
    if not project_id:
        print(
            f"--parent-task-key must be project-id:task-group:task-id, got "
            f"{args.parent_task_key!r} — a spawned task-key derived from it "
            "would break every catalog read in this project.",
            file=sys.stderr,
        )
        return 1

    try:
        data = json.loads(args.data_file.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        print(f"invalid JSON in {args.data_file}: {exc}", file=sys.stderr)
        return 1

    rows = data.get("followUpTasks") or []
    if not rows:
        print("followUpTasks is empty — nothing to do.")
        return 0

    # Manifests record the markdown sibling rather than data.json so the
    # user-facing report (and not the SSOT) is the cite-able artifact.
    parent_report = _data_to_report_path(args.data_file)
    try:
        parent_report_relative = (
            parent_report.resolve().relative_to(args.project_root.resolve()).as_posix()
        )
    except ValueError:
        parent_report_relative = str(parent_report)

    results = []
    for row in rows:
        origin = (row.get("origin") or "").strip().lower()
        if origin in NON_SPAWNING_ORIGINS:
            results.append((
                "skipped",
                row.get("newTaskId", ""),
                f"{origin} (advance via /okstra-run, no new task dir)",
            ))
            continue
        if (row.get("autoSpawn") or "").strip().lower() != "yes":
            results.append((
                "skipped",
                row.get("newTaskId", ""),
                "autoSpawn != yes",
            ))
            continue
        status, info = _spawn_one(
            project_root=args.project_root,
            project_id=project_id,
            task_group=args.task_group,
            parent_task_key=args.parent_task_key,
            parent_report_relative=parent_report_relative,
            row=row,
            dry_run=args.dry_run,
        )
        results.append((status, row.get("newTaskId", ""), info))

    print(f"Follow-up spawn summary ({'dry-run' if args.dry_run else 'live'}):")
    for status, task_id, info in results:
        print(f"  - [{status}] {task_id}: {info}")

    if any(status == "invalid" for status, *_ in results):
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
