#!/usr/bin/env python3
"""Read-only project inventory and CARBON workflow advisor."""

from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
from collections import Counter
from pathlib import Path
from typing import Any

from carbon_context import safe_execution_context


SCHEMA = "carbon.project-advice/v1"
BETA_NOTICE = (
    "CARBON is in beta and under active development. Commands, APIs, and artifact "
    "formats may change; verify critical results and share feedback."
)
IGNORED_DIRS = {
    ".git", ".carbon", ".confidence", ".idea", ".vscode", ".next", ".nuxt",
    ".venv", "venv", "node_modules", "vendor", "dist", "build", "coverage",
    "target", "Pods", "__pycache__", ".cache", ".pytest_cache",
}
SOURCE_EXTENSIONS = {
    ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".py", ".rb", ".go",
    ".java", ".kt", ".swift", ".cs", ".php", ".rs", ".html", ".vue",
    ".svelte", ".dart", ".scala", ".ex", ".exs",
}
LANGUAGES = {
    ".js": "JavaScript", ".jsx": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript",
    ".ts": "TypeScript", ".tsx": "TypeScript", ".py": "Python", ".rb": "Ruby",
    ".go": "Go", ".java": "Java", ".kt": "Kotlin", ".swift": "Swift",
    ".cs": "C#", ".php": "PHP", ".rs": "Rust", ".html": "HTML",
    ".vue": "Vue", ".svelte": "Svelte", ".dart": "Dart", ".scala": "Scala",
    ".ex": "Elixir", ".exs": "Elixir",
}
TEST_PATH_MARKERS = ("test", "tests", "spec", "specs", "e2e", "playwright", "cypress", "selenium")
UI_EXTENSIONS = {".html", ".jsx", ".tsx", ".vue", ".svelte", ".swift", ".dart"}
API_ROUTE_RE = re.compile(
    r"(?:app|router|server)\.(?:get|post|put|patch|delete|head|options)\s*\(|"
    r"@(?:app|router)\.(?:get|post|put|patch|delete|head|options)\s*\(|"
    r"\b(?:GET|POST|PUT|PATCH|DELETE)\s+/(?:api|v\d+)", re.I,
)
FRAMEWORK_MARKERS = {
    "Next.js": ("next", "next.config"),
    "React": ("react",),
    "Vue": ("vue",),
    "Svelte": ("svelte",),
    "Angular": ("@angular/core", "angular.json"),
    "Express": ("express",),
    "Fastify": ("fastify",),
    "NestJS": ("@nestjs/core",),
    "Django": ("django", "manage.py"),
    "FastAPI": ("fastapi",),
    "Flask": ("flask",),
    "Rails": ("rails", "gemfile"),
    "Spring": ("spring-boot", "pom.xml"),
    "ASP.NET": ("microsoft.aspnetcore",),
    "Flutter": ("flutter", "pubspec.yaml"),
}
TEST_FRAMEWORK_MARKERS = {
    "Playwright": ("@playwright/test", "playwright"),
    "Cypress": ("cypress",),
    "Selenium": ("selenium", "webdriver"),
    "Jest": ("jest",),
    "Vitest": ("vitest",),
    "pytest": ("pytest",),
    "JUnit": ("junit",),
    "RSpec": ("rspec",),
    "Robot Framework": ("robotframework", ".robot"),
}
AI_MARKERS = (
    "openai", "anthropic", "gemini", "langchain", "llamaindex", "llama-index",
    "transformers", "huggingface", "ollama", "litellm", "semantic-kernel",
    "embedding", "vector_store", "vectorstore", "prompt_template", "system_prompt",
    "retrieval_augmented", "llm", "model_provider", "ai_eval", "agent_tool",
)
RISK_MARKERS = {
    "authentication or authorization": ("auth", "login", "oauth", "permission", "role", "session"),
    "payments or commerce": ("payment", "billing", "checkout", "stripe", "cart", "order"),
    "personal or sensitive data": ("privacy", "profile", "patient", "health", "ssn", "pii", "gdpr"),
    "uploads, downloads, or file handling": ("upload", "download", "attachment", "file"),
    "notifications or external messages": ("email", "sms", "notification", "webhook", "message"),
    "database or migration changes": ("migration", "schema", "database", "prisma", "drizzle", "sql"),
    "infrastructure or deployment": ("terraform", "docker", "kubernetes", "deploy", "workflow", "infra"),
}


