#!/usr/bin/env python3
"""Create, manage, import, and export project-local CARBON AI-intent tests."""

from __future__ import annotations

import argparse
import csv
import datetime as dt
import hashlib
import json
import os
import re
import sys
import tempfile
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any

from carbon_schemas import INTENT_LIBRARY_SCHEMA, INTENT_TEST_SCHEMA, ensure_intent_library, schema_origin


SCHEMA = INTENT_LIBRARY_SCHEMA
TEST_SCHEMA = INTENT_TEST_SCHEMA
STATUSES = {"draft", "active", "archived"}
PRIORITIES = {"P0", "P1", "P2", "P3"}
SUPPORTED = {
    ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".py", ".java",
    ".kt", ".rb", ".cs", ".feature", ".robot", ".md", ".txt",
    ".csv", ".tsv", ".json", ".jsonl", ".xml", ".xlsx", ".docx",
}
IGNORED_DIRS = {".git", ".carbon", "node_modules", "vendor", "dist", "build", "coverage", ".venv", "venv", "__pycache__"}
MAX_SOURCE_BYTES = 10_000_000
SECRET_PATTERNS = [
    re.compile(r"\bsk-[A-Za-z0-9_-]{16,}\b"),
    re.compile(r"\bghp_[A-Za-z0-9]{20,}\b"),
    re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
    re.compile(r"\bBearer\s+[A-Za-z0-9._~+/-]{16,}\b", re.I),
    re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
]
TEST_PATTERNS = [
    re.compile(r"(?m)^\s*(?:test|it)(?:\.[A-Za-z]+)?\s*\(\s*['\"](?P<title>[^'\"]+)", re.I),
    re.compile(r"(?m)^\s*def\s+(?P<title>test_[A-Za-z0-9_]+)\s*\("),
    re.compile(r"(?m)^\s*(?:public\s+)?(?:async\s+)?(?:void|Task|fun)\s+(?P<title>test[A-Za-z0-9_]+)\s*\("),
    re.compile(r"(?mi)^\s*(?:Scenario(?: Outline)?|Test Case):\s*(?P<title>.+?)\s*$"),
    re.compile(r"(?m)^\s*#{1,4}\s+(?P<title>(?:TC[-_ ]?\d+|Test(?: Case)?)[^\n]*)$", re.I),
]
ASSERTION_PATTERN = re.compile(r"(?i)\b(expect|assert|should|verify|then\b|to_be_|to_equal|toHave|contains?\(|status_code)\b")
ACTION_PATTERN = re.compile(r"(?i)\b(goto|visit|navigate|click|fill|type|select|check|submit|press|send|request|get\(|post\(|put\(|patch\(|delete\()\b")


def now() -> str:
    return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def redact(value: str) -> str:
    result = value
    for pattern in SECRET_PATTERNS:
        result = pattern.sub("[REDACTED_SECRET]", result)
    return result


def reject_secrets(value: str, field: str) -> None:
    if any(pattern.search(value) for pattern in SECRET_PATTERNS):
        raise ValueError(f"{field} appears to contain a secret. Reference an environment variable or fixture name instead.")


def stable_id(*parts: str) -> str:
    digest = hashlib.sha256("\0".join(parts).encode()).hexdigest()[:12].upper()
    return f"INTENT-{digest}"


def store_path(root: Path) -> Path:
    return root / ".carbon" / "tests" / "intent-tests.json"


