#!/usr/bin/env python3
"""Manage categorized PRD Plugin reflection questions (REQ-090).

The canonical store is ``.prd_plugin/config.json#reflection``.  This module is
used directly as a CLI and defines the same validation and CRUD contract as the
MCP reflection tools.  All mutations share the MCP state lock so Python and
Node callers cannot allocate the same RFQ ID or overwrite each other's config.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import shutil
import sys
import tempfile
import time
import uuid
from contextlib import contextmanager
from pathlib import Path


QUESTION_ID_RE = re.compile(r"^RFQ-(\d+)$")
CATEGORY_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
OPT_TRUE = {"true", "1", "on", "yes", "enable", "enabled"}
OPT_FALSE = {"false", "0", "off", "no", "disable", "disabled"}
LOCK_STALE_SECONDS = 10
LOCK_WAIT_SECONDS = 12


def _style(raw):
    bom = raw.startswith("\ufeff")
    body = raw[1:] if bom else raw
    newline = "\r\n" if "\r\n" in body else "\n"
    match = re.search(r"[\r\n]([ \t]+)\S", body)
    if match:
        found = match.group(1)
        indent = "\t" if found.startswith("\t") else len(found)
    else:
        indent = 2
    return indent, newline, bom


def _read_json(path):
    try:
        raw = Path(path).read_bytes().decode("utf-8")
    except (OSError, UnicodeDecodeError) as exc:
        raise ValueError(f"cannot read {path}: {exc}") from exc
    try:
        data = json.loads(raw.lstrip("\ufeff"))
    except json.JSONDecodeError as exc:
        raise ValueError(f"{path} is not valid JSON: {exc}") from exc
    if not isinstance(data, dict):
        raise ValueError(f"{path} must contain a JSON object")
    return data, _style(raw)


def _atomic_write_json(path, data, style):
    path = Path(path)
    indent, newline, bom = style
    body = json.dumps(data, indent=indent, ensure_ascii=False) + "\n"
    if newline != "\n":
        body = body.replace("\n", newline)
    if bom:
        body = "\ufeff" + body
    path.parent.mkdir(parents=True, exist_ok=True)
    handle = None
    temp_path = None
    try:
        handle = tempfile.NamedTemporaryFile(
            mode="wb", prefix=path.name + ".", suffix=".tmp", dir=path.parent, delete=False
        )
        temp_path = Path(handle.name)
        handle.write(body.encode("utf-8"))
        handle.flush()
        os.fsync(handle.fileno())
        handle.close()
        handle = None
        os.replace(temp_path, path)
    finally:
        if handle is not None:
            handle.close()
        if temp_path is not None and temp_path.exists():
            try:
                temp_path.unlink()
            except OSError:
                pass


@contextmanager
def _state_lock(root):
    """Use the same directory lock as mcp/server.cjs."""
    root = Path(root)
    lock_dir = root / ".prd_plugin" / "local" / "mcp-state.lock"
    lock_dir.parent.mkdir(parents=True, exist_ok=True)
    token = f"{os.getpid()}-{uuid.uuid4().hex}"
    deadline = time.monotonic() + LOCK_WAIT_SECONDS
    acquired = False
    while not acquired:
        try:
            lock_dir.mkdir()
            (lock_dir / "owner").write_text(token, encoding="utf-8")
            acquired = True
        # PermissionError is the same condition on Windows: a directory another
        # process is deleting sits in a delete-pending state, and mkdir on it is
        # denied rather than reported as existing. FileNotFoundError means the
        # parent went with it. Treating any of these as fatal turns ordinary
        # contention into a crashed write.
        except (FileExistsError, PermissionError, FileNotFoundError):
            lock_dir.parent.mkdir(parents=True, exist_ok=True)
            try:
                age = time.time() - lock_dir.stat().st_mtime
                if age > LOCK_STALE_SECONDS:
                    grave = lock_dir.with_name(lock_dir.name + f".stale-{token}")
                    os.replace(lock_dir, grave)
                    shutil.rmtree(grave, ignore_errors=True)
                    continue
            except OSError:
                # Fall through to the deadline check rather than looping
                # straight back: a stat that keeps failing must not spin.
                pass
            if time.monotonic() >= deadline:
                raise ValueError("timed out waiting for the PRD Plugin state lock")
            time.sleep(0.02)
    try:
        yield
    finally:
        try:
            if (lock_dir / "owner").read_text(encoding="utf-8") == token:
                shutil.rmtree(lock_dir)
        except OSError:
            pass


def _normal_text(value):
    return " ".join(str(value).split()).lower()


def validate_reflection(reflection):
    if not isinstance(reflection, dict):
        raise ValueError("reflection must be an object")
    for key in ("enabled", "on_stop"):
        if not isinstance(reflection.get(key), bool):
            raise ValueError(f"reflection.{key} must be a boolean")
    cap = reflection.get("max_questions_per_stop")
    if isinstance(cap, bool) or not isinstance(cap, int) or not 1 <= cap <= 50:
        raise ValueError("reflection.max_questions_per_stop must be an integer from 1 to 50")
    categories = reflection.get("categories")
    if not isinstance(categories, list):
        raise ValueError("reflection.categories must be an array")
    if len(categories) > 100:
        raise ValueError("reflection.categories may contain at most 100 categories")

    category_ids = set()
    question_ids = set()
    question_texts = set()
    for category in categories:
        if not isinstance(category, dict):
            raise ValueError("each reflection category must be an object")
        category_id = category.get("id")
        if not isinstance(category_id, str) or not CATEGORY_ID_RE.fullmatch(category_id):
            raise ValueError(f"invalid reflection category id {category_id!r}")
        if category_id in category_ids:
            raise ValueError(f"duplicate reflection category id {category_id!r}")
        category_ids.add(category_id)
        name = category.get("name")
        if not isinstance(name, str) or not name.strip() or len(name.strip()) > 120:
            raise ValueError(f"category {category_id!r} name must be 1-120 characters")
        if not isinstance(category.get("enabled"), bool):
            raise ValueError(f"category {category_id!r} enabled must be a boolean")
        questions = category.get("questions")
        if not isinstance(questions, list):
            raise ValueError(f"category {category_id!r} questions must be an array")
        if len(questions) > 500:
            raise ValueError(f"category {category_id!r} may contain at most 500 questions")
        for question in questions:
            if not isinstance(question, dict):
                raise ValueError(f"category {category_id!r} contains a non-object question")
            question_id = question.get("id")
            if not isinstance(question_id, str) or not QUESTION_ID_RE.fullmatch(question_id):
                raise ValueError(f"question id {question_id!r} must match RFQ-###")
            if question_id in question_ids:
                raise ValueError(f"duplicate reflection question id {question_id}")
            question_ids.add(question_id)
            text = question.get("text")
            if not isinstance(text, str) or not text.strip() or len(text.strip()) > 1000:
                raise ValueError(f"question {question_id} text must be 1-1000 characters")
            normalized = _normal_text(text)
            if normalized in question_texts:
                raise ValueError(f"duplicate reflection question text for {question_id}")
            question_texts.add(normalized)
            if not isinstance(question.get("enabled"), bool):
                raise ValueError(f"question {question_id} enabled must be a boolean")
    return reflection


def _config_path(root):
    return Path(root) / ".prd_plugin" / "config.json"


def _registry_path(root):
    return Path(root) / ".prd_plugin" / "ids" / "registry.json"


def _load_config(root):
    data, style = _read_json(_config_path(root))
    reflection = data.get("reflection")
    if reflection is None:
        reflection = {
            "enabled": False,
            "on_stop": True,
            "max_questions_per_stop": 5,
            "categories": [],
        }
        data["reflection"] = reflection
    validate_reflection(reflection)
    return data, reflection, style


def _category(reflection, category_id):
    for category in reflection["categories"]:
        if category["id"] == category_id:
            return category
    raise ValueError(f"reflection category {category_id!r} not found")


def _question(reflection, question_id):
    for category in reflection["categories"]:
        for question in category["questions"]:
            if question["id"] == question_id:
                return category, question
    raise ValueError(f"reflection question {question_id!r} not found")


def _save_config(root, data, reflection, style):
    validate_reflection(reflection)
    _atomic_write_json(_config_path(root), data, style)


def _allocate_question_id_locked(root, reflection):
    registry, style = _read_json(_registry_path(root))
    next_map = registry.get("next")
    if not isinstance(next_map, dict):
        raise ValueError("registry.json next must be an object")
    used = set()
    highest = 0
    for category in reflection["categories"]:
        for question in category["questions"]:
            match = QUESTION_ID_RE.fullmatch(question["id"])
            if match:
                number = int(match.group(1))
                used.add(number)
                highest = max(highest, number)
    raw_next = next_map.get("RFQ", 1)
    if isinstance(raw_next, bool) or not isinstance(raw_next, int) or raw_next < 1:
        raise ValueError("registry next.RFQ must be a positive integer")
    number = max(raw_next, highest + 1)
    while number in used:
        number += 1
    next_map["RFQ"] = number + 1
    _atomic_write_json(_registry_path(root), registry, style)
    width = max(3, len(str(number)))
    return f"RFQ-{number:0{width}d}"


def list_reflections(root, *, entity="all", category_id=None, enabled=None):
    _, reflection, _ = _load_config(root)
    categories = []
    questions = []
    for category in reflection["categories"]:
        if category_id and category["id"] != category_id:
            continue
        if entity in ("all", "category") and (enabled is None or category["enabled"] is enabled):
            categories.append({
                "id": category["id"], "name": category["name"], "enabled": category["enabled"],
                "question_count": len(category["questions"]),
            })
        if entity in ("all", "question"):
            for question in category["questions"]:
                if enabled is not None and question["enabled"] is not enabled:
                    continue
                questions.append({
                    **question,
                    "category_id": category["id"],
                    "category_name": category["name"],
                    "effective_enabled": bool(reflection["enabled"] and reflection["on_stop"]
                                              and category["enabled"] and question["enabled"]),
                })
    return {
        "enabled": reflection["enabled"],
        "on_stop": reflection["on_stop"],
        "max_questions_per_stop": reflection["max_questions_per_stop"],
        "categories": categories,
        "questions": questions,
    }


def create_category(root, category_id, name, *, enabled=True):
    if not isinstance(category_id, str) or not CATEGORY_ID_RE.fullmatch(category_id):
        raise ValueError(f"invalid reflection category id {category_id!r}")
    if not isinstance(name, str) or not name.strip() or len(name.strip()) > 120:
        raise ValueError("category name must be 1-120 characters")
    if not isinstance(enabled, bool):
        raise ValueError("category enabled must be a boolean")
    with _state_lock(root):
        data, reflection, style = _load_config(root)
        if any(c["id"] == category_id for c in reflection["categories"]):
            raise ValueError(f"reflection category {category_id!r} already exists")
        record = {"id": category_id, "name": str(name).strip(), "enabled": enabled, "questions": []}
        reflection["categories"].append(record)
        _save_config(root, data, reflection, style)
        return dict(record)


def update_category(root, category_id, *, name=None, enabled=None):
    if name is None and enabled is None:
        raise ValueError("category update requires name or enabled")
    with _state_lock(root):
        data, reflection, style = _load_config(root)
        record = _category(reflection, category_id)
        if name is not None:
            record["name"] = str(name).strip()
        if enabled is not None:
            record["enabled"] = enabled
        _save_config(root, data, reflection, style)
        return dict(record)


def delete_category(root, category_id, *, cascade=False):
    with _state_lock(root):
        data, reflection, style = _load_config(root)
        record = _category(reflection, category_id)
        if record["questions"] and not cascade:
            raise ValueError(f"reflection category {category_id!r} is not empty; use cascade")
        reflection["categories"].remove(record)
        _save_config(root, data, reflection, style)
        return {"id": category_id, "deleted": True, "deleted_questions": len(record["questions"])}


def create_question(root, category_id, text, *, enabled=True):
    if not isinstance(text, str) or not text.strip() or len(text.strip()) > 1000:
        raise ValueError("question text must be 1-1000 characters")
    if not isinstance(enabled, bool):
        raise ValueError("question enabled must be a boolean")
    with _state_lock(root):
        data, reflection, style = _load_config(root)
        category = _category(reflection, category_id)
        normalized = _normal_text(text)
        if any(_normal_text(q["text"]) == normalized
               for c in reflection["categories"] for q in c["questions"]):
            raise ValueError("duplicate reflection question text")
        question_id = _allocate_question_id_locked(root, reflection)
        record = {"id": question_id, "text": str(text).strip(), "enabled": enabled}
        category["questions"].append(record)
        _save_config(root, data, reflection, style)
        return {**record, "category_id": category_id}


def update_question(root, question_id, *, text=None, category_id=None, enabled=None):
    if text is None and category_id is None and enabled is None:
        raise ValueError("question update requires text, category_id, or enabled")
    with _state_lock(root):
        data, reflection, style = _load_config(root)
        category, record = _question(reflection, question_id)
        if text is not None:
            normalized = _normal_text(text)
            if any(q is not record and _normal_text(q["text"]) == normalized
                   for c in reflection["categories"] for q in c["questions"]):
                raise ValueError("duplicate reflection question text")
            record["text"] = str(text).strip()
        if enabled is not None:
            record["enabled"] = enabled
        if category_id is not None and category_id != category["id"]:
            destination = _category(reflection, category_id)
            category["questions"].remove(record)
            destination["questions"].append(record)
            category = destination
        _save_config(root, data, reflection, style)
        return {**record, "category_id": category["id"]}


def delete_question(root, question_id):
    with _state_lock(root):
        data, reflection, style = _load_config(root)
        category, record = _question(reflection, question_id)
        category["questions"].remove(record)
        _save_config(root, data, reflection, style)
        return {"id": question_id, "deleted": True, "category_id": category["id"]}


def _bool_arg(value):
    if isinstance(value, bool):
        return value
    lowered = str(value).strip().lower()
    if lowered in OPT_TRUE:
        return True
    if lowered in OPT_FALSE:
        return False
    raise argparse.ArgumentTypeError("expected true/false, on/off, or enabled/disabled")


def _print_result(result, as_json):
    if as_json:
        print(json.dumps(result, indent=2, ensure_ascii=False))
    else:
        print(json.dumps(result, ensure_ascii=False))


def _parser():
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--repo-root", default=".")
    sub = parser.add_subparsers(dest="action", required=True)

    listing = sub.add_parser("list")
    listing.add_argument("--entity", choices=("all", "category", "question"), default="all")
    listing.add_argument("--category")
    listing.add_argument("--enabled", type=_bool_arg)
    listing.add_argument("--json", action="store_true")

    create = sub.add_parser("create")
    create.add_argument("entity", choices=("category", "question"))
    create.add_argument("id", nargs="?")
    create.add_argument("--name")
    create.add_argument("--category")
    create.add_argument("--text")
    create.add_argument("--enabled", type=_bool_arg)
    create.add_argument("--disabled", action="store_true")
    create.add_argument("--json", action="store_true")

    update = sub.add_parser("update")
    update.add_argument("entity", choices=("category", "question"))
    update.add_argument("id")
    update.add_argument("--name")
    update.add_argument("--category")
    update.add_argument("--text")
    update.add_argument("--enabled", type=_bool_arg)
    update.add_argument("--json", action="store_true")

    delete = sub.add_parser("delete")
    delete.add_argument("entity", choices=("category", "question"))
    delete.add_argument("id")
    delete.add_argument("--cascade", action="store_true")
    delete.add_argument("--json", action="store_true")
    return parser


def main(argv=None):
    args = _parser().parse_args(argv)
    root = Path(args.repo_root)
    try:
        if args.action == "list":
            result = list_reflections(root, entity=args.entity, category_id=args.category, enabled=args.enabled)
        elif args.action == "create" and args.entity == "category":
            if not args.id or args.name is None:
                raise ValueError("creating a category requires id and --name")
            if args.category is not None or args.text is not None:
                raise ValueError("category creation does not accept --category or --text")
            if args.disabled and args.enabled is not None:
                raise ValueError("use either --disabled or --enabled, not both")
            enabled = False if args.disabled else (True if args.enabled is None else args.enabled)
            result = create_category(root, args.id, args.name, enabled=enabled)
        elif args.action == "create":
            if args.category is None or args.text is None:
                raise ValueError("creating a question requires --category and --text")
            if args.id is not None or args.name is not None:
                raise ValueError("question creation allocates its ID and does not accept a positional id or --name")
            if args.disabled and args.enabled is not None:
                raise ValueError("use either --disabled or --enabled, not both")
            enabled = False if args.disabled else (True if args.enabled is None else args.enabled)
            result = create_question(root, args.category, args.text, enabled=enabled)
        elif args.action == "update" and args.entity == "category":
            if args.category is not None or args.text is not None:
                raise ValueError("category updates accept only --name and --enabled")
            result = update_category(root, args.id, name=args.name, enabled=args.enabled)
        elif args.action == "update":
            if args.name is not None:
                raise ValueError("question updates do not accept --name")
            result = update_question(root, args.id, text=args.text, category_id=args.category, enabled=args.enabled)
        elif args.entity == "category":
            result = delete_category(root, args.id, cascade=args.cascade)
        else:
            if args.cascade:
                raise ValueError("question deletion does not accept --cascade")
            result = delete_question(root, args.id)
        _print_result(result, getattr(args, "json", False))
        return 0
    except ValueError as exc:
        print(f"[PRD Plugin] reflection error: {exc}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    sys.exit(main())