def read_text(path: Path, limit: int = 400_000) -> str:
    try:
        if not path.is_file() or path.stat().st_size > limit:
            return ""
        return path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return ""


def load_json(path: Path, fallback: Any) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return fallback


def repository_files(root: Path, max_files: int) -> tuple[list[Path], bool]:
    files: list[Path] = []
    truncated = False
    for current, dirs, names in os.walk(root):
        dirs[:] = sorted(name for name in dirs if name not in IGNORED_DIRS and not name.startswith(".cache"))
        for name in sorted(names):
            path = Path(current) / name
            if path.is_symlink():
                continue
            files.append(path)
            if len(files) >= max_files:
                truncated = True
                return files, truncated
    return files, truncated


def git_rows(root: Path, args: list[str]) -> list[str]:
    try:
        result = subprocess.run(
            ["git", "-C", str(root), *args], capture_output=True, text=True,
            check=True, timeout=12,
        )
    except (OSError, subprocess.SubprocessError):
        return []
    return [row.strip() for row in result.stdout.splitlines() if row.strip()]


def changed_files(root: Path) -> list[str]:
    rows = git_rows(root, ["status", "--porcelain=v1"])
    changed: list[str] = []
    for row in rows:
        name = row[3:] if len(row) > 3 else ""
        if " -> " in name:
            name = name.split(" -> ", 1)[1]
        if name and name not in changed:
            changed.append(name)
    if changed:
        return changed[:200]
    return git_rows(root, ["diff", "--name-only", "HEAD~1...HEAD"])[:200]


def manifest_corpus(root: Path, files: list[Path]) -> tuple[str, dict[str, Any]]:
    names = {
        "package.json", "pyproject.toml", "requirements.txt", "Pipfile", "Gemfile",
        "pom.xml", "build.gradle", "build.gradle.kts", "Cargo.toml", "go.mod",
        "pubspec.yaml", "composer.json", "mix.exs",
    }
    selected = [path for path in files if path.name in names][:40]
    corpus = "\n".join(f"{path.name}\n{read_text(path)}" for path in selected).lower()
    package = load_json(root / "package.json", {})
    return corpus[:2_000_000], package if isinstance(package, dict) else {}


def detect_markers(corpus: str, relative_names: list[str], markers: dict[str, tuple[str, ...]]) -> list[str]:
    name_corpus = "\n".join(relative_names).lower()
    found = []
    for label, values in markers.items():
        if any(value.lower() in corpus or value.lower() in name_corpus for value in values):
            found.append(label)
    return found


def count_intent_tests(root: Path) -> dict[str, int]:
    library = load_json(root / ".carbon" / "tests" / "intent-tests.json", {})
    tests = library.get("tests", []) if isinstance(library, dict) else []
    if not isinstance(tests, list):
        tests = []
    counts = Counter(str(test.get("status") or "unknown") for test in tests if isinstance(test, dict))
    return {"total": len(tests), "active": counts["active"], "draft": counts["draft"], "archived": counts["archived"]}


def count_dirs(path: Path) -> int:
    try:
        return sum(1 for child in path.iterdir() if child.is_dir())
    except OSError:
        return 0


def latest_import_bake(root: Path) -> dict[str, Any] | None:
    base = root / ".carbon" / "tests" / "bake-runs"
    try:
        candidates = sorted((path for path in base.iterdir() if path.is_dir()), key=lambda path: path.stat().st_mtime, reverse=True)
    except OSError:
        return None
    for candidate in candidates:
        value = load_json(candidate / "bake.json", {})
        if isinstance(value, dict) and value.get("schema") == "carbon.import-bake/v1":
            unfinished = sum(int((value.get("summary") or {}).get(key, 0)) for key in ("queued", "running", "needs-repair"))
            return {"id": value.get("id"), "status": value.get("status"), "unfinished": unfinished, "summary": value.get("summary") or {}}
    return None