def atomic_json(path: Path, value: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
        json.dump(value, handle, indent=2, ensure_ascii=False)
        handle.write("\n")
        temporary = Path(handle.name)
    os.replace(temporary, path)


def empty_library(root: Path) -> dict[str, Any]:
    return {
        "schema": SCHEMA,
        "schemaOrigin": schema_origin("testcase", relationship="container of CARBON testcase extensions"),
        "projectRoot": str(root),
        "tests": [],
        "updatedAt": now(),
    }


def load_library(root: Path) -> dict[str, Any]:
    path = store_path(root)
    value = ensure_intent_library(path, root)
    if not isinstance(value, dict) or value.get("schema") != SCHEMA or not isinstance(value.get("tests"), list):
        raise ValueError(f"Unsupported intent-test library schema in {path}")
    return value


def save_library(root: Path, library: dict[str, Any]) -> Path:
    library["schema"] = SCHEMA
    library["schemaOrigin"] = schema_origin("testcase", relationship="container of CARBON testcase extensions")
    library["projectRoot"] = str(root)
    library["updatedAt"] = now()
    atomic_json(store_path(root), library)
    return store_path(root)


def string_list(value: Any) -> list[str]:
    if value is None:
        return []
    if isinstance(value, str):
        rows = re.split(r"\r?\n|\s*,\s*", value)
    elif isinstance(value, list):
        rows = [str(row) for row in value]
    else:
        rows = [str(value)]
    return [redact(row.strip())[:2000] for row in rows if row.strip()]


def title_from_prompt(prompt: str) -> str:
    text = re.sub(r"\s+", " ", prompt).strip()
    first = re.split(r"(?<=[.!?])\s+", text, maxsplit=1)[0]
    return (first[:117] + "…") if len(first) > 120 else first


def readiness(test: dict[str, Any]) -> dict[str, Any]:
    missing = []
    if not test.get("scope"): missing.append("target scope or start URL")
    if not test.get("setup"): missing.append("starting state, role, or fixture")
    if not test.get("oracles"): missing.append("objective pass/fail oracle")
    if not test.get("evidenceRequirements"): missing.append("evidence requirement")
    score = max(0, 100 - 18 * len(missing))
    band = "high" if score >= 82 else ("medium" if score >= 55 else "low")
    return {"score": score, "band": band, "missing": missing}


def normalize_test(raw: dict[str, Any], *, source: dict[str, Any] | None = None, identifier: str | None = None) -> dict[str, Any]:
    prompt = str(raw.get("intentPrompt") or raw.get("prompt") or raw.get("description") or "").strip()
    if not prompt:
        raise ValueError("An intent prompt is required.")
    reject_secrets(prompt, "intent prompt")
    title = str(raw.get("title") or title_from_prompt(prompt)).strip()[:240]
    if not title:
        raise ValueError("A title is required.")
    status = str(raw.get("status") or "draft").lower()
    if status not in STATUSES:
        raise ValueError(f"status must be one of {sorted(STATUSES)}")
    priority = str(raw.get("priority") or "P2").upper()
    if priority not in PRIORITIES:
        raise ValueError(f"priority must be one of {sorted(PRIORITIES)}")
    created = str(raw.get("createdAt") or now())
    test = {
        "schema": TEST_SCHEMA,
        "schemaOrigin": schema_origin("testcase"),
        "id": identifier or str(raw.get("id") or stable_id("prompt", title, prompt)),
        "title": title,
        "goal": str(raw.get("goal") or prompt).strip()[:12000],
        "intentPrompt": prompt[:12000],
        "scope": str(raw.get("scope") or "").strip()[:2000],
        "setup": string_list(raw.get("setup")),
        "inputs": string_list(raw.get("inputs")),
        "oracles": string_list(raw.get("oracles") or raw.get("expected")),
        "evidenceRequirements": string_list(raw.get("evidenceRequirements") or ["actual result", "environment/build", "reproducible evidence handle"]),
        "adapter": str(raw.get("adapter") or "auto").strip()[:80],
        "priority": priority,
        "tags": sorted(set(string_list(raw.get("tags")))),
        "safety": {
            "destructive": bool((raw.get("safety") or {}).get("destructive", False)),
            "paid": bool((raw.get("safety") or {}).get("paid", False)),
            "public": bool((raw.get("safety") or {}).get("public", False)),
            "highVolume": bool((raw.get("safety") or {}).get("highVolume", False)),
        },
        "status": status,
        "lifecycle": str(raw.get("lifecycle") or "candidate"),
        "source": source or raw.get("source") or {"kind": "prompt", "label": "user-authored prompt"},
        "readiness": {},
        "revision": max(1, int(raw.get("revision") or 1)),
        "createdAt": created,
        "updatedAt": now(),
    }
    test["readiness"] = readiness(test)
    return test


def find_test(library: dict[str, Any], identifier: str) -> tuple[int, dict[str, Any]]:
    for index, test in enumerate(library["tests"]):
        if test.get("id") == identifier:
            return index, test
    raise ValueError(f"Unknown intent test: {identifier}")


def resolve_sources(inputs: list[str]) -> list[Path]:
    files: list[Path] = []
    for raw in inputs:
        unresolved = Path(raw).expanduser()
        if unresolved.is_symlink(): raise ValueError(f"Symbolic-link import sources are not followed: {unresolved}")
        path = unresolved.resolve()
        if path.is_file() and path.suffix.lower() in SUPPORTED and path.stat().st_size <= MAX_SOURCE_BYTES:
            files.append(path)
        elif path.is_dir():
            for current, dirs, names in os.walk(path):
                dirs[:] = sorted(name for name in dirs if name not in IGNORED_DIRS)
                for name in sorted(names):
                    candidate = Path(current) / name
                    if not candidate.is_symlink() and candidate.suffix.lower() in SUPPORTED and candidate.stat().st_size <= MAX_SOURCE_BYTES:
                        files.append(candidate)
        else:
            raise ValueError(f"Unsupported or missing import source: {path}")
    return sorted(set(files))


def framework_name(path: Path, text: str) -> str:
    lower = text.lower()
    name = path.name.lower()
    if "testrail" in name or "testrail" in lower or "custom_steps_separated" in lower: return "testrail"
    if "xray" in name or '"testkey"' in lower or '"testinfo"' in lower: return "xray"
    if "@playwright/test" in lower or "page.goto" in lower: return "playwright"
    if "cy." in text or "cypress" in lower: return "cypress"
    if "selenium" in lower or "webdriver" in lower: return "selenium"
    if path.suffix == ".py" and ("pytest" in lower or "def test_" in lower): return "pytest"
    if "@test" in lower and path.suffix in {".java", ".kt"}: return "junit"
    if path.suffix == ".feature": return "gherkin"
    if path.suffix == ".xlsx": return "spreadsheet-manual"
    if path.suffix == ".docx": return "document-manual"
    if path.suffix == ".xml": return "xml-manual"
    if path.suffix in {".csv", ".tsv"}: return "tabular-manual"
    if path.suffix in {".md", ".txt"}: return "manual"
    return "code"


def row_value(row: dict[str, Any], *names: str) -> Any:
    lowered = {str(key).strip().lower().replace(" ", "_"): value for key, value in row.items()}
    return next((lowered[name] for name in names if name in lowered and lowered[name] not in (None, "")), "")


def imported_priority(value: Any) -> str:
    text = str(value or "").strip().lower()
    if text.upper() in PRIORITIES: return text.upper()
    if any(token in text for token in ("critical", "blocker", "highest", "urgent")): return "P0"
    if any(token in text for token in ("high", "major")): return "P1"
    if any(token in text for token in ("low", "minor", "trivial", "lowest")): return "P3"
    return "P2"


def local_name(tag: str) -> str:
    return tag.rsplit("}", 1)[-1].lower()


def cell_column(reference: str) -> int:
    letters = re.match(r"[A-Z]+", reference.upper())
    value = 0
    for letter in (letters.group(0) if letters else "A"):
        value = value * 26 + ord(letter) - 64
    return value - 1


def safe_zip_read(archive: zipfile.ZipFile, name: str, limit: int = 20_000_000) -> bytes:
    info = archive.getinfo(name)
    if info.file_size > limit:
        raise ValueError(f"Compressed document member exceeds the {limit // 1_000_000} MB expansion limit: {name}")
    if info.compress_size and info.file_size / info.compress_size > 250:
        raise ValueError(f"Compressed document member has an unsafe expansion ratio: {name}")
    return archive.read(info)


def xlsx_rows(path: Path) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    with zipfile.ZipFile(path) as archive:
        names = set(archive.namelist())
        shared: list[str] = []
        if "xl/sharedStrings.xml" in names:
            shared_root = ET.fromstring(safe_zip_read(archive, "xl/sharedStrings.xml"))
            for item in shared_root.iter():
                if local_name(item.tag) == "si":
                    shared.append("".join(node.text or "" for node in item.iter() if local_name(node.tag) == "t"))
        sheet_names = sorted(name for name in names if re.fullmatch(r"xl/worksheets/sheet\d+\.xml", name))
        if sum(archive.getinfo(name).file_size for name in sheet_names[:100]) > 50_000_000:
            raise ValueError("XLSX worksheets exceed the 50 MB local expansion limit.")
        for sheet_name in sheet_names[:100]:
            root = ET.fromstring(safe_zip_read(archive, sheet_name))
            matrix: list[list[str]] = []
            for row in (node for node in root.iter() if local_name(node.tag) == "row"):
                values: list[str] = []
                for cell in (node for node in row if local_name(node.tag) == "c"):
                    index = cell_column(cell.attrib.get("r", "A1"))
                    while len(values) <= index: values.append("")
                    kind = cell.attrib.get("t", "")
                    value_node = next((node for node in cell.iter() if local_name(node.tag) == "v"), None)
                    inline = "".join(node.text or "" for node in cell.iter() if local_name(node.tag) == "t")
                    raw = inline if kind == "inlineStr" else (value_node.text if value_node is not None and value_node.text else "")
                    if kind == "s" and raw.isdigit() and int(raw) < len(shared): raw = shared[int(raw)]
                    values[index] = raw
                if any(value.strip() for value in values): matrix.append(values)
                if len(matrix) >= 20_001: break
            if not matrix: continue
            headers = [value.strip().lower().replace(" ", "_") or f"column_{index + 1}" for index, value in enumerate(matrix[0])]
            for values in matrix[1:]:
                record = {header: values[index] if index < len(values) else "" for index, header in enumerate(headers)}
                record["_sheet"] = Path(sheet_name).stem
                if any(str(value).strip() for key, value in record.items() if key != "_sheet"): rows.append(record)
    return rows


def docx_text(path: Path) -> str:
    with zipfile.ZipFile(path) as archive:
        if "word/document.xml" not in archive.namelist(): raise ValueError(f"Invalid DOCX file: {path}")
        root = ET.fromstring(safe_zip_read(archive, "word/document.xml"))
    lines = []
    for paragraph in (node for node in root.iter() if local_name(node.tag) in {"p", "tr"}):
        value = " ".join("".join(child.text or "" for child in paragraph.iter() if local_name(child.tag) == "t").split())
        if value and (not lines or lines[-1] != value): lines.append(value)
    return "\n".join(lines)


def xml_rows(path: Path) -> list[dict[str, Any]]:
    root = ET.fromstring(path.read_bytes())
    case_tags = {"case", "testcase", "test", "issue", "item"}
    raw_candidates = [node for node in root.iter() if local_name(node.tag) in case_tags]
    # Some exports wrap a real testcase in a generic test/item container. Keep the
    # innermost case so one external test cannot silently become duplicate intents.
    candidates = [
        node for node in raw_candidates
        if not any(descendant is not node and local_name(descendant.tag) in case_tags for descendant in node.iter())
    ]
    if not candidates: candidates = [root]
    rows = []
    for candidate in candidates[:20_000]:
        row: dict[str, Any] = {}
        steps, expected = [], []
        for node in candidate.iter():
            if node is candidate: continue
            key = local_name(node.tag)
            text = " ".join("".join(node.itertext()).split())
            if not text: continue
            if key in {"step", "action", "description"} and text not in steps: steps.append(text)
            if key in {"expected", "expectedresult", "result", "passcriteria"} and text not in expected: expected.append(text)
            if key not in row and len(list(node)) == 0: row[key] = text
        row.setdefault("title", candidate.attrib.get("name") or candidate.attrib.get("key") or row.get("summary") or row.get("name"))
        if steps: row["steps"] = steps
        if expected: row["expected"] = expected
        if any(value not in (None, "", []) for value in row.values()): rows.append(row)
    return rows


def structured_case(row: dict[str, Any]) -> dict[str, Any]:
    merged = dict(row)
    for key in ("fields", "testInfo", "test_info"):
        nested = row.get(key)
        if isinstance(nested, dict):
            for nested_key, value in nested.items(): merged.setdefault(nested_key, value)
    raw_steps = next((merged.get(key) for key in ("steps", "testSteps", "test_steps", "custom_steps_separated", "custom_steps") if merged.get(key)), [])
    if isinstance(raw_steps, str) and raw_steps.strip().startswith(("[", "{")):
        try:
            decoded = json.loads(raw_steps)
            raw_steps = decoded.get("steps", []) if isinstance(decoded, dict) else decoded
        except json.JSONDecodeError:
            pass
    actions, expected, data = [], [], []
    if isinstance(raw_steps, list):
        for step in raw_steps:
            if isinstance(step, dict):
                action = next((step.get(key) for key in ("action", "step", "description", "content", "text") if step.get(key)), "")
                oracle = next((step.get(key) for key in ("expectedResult", "expected", "result", "outcome") if step.get(key)), "")
                inputs = next((step.get(key) for key in ("data", "input", "testData") if step.get(key)), "")
                if action: actions.append(str(action))
                if oracle: expected.append(str(oracle))
                if inputs: data.append(str(inputs))
            elif str(step).strip(): actions.append(str(step))
    if actions: merged["steps"] = actions
    if expected: merged["expected"] = expected
    if data: merged["data"] = data
    return merged


def structured_rows(path: Path, text: str) -> list[dict[str, Any]] | None:
    suffix = path.suffix.lower()
    if suffix == ".xlsx": return [structured_case(row) for row in xlsx_rows(path)[:20_000]]
    if suffix == ".xml": return [structured_case(row) for row in xml_rows(path)[:20_000]]
    if suffix in {".csv", ".tsv"}:
        return [structured_case(row) for index, row in enumerate(csv.DictReader(text.splitlines(), delimiter="\t" if suffix == ".tsv" else ",")) if index < 20_000]
    if suffix == ".jsonl":
        return [structured_case(row) for index, row in enumerate(json.loads(line) for line in text.splitlines() if line.strip()) if index < 20_000 and isinstance(row, dict)]
    if suffix == ".json":
        value = json.loads(text)
        if isinstance(value, list): return [structured_case(row) for row in value[:20_000] if isinstance(row, dict)]
        if isinstance(value, dict):
            for key in ("tests", "cases", "testCases", "items"):
                if isinstance(value.get(key), list): return [structured_case(row) for row in value[key][:20_000] if isinstance(row, dict)]
            return [structured_case(value)]
    return None


def legacy_drafts(path: Path, source_label: str | None = None) -> list[dict[str, Any]]:
    label = source_label or str(path)
    display_path = Path(label)
    text = docx_text(path) if path.suffix.lower() == ".docx" else ("" if path.suffix.lower() == ".xlsx" else path.read_text(encoding="utf-8", errors="replace"))
    reject_secrets(text, f"legacy source {path}")
    digest = hashlib.sha256(path.read_bytes()).hexdigest()
    framework = framework_name(display_path, text)
    rows = structured_rows(path, text)
    if rows is not None: reject_secrets(json.dumps(rows, ensure_ascii=False), f"legacy source {path}")
    drafts: list[dict[str, Any]] = []
    if rows is not None:
        for index, row in enumerate(rows):
            title = str(row_value(row, "title", "name", "test", "test_case", "summary", "testkey", "key") or f"{display_path.stem} case {index + 1}")
            steps = string_list(row_value(row, "steps", "procedure", "actions", "freeformtext", "freeform_text", "custom_steps", "custom_steps_separated"))
            expected = string_list(row_value(row, "expected", "expected_result", "expectedresult", "oracle", "assertion", "pass_criteria", "custom_expected"))
            description = str(row_value(row, "intent", "prompt", "description", "goal", "objective") or "")
            prompt = description or f"Validate that {title.rstrip('.')} from the legacy {framework} suite. Prefer observable user and system outcomes over implementation-specific mechanics."
            source = {"kind": "legacy", "path": label, "framework": framework, "sha256": digest, "caseIndex": index + 1, "originalTitle": title}
            external_id = row_value(row, "id", "case_id", "test_id", "testkey", "key", "issue_key")
            if external_id: source["externalId"] = str(external_id)
            section = row_value(row, "section", "folder", "test_repository_path", "suite")
            if section: source["section"] = str(section)
            external_priority = row_value(row, "priority", "case_priority", "test_priority")
            if external_priority: source["externalPriority"] = str(external_priority)
            test_type = row_value(row, "type", "test_type", "testtype", "template")
            if test_type: source["externalType"] = str(test_type)
            drafts.append(normalize_test({
                "title": title, "intentPrompt": prompt, "setup": row_value(row, "setup", "preconditions", "precondition", "given", "custom_preconds"),
                "inputs": row_value(row, "data", "inputs", "test_data"), "oracles": expected,
                "tags": ["legacy-import", framework], "priority": imported_priority(external_priority), "status": "draft",
                "evidenceRequirements": ["actual result", "environment/build", "observable outcome evidence"],
            }, source=source, identifier=stable_id(label, digest, str(index), title)))
            if steps:
                drafts[-1]["legacySteps"] = steps
        return drafts

    matches = []
    for pattern in TEST_PATTERNS:
        matches.extend((match.start(), match.group("title").strip()) for match in pattern.finditer(text))
    matches = sorted(set(matches))
    if not matches and text.strip():
        matches = [(0, display_path.stem.replace("_", " ").replace("-", " "))]
    for index, (start, title) in enumerate(matches):
        end = matches[index + 1][0] if index + 1 < len(matches) else len(text)
        chunk = text[start:end][:16000]
        assertions = [redact(line.strip())[:1000] for line in chunk.splitlines() if ASSERTION_PATTERN.search(line)][:12]
        actions = [redact(line.strip())[:1000] for line in chunk.splitlines() if ACTION_PATTERN.search(line)][:20]
        human_title = re.sub(r"^(?:test_?|Test)", "", title).replace("_", " ").strip() or title
        prompt = f"Validate that {human_title.rstrip('.')} from the legacy {framework} suite. Preserve the business intent, but prefer resilient user-visible behavior, service state, and recovery over implementation-specific selectors or timing."
        source = {"kind": "legacy", "path": label, "framework": framework, "sha256": digest, "line": text.count("\n", 0, start) + 1, "originalTitle": title}
        draft = normalize_test({
            "title": human_title, "intentPrompt": prompt, "oracles": assertions,
            "tags": ["legacy-import", framework], "status": "draft",
            "evidenceRequirements": ["actual result", "environment/build", "observable outcome evidence"],
        }, source=source, identifier=stable_id(label, digest, str(start), title))
        if actions: draft["legacySteps"] = actions
        draft["readiness"] = readiness(draft)
        drafts.append(draft)
    return drafts


def cmd_init(args: argparse.Namespace) -> None:
    root = Path(args.root).expanduser().resolve()
    library = load_library(root)
    print(json.dumps({"library": str(store_path(root)), "tests": len(library["tests"])}, indent=2))


def cmd_list(args: argparse.Namespace) -> None:
    root = Path(args.root).expanduser().resolve()
    tests = load_library(root)["tests"]
    if args.status: tests = [test for test in tests if test.get("status") == args.status]
    if args.query:
        query = args.query.lower()
        tests = [test for test in tests if query in json.dumps(test, ensure_ascii=False).lower()]
    print(json.dumps({"library": str(store_path(root)), "count": len(tests), "tests": tests}, indent=2))


def cmd_create(args: argparse.Namespace) -> None:
    root = Path(args.root).expanduser().resolve()
    library = load_library(root)
    test = normalize_test({
        "title": args.title, "intentPrompt": args.prompt, "scope": args.scope,
        "setup": args.setup, "inputs": args.input, "oracles": args.expected,
        "tags": args.tag, "adapter": args.adapter, "priority": args.priority, "status": args.status,
    })
    if any(row.get("id") == test["id"] for row in library["tests"]):
        raise ValueError(f"An equivalent prompt-defined test already exists: {test['id']}")
    library["tests"].append(test)
    save_library(root, library)
    print(json.dumps(test, indent=2))


def cmd_update(args: argparse.Namespace) -> None:
    root = Path(args.root).expanduser().resolve()
    library = load_library(root)
    index, existing = find_test(library, args.id)
    patch = json.loads(Path(args.patch).read_text(encoding="utf-8")) if args.patch else {}
    if not isinstance(patch, dict): raise ValueError("patch must be a JSON object")
    for key, value in {
        "title": args.title, "intentPrompt": args.prompt, "scope": args.scope,
        "oracles": args.expected, "adapter": args.adapter, "priority": args.priority,
        "status": args.status, "tags": args.tag,
    }.items():
        if value is not None: patch[key] = value
    merged = {**existing, **patch, "id": existing["id"], "createdAt": existing["createdAt"], "revision": int(existing.get("revision", 1)) + 1}
    library["tests"][index] = normalize_test(merged, identifier=existing["id"])
    save_library(root, library)
    print(json.dumps(library["tests"][index], indent=2))


def cmd_duplicate(args: argparse.Namespace) -> None:
    root = Path(args.root).expanduser().resolve()
    library = load_library(root)
    _, existing = find_test(library, args.id)
    copy = normalize_test({**existing, "title": args.title or f"{existing['title']} copy", "status": "draft", "revision": 1, "createdAt": now()}, identifier=stable_id("copy", existing["id"], now()))
    library["tests"].append(copy)
    save_library(root, library)
    print(json.dumps(copy, indent=2))


def cmd_status(args: argparse.Namespace) -> None:
    root = Path(args.root).expanduser().resolve()
    library = load_library(root)
    index, existing = find_test(library, args.id)
    existing["status"] = args.status
    existing["revision"] = int(existing.get("revision", 1)) + 1
    existing["updatedAt"] = now()
    library["tests"][index] = existing
    save_library(root, library)
    print(json.dumps(existing, indent=2))


def cmd_delete(args: argparse.Namespace) -> None:
    if not args.confirm: raise ValueError("Deletion is permanent. Pass --confirm after reviewing the exact test ID.")
    root = Path(args.root).expanduser().resolve()
    library = load_library(root)
    index, existing = find_test(library, args.id)
    del library["tests"][index]
    save_library(root, library)
    print(json.dumps({"deleted": existing["id"], "title": existing["title"], "library": str(store_path(root))}, indent=2))


def cmd_import(args: argparse.Namespace) -> None:
    root = Path(args.root).expanduser().resolve()
    library = load_library(root)
    known = {test.get("id") for test in library["tests"]}
    imported, skipped, failures = [], [], []
    labels = {}
    if args.source_manifest:
        manifest = json.loads(Path(args.source_manifest).read_text(encoding="utf-8"))
        if not isinstance(manifest, dict): raise ValueError("source manifest must be a JSON object")
        labels = {str(Path(key).expanduser().resolve()): str(value) for key, value in manifest.items()}
    for source in resolve_sources(args.source):
        label = labels.get(str(source), str(source))
        try:
            for draft in legacy_drafts(source, label):
                if draft["id"] in known:
                    skipped.append({"id": draft["id"], "source": label, "reason": "already imported"})
                    continue
                known.add(draft["id"])
                library["tests"].append(draft)
                imported.append({"id": draft["id"], "title": draft["title"], "source": label, "readiness": draft["readiness"]})
        except (OSError, ValueError, json.JSONDecodeError, csv.Error) as error:
            failures.append({"source": label, "error": str(error)})
    save_library(root, library)
    bake = None
    if args.bake_in and imported:
        from carbon_bake import prepare_bake_run
        bake = prepare_bake_run(root, [row["id"] for row in imported], args.max_repairs, args.target)
    print(json.dumps({
        "library": str(store_path(root)), "imported": len(imported), "skipped": len(skipped), "failed": len(failures),
        "drafts": imported, "skips": skipped, "failures": failures,
        "bakeRun": ({"id": bake["id"], "status": bake["status"], "summary": bake["summary"], "report": bake["report"]} if bake else None),
        "next": (f"Continue the full local bake-in run {bake['id']}; execute every queued intent, repair only test-definition failures, and rerun before completion." if bake else "Review readiness gaps and source provenance, edit the intent/oracle, then explicitly activate selected drafts."),
    }, indent=2))


def cmd_export(args: argparse.Namespace) -> None:
    root = Path(args.root).expanduser().resolve()
    tests = load_library(root)["tests"]
    if not args.include_archived: tests = [test for test in tests if test.get("status") != "archived"]
    output = Path(args.output).expanduser().resolve()
    output.parent.mkdir(parents=True, exist_ok=True)
    if args.format == "prompts":
        text = "\n\n".join(f"# {test['id']} · {test['title']}\n{test['intentPrompt']}\nExpected: {'; '.join(test.get('oracles') or ['not yet defined'])}" for test in tests)
        output.write_text(text + ("\n" if text else ""), encoding="utf-8")
    else:
        atomic_json(output, {"schema": SCHEMA, "tests": tests, "exportedAt": now()})
    print(json.dumps({"output": str(output), "tests": len(tests), "format": args.format}, indent=2))


def parser() -> argparse.ArgumentParser:
    root = argparse.ArgumentParser(description=__doc__)
    sub = root.add_subparsers(dest="command", required=True)
    init = sub.add_parser("init"); init.add_argument("--root", required=True); init.set_defaults(func=cmd_init)
    listing = sub.add_parser("list"); listing.add_argument("--root", required=True); listing.add_argument("--status", choices=sorted(STATUSES)); listing.add_argument("--query"); listing.set_defaults(func=cmd_list)
    create = sub.add_parser("create"); create.add_argument("--root", required=True); create.add_argument("--prompt", required=True); create.add_argument("--title"); create.add_argument("--scope", default=""); create.add_argument("--setup", action="append", default=[]); create.add_argument("--input", action="append", default=[]); create.add_argument("--expected", action="append", default=[]); create.add_argument("--tag", action="append", default=[]); create.add_argument("--adapter", default="auto"); create.add_argument("--priority", choices=sorted(PRIORITIES), default="P2"); create.add_argument("--status", choices=sorted(STATUSES), default="draft"); create.set_defaults(func=cmd_create)
    update = sub.add_parser("update"); update.add_argument("--root", required=True); update.add_argument("--id", required=True); update.add_argument("--patch"); update.add_argument("--title"); update.add_argument("--prompt"); update.add_argument("--scope"); update.add_argument("--expected", action="append"); update.add_argument("--tag", action="append"); update.add_argument("--adapter"); update.add_argument("--priority", choices=sorted(PRIORITIES)); update.add_argument("--status", choices=sorted(STATUSES)); update.set_defaults(func=cmd_update)
    duplicate = sub.add_parser("duplicate"); duplicate.add_argument("--root", required=True); duplicate.add_argument("--id", required=True); duplicate.add_argument("--title"); duplicate.set_defaults(func=cmd_duplicate)
    status = sub.add_parser("status"); status.add_argument("--root", required=True); status.add_argument("--id", required=True); status.add_argument("--status", choices=sorted(STATUSES), required=True); status.set_defaults(func=cmd_status)
    delete = sub.add_parser("delete"); delete.add_argument("--root", required=True); delete.add_argument("--id", required=True); delete.add_argument("--confirm", action="store_true"); delete.set_defaults(func=cmd_delete)
    importing = sub.add_parser("import"); importing.add_argument("--root", required=True); importing.add_argument("--source", action="append", required=True); importing.add_argument("--source-manifest"); importing.add_argument("--target", default=""); importing.add_argument("--max-repairs", type=int, default=2); importing.add_argument("--bake-in", action=argparse.BooleanOptionalAction, default=True); importing.set_defaults(func=cmd_import)
    export = sub.add_parser("export"); export.add_argument("--root", required=True); export.add_argument("--output", required=True); export.add_argument("--format", choices=("json", "prompts"), default="json"); export.add_argument("--include-archived", action="store_true"); export.set_defaults(func=cmd_export)
    return root


def main() -> int:
    try:
        args = parser().parse_args()
        args.func(args)
        return 0
    except (OSError, ValueError, json.JSONDecodeError) as error:
        print(f"carbon_intent: {error}", file=sys.stderr)
        return 2


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