"""task-manifest.json 의 workStatus 를 갱신하는 단일 CLI 진입점.

okstra-inspect status.4 가 Edit 도구로 하던 수동 JSON 편집(키 순서·개행 보존
규칙을 프롬프트로 강제)을 CLI 로 수렴시킨다. 직렬화는 render._write_json 과
동일한 json.dumps(indent=2, ensure_ascii=False) + "\n" 이므로 재렌더와 byte
규칙이 일치한다. 직접 수행 결과는 작업 정본을 저장한 뒤 그룹 기억에 공유한다.
"""
from __future__ import annotations

import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path

from okstra_ctl import group_context
from okstra_ctl.direct_work import find_brief_tasks, publish_direct_work, record_direct_work
from okstra_ctl.run_context import task_mutex
from okstra_ctl.ids import slugify_task_segment
from okstra_ctl.fixed_text import line, scalar
from okstra_ctl.paths import task_dir, task_manifest_file
from okstra_ctl.json_boundary import load_owned_object, write_owned_object_atomic
from okstra_project import (
    ResolverError,
    StateError,
    resolve_project_root,
    resolve_task_reference,
    project_json_path,
)

ALLOWED_WORK_STATUSES = ("todo", "in-progress", "blocked", "done")


def _emit(payload: dict, *, text: bool = False) -> None:
    if not text:
        print(json.dumps(payload, ensure_ascii=False, indent=2))
        return
    lines = ["# Okstra Work Status Result", ""]
    for key, label in (
        ("ok", "OK"), ("stage", "Stage"), ("reason", "Reason"),
        ("taskKey", "Task key"), ("previousWorkStatus", "Previous work status"),
        ("workStatus", "Work status"), ("workStatusUpdatedAt", "Updated at"),
        ("workStatusNote", "Note"), ("taskManifestPath", "Task manifest"),
        ("groupContextPath", "Group context"),
        ("latestWorkRecordPath", "Direct work record"), ("statusRecorded", "Status recorded"),
    ):
        if key in payload:
            lines.append(line(label, payload.get(key)).rstrip("\n"))
    for match in payload.get("matches", []) if isinstance(payload.get("matches"), list) else []:
        if isinstance(match, dict):
            lines.append(f"- Match: `{scalar(match.get('taskKey'))}`")
    print("\n".join(lines))


def _manifest_path(project_root: Path, entry: dict) -> Path:
    rel = entry.get("taskManifestPath") or ""
    if rel:
        candidate = Path(rel)
        return candidate if candidate.is_absolute() else project_root / candidate
    group_seg = entry.get("taskGroupPathSegment") or slugify_task_segment(
        entry.get("taskGroup", "")
    )
    id_seg = entry.get("taskIdPathSegment") or slugify_task_segment(
        entry.get("taskId", "")
    )
    return task_manifest_file(task_dir(project_root, group_seg, id_seg))


_CLI_EPILOG = r"""Usage:
  okstra set-work-status <token> <status> [--note <text>] [--task-group <g>]
                         [--note-file <path>]
                         [--project-root <dir>] [--cwd <dir>] [--json]

<token> is a full task-key (<project-id>:<task-group>:<task-id>) or a bare
task-id. <status> is one of: todo | in-progress | blocked | done.

Updates workStatus + workStatusUpdatedAt in task-manifest.json (workStatusNote
only when --note is passed) using the same JSON serialization as the manifest
renderer. Output: JSON { ok, taskKey, previousWorkStatus, workStatus, ... };
ok:false stages: resolve | catalog | not-found | ambiguous (pick from
matches[]) | manifest-missing | manifest-invalid.

An existing brief can be registered without starting a run. For direct completion,
provide --note or --note-file describing the work and verification (or why no
checks were run). Direct records are shared through group-context and inspect.
No run, phase completion, or cross-verification success is synthesized.
stage:share with statusRecorded:true means the status was saved but sharing
failed; repeat the same command to retry without duplicating the result.
"""