def carbon_state(root: Path) -> dict[str, Any]:
    base = root / ".carbon"
    framework = load_json(base / "framework" / "product-model.json", {})
    return {
        "initialized": base.is_dir(),
        "frameworkModel": bool(framework),
        "runs": count_dirs(base / "runs"),
        "learningSessions": count_dirs(base / "learning"),
        "recordings": count_dirs(base / "recordings"),
        "autoLoops": count_dirs(base / "auto"),
        "intentTests": count_intent_tests(root),
        "importBakes": count_dirs(base / "tests" / "bake-runs"),
        "latestImportBake": latest_import_bake(root),
        "confidenceWorkspace": (root / ".confidence").is_dir(),
    }


def risk_areas(names: list[str], changed: list[str]) -> list[dict[str, Any]]:
    all_names = "\n".join(names).lower()
    changed_names = "\n".join(changed).lower()
    risks = []
    for label, markers in RISK_MARKERS.items():
        project_hit = any(marker in all_names for marker in markers)
        change_hit = any(marker in changed_names for marker in markers)
        if project_hit:
            risks.append({"area": label, "changedNow": change_hit})
    return sorted(risks, key=lambda row: (not row["changedNow"], row["area"]))


def add_step(steps: list[dict[str, Any]], *, priority: int, command: str, title: str,
             why: str, evidence: str, approval: bool = False) -> None:
    if any(step["command"] == command for step in steps):
        return
    steps.append({
        "priority": priority,
        "title": title,
        "command": command,
        "why": why,
        "evidenceExpected": evidence,
        "requiresApproval": approval,
    })