def _parse_args(argv: list[str] | None):
    parser = argparse.ArgumentParser(
        prog="okstra set-work-status",
        epilog=_CLI_EPILOG,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        description="Set a task's user-managed workStatus in task-manifest.json."
    )
    parser.add_argument(
        "token",
        help="full task-key (<project-id>:<task-group>:<task-id>) or bare task-id",
    )
    parser.add_argument("status", choices=ALLOWED_WORK_STATUSES)
    parser.add_argument(
        "--note",
        default=None,
        help="set workStatusNote; omit to leave any existing note untouched",
    )
    parser.add_argument(
        "--task-group", default="", help="scope a bare task-id match to this task-group"
    )
    parser.add_argument("--note-file", type=Path, help="read the work and verification summary from a UTF-8 file")
    parser.add_argument("--project-root", default="", help="project root for catalog lookup")
    parser.add_argument("--cwd", default=".", help="cwd for project root resolution")
    parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
    parser.add_argument("--text", action="store_true", help="emit fixed text fields")
    return parser.parse_args(argv)


def _resolve_entry(args):
    try:
        project_root = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
    except ResolverError as exc:
        _emit({"ok": False, "stage": "resolve", "reason": str(exc)}, text=args.text)
        return None, None, 2

    token = args.token
    task_group = args.task_group
    if token.count(":") == 2:
        project_id, key_group, key_id = token.split(":")
        project = load_owned_object(project_json_path(project_root), artifact="project config")
        if project_id != project.get("projectId"):
            _emit({"ok": False, "stage": "resolve", "reason": "Task key belongs to another project"}, text=args.text)
            return None, None, 2
        token = key_id
        task_group = key_group or task_group

    try:
        matches = resolve_task_reference(
            project_root, token, task_group=task_group or None
        )
        briefs = find_brief_tasks(project_root, token, task_group)
        by_key = {item["taskKey"].casefold(): item for item in matches}
        for brief in briefs:
            key = brief["taskKey"].casefold()
            if key in by_key:
                for field, value in brief.items():
                    by_key[key].setdefault(field, value)
            else:
                matches.append(brief)
    except (OSError, ValueError) as exc:
        _emit({"ok": False, "stage": "resolve", "reason": str(exc)}, text=args.text)
        return None, None, 2
    except StateError as exc:
        _emit({"ok": False, "stage": "catalog", "reason": str(exc)}, text=args.text)
        return None, None, 2

    if not matches:
        _emit({"ok": False, "stage": "not-found", "token": args.token}, text=args.text)
        return None, None, 1
    if len(matches) > 1:
        _emit({"ok": False, "stage": "ambiguous", "matches": matches}, text=args.text)
        return None, None, 2
    return project_root, matches[0], 0


def _updated_payload(project_root: Path, manifest_path: Path, manifest: dict, entry: dict, args) -> dict:
    previous = manifest.get("workStatus", "")
    previous_note = manifest.get("workStatusNote", "")
    manifest["workStatus"] = args.status
    if previous != args.status or (args.note is not None and args.note != previous_note):
        manifest["workStatusUpdatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
    manifest.setdefault("workStatusUpdatedAt", datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"))
    manifest["updatedAt"] = manifest["workStatusUpdatedAt"]
    if args.note is not None:
        manifest["workStatusNote"] = args.note
    if args.status == "done" and (manifest.get("registrationSource") == "direct" or args.note is not None):
        record_direct_work(project_root, manifest_path, manifest)
    write_owned_object_atomic(manifest_path, manifest, artifact="task manifest")
    return {
        "ok": True, "taskKey": entry.get("taskKey", ""),
        "previousWorkStatus": previous, "workStatus": args.status,
        "workStatusUpdatedAt": manifest["workStatusUpdatedAt"],
        "workStatusNote": manifest.get("workStatusNote", ""),
        "taskManifestPath": str(manifest_path),
        "latestWorkRecordPath": manifest.get("latestWorkRecordPath", ""),
    }


def _refresh_group_queue(project_root: Path, entry: dict) -> str:
    """상태를 바꾼 task 가 속한 그룹 문서의 시작 순서를 다시 그린다.

    그 투영을 쓰는 곳이 report-finalize 하나뿐이라, 여기서 갱신하지 않으면
    사람이 적은 상태가 다음 finalize 까지 그룹 문서에 닿지 않는다. 그룹 문서가
    없거나 갱신에 실패해도 상태 기록 자체는 성공이다 — 매니페스트는 이미
    쓰였고, 이것은 파생 표면이다. 실패는 삼키지 않고 stderr 로 알린다.
    """
    task_group = str(entry.get("taskGroup") or "")
    if not task_group:
        return ""
    try:
        target = group_context.refresh_group_queue(project_root, task_group)
    except (OSError, ValueError) as exc:
        print(
            f"set-work-status: work status recorded, but the group-context start "
            f"order for {task_group!r} could not be redrawn: {exc}",
            file=sys.stderr,
        )
        return ""
    return str(target) if target is not None else ""


def _write_status(project_root: Path, entry: dict, args) -> dict:
    manifest_path = _manifest_path(project_root, entry)
    manifest_path.resolve().relative_to((project_root / ".okstra" / "tasks").resolve())
    if manifest_path.exists():
        manifest = load_owned_object(manifest_path, artifact="task manifest")
    elif entry.get("_briefVerified"):
        if (manifest_path.parent / "runs").exists() or entry.get("latestRunPath"):
            raise ValueError("Task manifest is missing for a task with run history")
        identity_fields = (
            "schemaVersion", "projectId", "projectRoot", "taskGroup", "taskId", "taskKey",
            "taskGroupPathSegment", "taskIdPathSegment", "taskBriefPath", "taskRootPath", "taskManifestPath",
        )
        manifest = {key: entry[key] for key in identity_fields if key in entry}
        manifest["registrationSource"] = "direct"
    else:
        return {"ok": False, "stage": "manifest-missing", "taskKey": entry["taskKey"]}
    if str(manifest.get("taskKey", "")).casefold() != entry["taskKey"].casefold():
        raise ValueError("Task manifest identity does not match the requested task")
    if manifest.get("registrationSource") == "direct" or args.note is not None:
        project_id, group, task_id = entry["taskKey"].split(":")
        for field, value in (("projectId", project_id), ("taskGroup", group), ("taskId", task_id)):
            manifest.setdefault(field, value)
    payload = _updated_payload(project_root, manifest_path, manifest, entry, args)
    if manifest.get("registrationSource") == "direct" or manifest.get("latestWorkRecordPath"):
        project_id, group, task_id = entry["taskKey"].split(":")
        try:
            refreshed = publish_direct_work(project_root, {
                **manifest, "projectId": project_id, "taskGroup": group, "taskId": task_id,
            })
        except (OSError, ValueError) as exc:
            return {**payload, "ok": False, "statusRecorded": True, "stage": "share", "reason": str(exc)}
    else:
        refreshed = _refresh_group_queue(project_root, entry)
    if refreshed:
        payload["groupContextPath"] = str(refreshed)
    return payload


def main(argv: list[str] | None = None) -> int:
    args = _parse_args(argv)
    try:
        if args.note_file:
            if args.note is not None:
                raise ValueError("Use either --note or --note-file")
            args.note = args.note_file.read_text(encoding="utf-8")
        project_root, entry, resolution_exit = _resolve_entry(args)
        if resolution_exit:
            return resolution_exit
        assert project_root is not None and entry is not None
        with task_mutex(entry["taskKey"]):
            payload = _write_status(project_root, entry, args)
    except (OSError, ValueError) as exc:
        payload = {"ok": False, "stage": "manifest-invalid", "reason": str(exc)}
    _emit(payload, text=args.text)
    return 0 if payload["ok"] else 1


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