def recommendations(observed: dict[str, Any], scope: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
    changes = observed["changes"]
    surfaces = observed["surfaces"]
    tests = observed["tests"]
    state = observed["carbonState"]
    risks = observed["riskAreas"]
    changed_risk = any(row["changedNow"] for row in risks)
    size = "medium" if changed_risk or len(changes) > 20 else "quick"
    strategy = "depth" if changed_risk and len(changes) <= 12 else ("balanced" if len(changes) > 12 else "auto")
    target = " ".join(scope.split())[:240] or ("current change" if changes else "current project")
    target = target.replace('"', "'").replace("`", "'")
    first = {
        "command": f"$carbon test this --scope \"{target}\" --size {size} --strategy {strategy}",
        "claudeCode": f"/carbon:carbon test this --scope \"{target}\" --size {size} --strategy {strategy}",
        "chatgptOrMcp": f"/carbon test this --scope \"{target}\" --size {size} --strategy {strategy}",
        "mode": "Test" if observed["runnableSignals"] else "Review",
        "size": size,
        "strategy": strategy,
        "reason": "Start with the smallest run that covers the observed change and risk surface." if changes else "Establish a baseline product model and evidence-backed coverage before expanding automation.",
    }

    steps: list[dict[str, Any]] = []
    latest_bake = state.get("latestImportBake") or {}
    if latest_bake.get("unfinished"):
        add_step(steps, priority=0, command=f"$carbon-tests continue bake {latest_bake['id']}", title="Finish the imported-test bake",
                 why=f"The latest local import bake has {latest_bake['unfinished']} unfinished tests.",
                 evidence="Terminal result for every converted intent, bounded test-definition repairs, activation of observed passes, and the visual bake report.")
    if not state["initialized"]:
        add_step(steps, priority=1, command=first["command"], title="Create the first CARBON baseline",
                 why="No durable .carbon workspace was found.", evidence="Product/change model, prioritized plan, observed results or explicit blockers, and a visual report.")
    else:
        add_step(steps, priority=1, command=first["command"], title="Validate the current scope",
                 why="CARBON state exists; update it with evidence for the current project or change.", evidence="A new durable run linked to existing product and regression knowledge.")

    if surfaces["webUi"]:
        add_step(steps, priority=2, command=f"$carbon-browser test {target} with {strategy} exploration",
                 title="Exercise the user-visible behavior", why="Web UI signals were found in the project.",
                 evidence="Screenshots, DOM/accessibility state, console, network, performance, persona feedback, and exploratory observations where available.")
    if surfaces["api"]:
        add_step(steps, priority=2, command=f"$carbon-generate API boundary, auth, idempotency, and resilience tests for {target}",
                 title="Connect API coverage to product flows", why="API routes or schema artifacts were detected.",
                 evidence="Planned positive, negative, boundary, auth, idempotency, retry, and resilience cases with provenance.")
    if surfaces["ai"]:
        if not state["confidenceWorkspace"]:
            add_step(steps, priority=2, command="$carbon-confidence-init", title="Initialize AI confidence evidence",
                     why="AI-system signals were found but no .confidence workspace exists.",
                     evidence="System map, risks, claims, eval definitions, traces, incidents, and release-report structure.")
        add_step(steps, priority=3, command=f"$carbon-confidence-plan {target}", title="Plan AI-specific validation",
                 why="Non-deterministic behavior needs sampled evals, populations, oracles, judges, security, and monitoring beyond deterministic checks.",
                 evidence="Risk-to-claim-to-eval traceability and decision-changing evidence requirements.")
    if tests["files"]:
        add_step(steps, priority=3, command="$ai-upgrade assess the existing test suite for AI readiness",
                 title="Reuse and strengthen existing tests", why=f"Detected {tests['files']} likely test files across {len(tests['frameworks']) or 1} framework families.",
                 evidence="Keep/repair/convert/retire recommendations and reviewable AI-intent drafts.")
    else:
        add_step(steps, priority=3, command=f"$carbon-generate {target}", title="Generate the first risk-ranked candidates",
                 why="No likely automated test files were detected.",
                 evidence="Planned candidates from the observed product surface; no false pass claims.")
    if state["intentTests"]["draft"]:
        add_step(steps, priority=4, command="$carbon-tests review draft tests", title="Review imported or prompt-defined drafts",
                 why=f"There are {state['intentTests']['draft']} draft intent tests that do not yet join automatic runs.",
                 evidence="Reviewed active tests with explicit setup, oracles, safety, and evidence requirements.")
    if not observed["automation"]["continuousIntegration"]:
        add_step(steps, priority=5, command="$carbon ask CARBON to preview a change-aware CI workflow",
                 title="Preview incremental automation", why="No recognized continuous-integration configuration was found.",
                 evidence="A reviewable PR/manual/nightly workflow proposal; installation remains approval-gated.", approval=True)
    return first, sorted(steps, key=lambda row: row["priority"])


def analyze(root: Path, scope: str, max_files: int) -> dict[str, Any]:
    files, truncated = repository_files(root, max_files)
    relative = [path.relative_to(root).as_posix() for path in files]
    source = [path for path in files if path.suffix.lower() in SOURCE_EXTENSIONS]
    language_counts = Counter(LANGUAGES[path.suffix.lower()] for path in source if path.suffix.lower() in LANGUAGES)
    changes = changed_files(root)
    corpus, package = manifest_corpus(root, files)
    frameworks = detect_markers(corpus, relative, FRAMEWORK_MARKERS)
    test_frameworks = detect_markers(corpus, relative, TEST_FRAMEWORK_MARKERS)
    test_files = [
        path for path in files
        if any(marker in path.relative_to(root).as_posix().lower().split("/") for marker in TEST_PATH_MARKERS)
        or any(marker in path.name.lower() for marker in (".test.", ".spec.", "test_", "_test."))
    ]
    ui_files = [path for path in source if path.suffix.lower() in UI_EXTENSIONS]
    api_docs = [path for path in files if path.name.lower() in {"openapi.json", "openapi.yaml", "openapi.yml", "swagger.json", "swagger.yaml", "swagger.yml"}]
    route_hits = 0
    for path in source[:2500]:
        text = read_text(path, 160_000)
        if text and API_ROUTE_RE.search(text):
            route_hits += 1
    names_lower = "\n".join(relative).lower()
    ai_hits = sorted({marker for marker in AI_MARKERS if marker in corpus or marker in names_lower})
    ci = any(name.startswith((".github/workflows/", ".gitlab-ci")) or name in {"Jenkinsfile", "azure-pipelines.yml", "circle.yml"} for name in relative)
    scheduled = any("schedule" in read_text(path, 120_000).lower() for path in files if ".github/workflows/" in path.relative_to(root).as_posix())
    state = carbon_state(root)
    global_context = safe_execution_context()
    project_name = str(package.get("name") or root.name)
    observed = {
        "project": {
            "name": project_name,
            "root": str(root),
            "filesScanned": len(files),
            "scanTruncated": truncated,
            "sourceFiles": len(source),
            "languages": [{"name": name, "files": count} for name, count in language_counts.most_common(8)],
            "frameworks": frameworks,
        },
        "changes": changes,
        "surfaces": {
            "webUi": bool(ui_files),
            "uiFiles": len(ui_files),
            "api": bool(api_docs or route_hits),
            "apiSchemas": [path.relative_to(root).as_posix() for path in api_docs[:10]],
            "routeFiles": route_hits,
            "ai": bool(ai_hits),
            "aiSignals": ai_hits,
        },
        "tests": {
            "files": len(test_files),
            "frameworks": test_frameworks,
        },
        "automation": {
            "continuousIntegration": ci,
            "scheduledWorkflowSignal": scheduled,
        },
        "carbonState": state,
        "globalExecutionContext": global_context,
        "riskAreas": risk_areas(relative, changes),
        "runnableSignals": bool(source or package.get("scripts")),
    }
    first, steps = recommendations(observed, scope)
    gaps = []
    if truncated:
        gaps.append(f"Inventory stopped at {max_files} files; recommendations may omit later paths.")
    if not changes:
        gaps.append("No current Git change was detected; recommendations use the project baseline.")
    if not observed["runnableSignals"]:
        gaps.append("No clear runnable application signal was detected; begin in Review mode or provide a URL/start command.")
    if observed["surfaces"]["webUi"] and not global_context["username"] and not global_context["passwordConfigured"]:
        gaps.append("A reachable URL and authenticated test identity were not inferred from filenames; provide them before live browser execution if needed.")
    return {
        "schema": SCHEMA,
        "framework": {"name": "CARBON", "releaseStage": "beta"},
        "betaNotice": BETA_NOTICE,
        "readOnly": True,
        "scope": scope or "current project",
        "observed": observed,
        "recommendedStart": first,
        "nextSteps": steps,
        "gaps": gaps,
        "authority": "This assessment changes nothing. Destructive, paid, public, mutation, schedule, regression-promotion, and release actions remain approval-gated.",
        "evidenceBoundary": "Project inventory supports recommendations, not pass/fail claims. Execution evidence is required before CARBON reports a test result.",
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="Analyze a project and recommend the best CARBON workflows and next steps.")
    parser.add_argument("--root", default=".", help="Project root to inspect. Default: current directory.")
    parser.add_argument("--scope", default="", help="Optional feature, change, flow, or release scope.")
    parser.add_argument("--max-files", type=int, default=20_000, help="Maximum project files to inventory (100-50000).")
    parser.add_argument("--json", action="store_true", help="Emit JSON. JSON is also the default stable output.")
    args = parser.parse_args()
    root = Path(args.root).expanduser().resolve()
    if not root.is_dir():
        print(json.dumps({"error": f"Project root is not a directory: {root}"}), file=sys.stderr)
        return 2
    max_files = max(100, min(50_000, args.max_files))
    try:
        result = analyze(root, args.scope, max_files)
    except (OSError, ValueError) as error:
        print(json.dumps({"error": str(error)}), file=sys.stderr)
        return 1
    print(json.dumps(result, indent=2, ensure_ascii=False))
    return 0


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