"""FastAPI application for TraceRTM."""

import asyncio
import inspect
import logging
import os
import signal
import uuid
import warnings

# Suppress websockets legacy ws_handler deprecation (uvicorn/starlette integration).
# Filter without module= so it applies when the warning is triggered from any caller.
warnings.filterwarnings(
    "ignore",
    message="remove second argument of ws_handler",
    category=DeprecationWarning,
)
from collections import defaultdict
from collections.abc import AsyncGenerator, Callable
from contextlib import asynccontextmanager
from pathlib import Path
from types import FrameType
from typing import Annotated, Any, cast


def _path_exists_str(path_str: str) -> bool:
    """Sync helper for Path(path).exists() (run via asyncio.to_thread)."""
    return Path(path_str).exists()


def _path_name_str(path_str: str) -> str:
    """Sync helper for Path(path).name (run via asyncio.to_thread)."""
    return Path(path_str).name


# Disable optional Pydantic plugins that are not part of this repo (ex: logfire).
if os.getenv("PYDANTIC_DISABLE_PLUGINS") is None:
    os.environ["PYDANTIC_DISABLE_PLUGINS"] = "logfire-plugin"

from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, ValidationError
from sqlalchemy import func, or_, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession

# Load .env file if it exists
try:
    from dotenv import load_dotenv

    # Try to find .env file relative to project root
    env_path = Path(__file__).parent.parent.parent.parent / ".env"
    if env_path.exists():
        load_dotenv(env_path, override=True)
except ImportError:
    # If python-dotenv not available, try to read .env manually
    env_path = Path(__file__).parent.parent.parent.parent / ".env"
    if env_path.exists():
        with Path(env_path).open(encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    key, value = line.split("=", 1)
                    os.environ.setdefault(key.strip(), value.strip())

from datetime import UTC

# Imported extracted modules to reduce C901 complexity
from tracertm.api.config.rate_limiting import enforce_rate_limit
from tracertm.api.config.startup import startup_initialization
from tracertm.api.handlers.auth import (
    AuthCallbackPayload,
    auth_callback_endpoint,
    auth_logout_endpoint,
    auth_me_endpoint,
    device_code_endpoint,
    device_complete_endpoint,
    device_token_endpoint,
    login_endpoint,
    signup_endpoint,
)
from tracertm.api.handlers.websocket import websocket_endpoint as _websocket_endpoint_impl
from tracertm.core.concurrency import ConcurrencyError
from tracertm.models.integration import IntegrationCredential
from tracertm.models.item import Item
from tracertm.models.link import Link
from tracertm.repositories import item_repository, link_repository, project_repository
from tracertm.schemas.execution import (
    ExecutionComplete,
    ExecutionCreate,
    ExecutionEnvironmentConfigUpdate,
)
from tracertm.services import (
    cycle_detection_service,
    impact_analysis_service,
    performance_service,
    shortest_path_service,
    traceability_matrix_service,
    traceability_service,
    workos_auth_service,
)
from tracertm.services.cache_service import RedisUnavailableError
from tracertm.services.temporal_service import TemporalService

logger = logging.getLogger(__name__)

# HTTP status threshold for server errors (health checks)
HTTP_OK = 200
HTTP_SERVER_ERROR_START = 500
STATE_PARTS_EXTENDED = 4  # state format: scope:project_id:provider or scope:project_id:provider:...

# ---------------------------------------------------------------------------
# Security and access-control placeholders
# These lightweight hooks exist to satisfy unit tests that patch them; the
# real implementations can be wired in later without changing the API surface.
# ---------------------------------------------------------------------------


class APIKeyManager:
    """Placeholder API key manager.

    These methods exist primarily for unit tests that patch them.
    """

    def generate(self, *_: object, **__: object) -> dict[str, str]:
        """Generate an API key."""
        return {"api_key": "sk_test_placeholder"}

    def validate(self, *_: object, **__: object) -> dict[str, bool]:
        """Validate an API key."""
        return {"valid": True}

    def has_scope(self, *_: object, **__: object) -> bool:
        """Return True if the API key has the required scope."""
        return True

    def is_expired(self, *_: object, **__: object) -> bool:
        """Return True if the API key is expired."""
        return False


class TokenManager:
    """Placeholder token manager.

    Provides stubbed access/refresh token operations used by tests.
    """

    def generate_access_token(self, *_: object, **__: object) -> dict[str, Any]:
        """Generate an access token."""
        return {"access_token": "token", "token_type": "bearer", "expires_in": 3600}

    def refresh_access_token(self, *_: object, **__: object) -> dict[str, Any]:
        """Refresh an access token."""
        return {"access_token": "token", "token_type": "bearer", "expires_in": 3600}

    def validate_refresh_token(self, *_: object, **__: object) -> bool:
        """Validate a refresh token."""
        return True

    def revoke_token(self, *_: object, **__: object) -> bool:
        """Revoke a token."""
        return True


class PermissionManager:
    """Placeholder permission manager."""

    def has_permission(self, *_: object, **__: object) -> bool:
        """Return True if the user has the specified permission."""
        return True


class RateLimiter:
    """Lightweight in-memory rate limiter used for tests."""

    def __init__(self) -> None:
        """Initialize the in-memory rate limiter."""
        self._counts: defaultdict[object, int] = defaultdict(int)

    def check_limit(self, key: object, *_: object, limit: int | None = None, **__: object) -> bool:
        """Return True if the key is still under the rate limit."""
        limit = limit or 1000  # Increased limit for bulk operations
        self._counts[key] += 1
        return self._counts[key] <= limit

    def get_remaining(self, key: object = None, limit: int | None = None, **__: object) -> int:
        """Return remaining requests for the key within the window."""
        limit = limit or 1000  # Increased limit for bulk operations
        return max(0, limit - self._counts.get(key, 0))

    def get_limit(self, *_: object, **__: object) -> int:
        """Return the configured rate limit."""
        return 1000  # Increased limit for bulk operations

    def get_reset_time(self, *_: object, **__: object) -> int:
        """Return the epoch seconds for the reset time (placeholder)."""
        return 0

    def get_retry_after(self, *_: object, **__: object) -> int:
        """Return seconds until retry should be attempted."""
        return 1

    def get_message(self, *_: object, **__: object) -> str:
        """Return a human-readable rate limit error message."""
        return "Rate limit exceeded"


class WorkflowTriggerPayload(BaseModel):
    """Request payload for triggering a workflow by name."""

    workflow_name: str
    input: dict[str, Any]


# AuthCallbackPayload imported from tracertm.api.handlers.auth


def verify_token(token: str, *_: object, **__: object) -> dict[str, Any]:
    """Verify WorkOS AuthKit access tokens."""
    return workos_auth_service.verify_access_token(token)


def verify_refresh_token(refresh_token: str, *_: object, **__: object) -> dict[str, Any]:
    """Verify refresh token using WorkOS AuthKit (if configured)."""
    try:
        result = workos_auth_service.authenticate_with_refresh_token(refresh_token)
        if isinstance(result, dict):
            return result
        msg = "Invalid refresh token response"
        raise ValueError(msg)
    except Exception as exc:
        raise ValueError(str(exc)) from exc


def generate_access_token(refresh_token_val: str, *_: object, **__: object) -> dict[str, Any]:
    """Generate access tokens from refresh token exchange output."""
    result = verify_refresh_token(refresh_token_val)
    if not isinstance(result, dict):
        msg = "Unable to generate access token"
        raise ValueError(msg)
    return {
        "access_token": result.get("access_token"),
        "refresh_token": result.get("refresh_token"),
        "token_type": result.get("token_type", "bearer"),
        "expires_in": result.get("expires_in"),
    }


def check_permissions(*_: object, **__: object) -> bool:
    """Return True when permission checks are satisfied (placeholder)."""
    return True


def check_project_access(*_: object, **__: object) -> bool:
    """Return True when project access checks are satisfied (placeholder)."""
    return True


# System admin: emails listed in TRACERTM_SYSTEM_ADMIN_EMAILS (comma-separated) get full access.
_admin_emails_cache: frozenset[str] | None = None
_admin_user_ids: set[str] = set()


def _system_admin_emails() -> frozenset[str]:
    global _admin_emails_cache
    if _admin_emails_cache is not None:
        return _admin_emails_cache
    raw = os.getenv("TRACERTM_SYSTEM_ADMIN_EMAILS", "kooshapari@gmail.com").strip()
    emails = frozenset(e.strip().lower() for e in raw.split(",") if e.strip())
    _admin_emails_cache = emails
    return emails


def _is_system_admin_email(email: str | None) -> bool:
    if not email:
        return False
    return email.strip().lower() in _system_admin_emails()


def is_system_admin(claims: dict[str, Any] | None, email_from_user: str | None = None) -> bool:
    """True if the user is a system admin (by email or cached user_id from /auth/me)."""
    if not claims or not isinstance(claims, dict):
        return False
    user_id = claims.get("sub")
    if user_id and user_id in _admin_user_ids:
        return True
    email = email_from_user or (claims.get("email") if isinstance(claims.get("email"), str) else None)
    if _is_system_admin_email(cast("str | None", email)):
        if user_id and isinstance(user_id, str):
            _admin_user_ids.add(user_id)
        return True
    return False


def check_permission(*_args: object, **_kwargs: object) -> bool:
    """Return True when permission checks are satisfied (placeholder)."""
    return True


def has_permission(*_args: object, **_kwargs: object) -> bool:
    """Return True when permission checks are satisfied (placeholder)."""
    return True


def check_resource_ownership(*_args: object, **_kwargs: object) -> bool:
    """Return True when resource ownership checks are satisfied (placeholder)."""
    return True


def verify_webhook_signature(*_args: object, **_kwargs: object) -> bool:
    """Return True when webhook signature is valid (placeholder)."""
    return True


def verify_webhook_timestamp(*_args: object, **_kwargs: object) -> bool:
    """Return True when webhook timestamp is valid (placeholder)."""
    return True


def create_session(*_args: object, **_kwargs: object) -> dict[str, str]:
    """Create a session and return its id (placeholder)."""
    return {"session_id": "placeholder"}


def verify_session(*_args: object, **_kwargs: object) -> bool:
    """Return True when session is valid (placeholder)."""
    return True


def invalidate_session(*_args: object, **_kwargs: object) -> bool:
    """Invalidate an existing session (placeholder)."""
    return True


def check_mfa_requirement(*_args: object, **_kwargs: object) -> bool:
    """Return True when MFA is required (placeholder)."""
    return True


def verify_mfa_code(*_args: object, **_kwargs: object) -> bool:
    """Return True when MFA code is valid (placeholder)."""
    return True


def verify_csrf_token(*_args: object, **_kwargs: object) -> bool:
    """Return True when CSRF token is valid (placeholder)."""
    return True


def hash_password(password: str) -> str:
    """Return a deterministic password hash (placeholder)."""
    return f"hashed-{password}"


def get_rate_limit(*_args: object, **_kwargs: object) -> dict[str, int]:
    """Return rate limit information (placeholder)."""
    return {"limit": 100, "remaining": 100, "reset": 0}


def get_endpoint_limit(*_args: object, **_kwargs: object) -> dict[str, int]:
    """Return endpoint limit information (placeholder)."""
    return {"limit": 100, "window": 60}


def get_client_ip(*_args: object, **_kwargs: object) -> str:
    """Return the client IP address (placeholder)."""
    return "127.0.0.1"


def is_whitelisted(*_args: object, **_kwargs: object) -> bool:
    """Return True if client is IP-whitelisted (placeholder)."""
    return False


# ---------------------------------------------------------------------------


CycleDetectionService = cycle_detection_service.CycleDetectionService
ImpactAnalysisService = impact_analysis_service.ImpactAnalysisService
ShortestPathService = shortest_path_service.ShortestPathService
ItemRepository = item_repository.ItemRepository
LinkRepository = link_repository.LinkRepository
ProjectRepository = project_repository.ProjectRepository


async def _maybe_await(value: object) -> object:
    """Await values only when needed."""
    if inspect.isawaitable(value):
        return await value
    return value


def ensure_write_permission(claims: dict[str, Any] | None, action: str) -> None:
    """Basic permission gate used by write endpoints. System admins bypass checks."""
    if is_system_admin(claims):
        return
    role = (claims or {}).get("role")
    if role == "guest":
        raise HTTPException(status_code=403, detail="Read-only role")
    if not check_permissions(role=role, action=action, resource="item"):
        raise HTTPException(status_code=403, detail="Forbidden")


def ensure_read_permission(claims: dict[str, Any] | None, resource_id: str | None = None) -> None:
    """Check read permission; system admins bypass. Raise HTTPException if denied."""
    if is_system_admin(claims):
        return
    if resource_id and not check_project_access(claims.get("sub") if claims else None, resource_id):
        raise HTTPException(status_code=403, detail="Read access denied")


def auth_guard(request: Request) -> dict[str, Any]:
    """Authenticate incoming requests when auth is enabled."""
    auth_header = request.headers.get("Authorization")
    if not auth_header or not auth_header.lower().startswith("bearer ") or "  " in auth_header:
        raise HTTPException(status_code=401, detail="Authorization required")

    token = auth_header.split(None, 1)[1].strip()
    if not token or " " in token:
        raise HTTPException(status_code=401, detail="Authorization required")

    try:
        claims = verify_token(token)
    except Exception as exc:
        logger.exception("Authentication failed")
        raise HTTPException(status_code=401, detail=f"Invalid token: {exc!s}")

    if not isinstance(claims, dict):
        raise HTTPException(status_code=401, detail="Invalid token")
    return claims


def ensure_project_access(project_id: str | None, claims: dict[str, Any] | None) -> None:
    """Check project access using injected helper when available. System admins bypass checks."""
    if not project_id:
        return
    if is_system_admin(claims):
        return
    if not check_project_access(claims.get("sub") if claims else None, project_id):
        raise HTTPException(status_code=403, detail="Project access denied")


def ensure_credential_access(credential: IntegrationCredential | None, claims: dict[str, Any] | None) -> None:
    """Check access to a credential (project or user scoped)."""
    if credential is None:
        raise HTTPException(status_code=404, detail="Credential not found")
    if credential.project_id:
        ensure_project_access(credential.project_id, claims)
        return
    user_id = claims.get("sub") if claims else None
    if not user_id or credential.created_by_user_id != user_id:
        raise HTTPException(status_code=403, detail="Credential access denied")


_rate_limit_counts: defaultdict[tuple[str, str, str], int] = defaultdict(int)

# enforce_rate_limit function now imported from tracertm.api.config.rate_limiting


@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
    """Startup then yield then shutdown (replaces deprecated on_event)."""
    # Startup - now using extracted module to reduce complexity
    await startup_initialization(app)
    try:
        yield
    finally:
        await _shutdown_event_impl(app)


# _startup_event_impl function now replaced by startup_initialization from tracertm.api.config.startup


async def _shutdown_event_impl(app: FastAPI) -> None:
    """Close connections on shutdown."""
    # Stop gRPC server
    if hasattr(app.state, "grpc_server") and app.state.grpc_server:
        try:
            from tracertm.grpc.server import stop_grpc_server

            await stop_grpc_server(app.state.grpc_server)
        except Exception:
            logger.exception("Error stopping gRPC server")

    # Close Go Backend Client
    if hasattr(app.state, "go_client") and app.state.go_client:
        try:
            await app.state.go_client.close()
            logger.info("Go Backend Client closed")
        except Exception:
            logger.exception("Error closing Go Backend Client")

    # Close NATS connection
    if hasattr(app.state, "nats_client") and app.state.nats_client:
        try:
            await app.state.nats_client.close()
            logger.info("NATS connection closed")
        except Exception:
            logger.exception("Error closing NATS connection")


# Create FastAPI app
app = FastAPI(
    title="TraceRTM API",
    description="Traceability Requirements Tracking Management API",
    version="1.0.0",
    lifespan=_lifespan,
)


def _install_signal_logging() -> None:
    """Log shutdown signals so supervisor-initiated stops are explicit."""

    def _wrap_handler(_sig: int, handler: object) -> Callable[[int, FrameType | None], Any] | int | None:
        if callable(handler):

            def _wrapped(signum: int, frame: FrameType | None) -> Any:
                logger.warning("Received shutdown signal: %s", signal.Signals(signum).name)
                return cast("Callable[[int, FrameType | None], Any]", handler)(signum, frame)

            return _wrapped
        return cast("int | None", handler)

    for sig in (signal.SIGTERM, signal.SIGINT):
        prev = signal.getsignal(sig)
        signal.signal(sig, _wrap_handler(sig, prev))


_install_signal_logging()

# Initialize APM instrumentation
try:
    from tracertm.observability import init_tracing, instrument_all, instrument_app

    # Check if tracing is enabled
    tracing_enabled = os.getenv("TRACING_ENABLED", "false").lower() == "true"

    if tracing_enabled:
        # Initialize distributed tracing
        init_tracing(
            service_name="tracertm-python-backend",
            service_version="1.0.0",
            environment=os.getenv("TRACING_ENVIRONMENT", "development"),
            otlp_endpoint=os.getenv("OTLP_ENDPOINT", "127.0.0.1:4317"),
        )

        # Instrument FastAPI
        instrument_app(app)

        # Instrument HTTP clients and Redis
        instrument_all()

        logger.info("✅ APM instrumentation enabled")
    else:
        logger.info("ℹ️  APM instrumentation disabled (set TRACING_ENABLED=true to enable)")
except ImportError as e:
    if tracing_enabled:
        logger.exception("APM instrumentation required but not available")
        raise
    logger.warning("APM instrumentation not available: %s", e)
except Exception:
    if tracing_enabled:
        logger.exception("Failed to initialize APM instrumentation (required)")
        raise
    logger.exception("Failed to initialize APM instrumentation")


@app.exception_handler(RedisUnavailableError)
async def redis_unavailable_handler(_request: Request, exc: RedisUnavailableError) -> JSONResponse:
    """Required service Redis down: fail clearly with named item (CLAUDE.md)."""
    logger.error("Redis unavailable: %s", exc)
    return JSONResponse(
        status_code=503,
        content={"detail": str(exc)},
    )


# GitHub / Linear client errors → HTTP with codes for frontend (toast, reconnect, etc.)
def _register_integration_exception_handlers() -> None:
    from datetime import datetime

    from tracertm.clients.github_client import (
        GitHubAuthError,
        GitHubNotFoundError,
        GitHubRateLimitError,
    )
    from tracertm.clients.linear_client import (
        LinearAuthError,
        LinearNotFoundError,
        LinearRateLimitError,
    )

    @app.exception_handler(GitHubAuthError)
    @app.exception_handler(LinearAuthError)
    async def integration_auth_handler(_request: Request, exc: Exception) -> JSONResponse:
        """Integration token expired/invalid: 401 + code so frontend can show reconnect (no full logout)."""
        logger.warning("Integration auth error: %s", exc)
        return JSONResponse(
            status_code=401,
            content={
                "detail": str(exc) or "Integration token expired or invalid. Please reconnect in Settings.",
                "code": "integration_auth_required",
            },
        )

    @app.exception_handler(GitHubRateLimitError)
    async def github_rate_limit_handler(_request: Request, exc: GitHubRateLimitError) -> JSONResponse:
        """GitHub rate limit: 429 + Retry-After for loud/graceful handling."""
        now = datetime.now(UTC)
        reset = exc.reset_at.replace(tzinfo=UTC) if getattr(exc.reset_at, "tzinfo", None) is None else exc.reset_at
        delta = (reset - now).total_seconds()
        retry_after = max(1, int(delta)) if delta > 0 else 60
        logger.warning("GitHub rate limit: retry after %s s", retry_after)
        return JSONResponse(
            status_code=429,
            content={
                "detail": "GitHub rate limit exceeded. Please try again later.",
                "code": "rate_limited",
                "retry_after": retry_after,
            },
            headers={"Retry-After": str(retry_after)},
        )

    @app.exception_handler(LinearRateLimitError)
    async def linear_rate_limit_handler(_request: Request, _exc: LinearRateLimitError) -> JSONResponse:
        """Linear rate limit: 429 + Retry-After."""
        retry_after = 60
        logger.warning("Linear rate limit: retry after %s s", retry_after)
        return JSONResponse(
            status_code=429,
            content={
                "detail": "Linear rate limit exceeded. Please try again later.",
                "code": "rate_limited",
                "retry_after": retry_after,
            },
            headers={"Retry-After": str(retry_after)},
        )

    @app.exception_handler(GitHubNotFoundError)
    @app.exception_handler(LinearNotFoundError)
    async def integration_not_found_handler(_request: Request, exc: Exception) -> JSONResponse:
        """Integration resource not found: 404 + code for frontend toast."""
        logger.info("Integration not found: %s", exc)
        return JSONResponse(
            status_code=404,
            content={
                "detail": str(exc) or "Resource not found.",
                "code": "integration_not_found",
            },
        )


_register_integration_exception_handlers()


@app.exception_handler(Exception)
async def unhandled_exception_handler(_request: Request, exc: Exception) -> JSONResponse:
    """Log unhandled exceptions and return a safe 500 response (no traceback leak)."""
    if isinstance(exc, HTTPException):
        return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
    logger.error("Unhandled exception: %s", exc)
    return JSONResponse(
        status_code=500,
        content={"detail": "Internal server error"},
    )


# NATS Event Bus integration
# Helper functions (_backoff_delay, _poll_one_service, _poll_services) now in tracertm.api.config.startup

# Add CORS middleware (gateway + frontend only; no wildcards)
# External clients must use the gateway; allow gateway (4000) + frontend origins
CORS_ORIGINS = os.getenv(
    "CORS_ORIGINS",
    (
        "http://localhost:4000,http://127.0.0.1:4000,"
        "http://localhost:5173,http://127.0.0.1:5173,"
        "http://localhost:3000,http://127.0.0.1:3000"
    ),
).split(",")

app.add_middleware(
    CORSMiddleware,
    allow_origins=[origin.strip() for origin in CORS_ORIGINS],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Include specification routers
from tracertm.api.middleware import AuthenticationMiddleware, CacheHeadersMiddleware
from tracertm.api.routers import (
    adrs,
    auth,
    blockchain,
    contracts,
    execution,
    features,
    mcp,
    notifications,
    oauth,
    quality,
)

# Try to import Brotli compression (optional dependency)
try:
    from brotli_asgi import BrotliMiddleware

    brotli_available = True
    BrotliMiddleware_: type[BrotliMiddleware] | None = BrotliMiddleware
except ImportError:
    BrotliMiddleware_ = None
    brotli_available = False
    logger.info("brotli-asgi not installed - response compression disabled")

# Add performance middlewares (order matters - outermost first)
# 1. Brotli compression for smaller JSON responses (20-30% savings)
if brotli_available and BrotliMiddleware_ is not None:
    app.add_middleware(
        BrotliMiddleware_,
        quality=4,  # Balance between speed and compression (1-11)
        minimum_size=500,  # Only compress responses > 500 bytes
    )

# 2. Cache headers for browser caching optimization
app.add_middleware(CacheHeadersMiddleware)

# Authentication endpoints (device flow, token management, etc.)
app.include_router(auth.router)

# Specification routers
app.include_router(adrs.router, prefix="/api/v1")
app.include_router(contracts.router, prefix="/api/v1")
app.include_router(features.router, prefix="/api/v1")
app.include_router(quality.router, prefix="/api/v1")
app.include_router(notifications.router, prefix="/api/v1")
app.include_router(blockchain.router, prefix="/api/v1")
app.include_router(execution.router, prefix="/api/v1")

# Agent sessions and workflow
from tracertm.api.routers import agent

app.include_router(agent.router, prefix="/api/v1")

# MCP router (Model Context Protocol over HTTP)
app.include_router(mcp.router, prefix="/api/v1")

# OAuth and integration management
app.include_router(oauth.router)

# 3. Authentication middleware (must be innermost to run first on request)
app.add_middleware(AuthenticationMiddleware)


from tracertm.api.deps import get_cache_service, get_db
from tracertm.api.handlers.items import (
    ItemListParams,
    build_list_response,
    build_query_conditions,
    execute_item_query,
    resolve_view_matches,
    try_get_from_cache,
)
from tracertm.api.handlers.links import (
    LinkQueryParams,
    build_links_response,
    detect_link_columns,
    execute_links_query,
    parse_exclude_types,
    try_get_links_from_cache,
)
from tracertm.services.cache_service import CacheService


# Health check endpoint
@app.get("/health")
async def health_check() -> dict[str, str]:
    """Health check endpoint."""
    return {
        "status": "healthy",
        "version": "1.0.0",
        "service": "TraceRTM API",
    }


@app.get("/metrics")
async def metrics() -> Response:
    """Prometheus scrape endpoint for monitoring (process metrics, etc.)."""
    from prometheus_client import CONTENT_TYPE_LATEST, REGISTRY, generate_latest

    output = generate_latest(REGISTRY)
    return Response(
        content=output,
        media_type=CONTENT_TYPE_LATEST,
    )


@app.get("/api/v1/health")
async def api_health_check(
    db: Annotated[AsyncSession, Depends(get_db)],
    cache: Annotated[CacheService, Depends(get_cache_service)],
):
    """API health check endpoint with component status."""
    import time

    from sqlalchemy import text

    components: dict[str, dict[str, Any]] = {}
    status = "ok"

    # Database health
    db_start = time.time()
    try:
        await db.execute(text("SELECT 1"))
        latency = (time.time() - db_start) * 1000
        components["database"] = {"status": "healthy", "latency_ms": latency}

        # Preflight: require key Python tables (Alembic migrations applied)
        result = await db.execute(
            text(
                "SELECT EXISTS (SELECT 1 FROM information_schema.tables "
                "WHERE table_schema = 'public' AND table_name = 'test_cases')",
            ),
        )
        tables_ok = result.scalar() is True
        if not tables_ok:
            components["migrations"] = {
                "status": "unhealthy",
                "error": (
                    "Python tables missing; run: ./scripts/run_python_migrations.sh or uv run alembic upgrade head"
                ),
            }
            status = "unhealthy"
        else:
            components["migrations"] = {"status": "healthy"}
    except (OSError, RuntimeError, ValueError, SQLAlchemyError) as exc:
        components["database"] = {"status": "unhealthy", "error": str(exc)}
        status = "unhealthy"

    # Redis / cache health
    try:
        healthy = await cache.health_check()
        components["redis"] = {"status": "healthy" if healthy else "unhealthy"}
        if not healthy:
            status = "unhealthy"
    except (OSError, RedisUnavailableError, RuntimeError, TypeError, ValueError) as exc:
        components["redis"] = {"status": "unhealthy", "error": str(exc)}
        status = "unhealthy"

    # NATS health (if initialized)
    nats_client = getattr(app.state, "nats_client", None)
    try:
        if nats_client is None:
            components["nats"] = {"status": "unhealthy", "error": "not initialized"}
            status = "unhealthy"
        else:
            nats_health = await nats_client.health_check()
            components["nats"] = {
                "status": "healthy" if nats_health.get("connected") else "unhealthy",
                "details": nats_health,
            }
            if not nats_health.get("connected"):
                status = "unhealthy"
    except (AttributeError, OSError, RuntimeError, TypeError, ValueError) as exc:
        components["nats"] = {"status": "unhealthy", "error": str(exc)}
        status = "unhealthy"

    # Temporal health
    try:
        temporal_health = await TemporalService().health_check()
        components["temporal"] = {
            "status": "healthy" if temporal_health.get("status") == "ready" else "unhealthy",
            "details": temporal_health,
        }
        if temporal_health.get("status") != "ready":
            status = "unhealthy"
    except (AttributeError, OSError, RuntimeError, TypeError, ValueError) as exc:
        components["temporal"] = {"status": "unhealthy", "error": str(exc)}
        status = "unhealthy"

    # Go backend health (integration)
    go_backend_url = os.getenv("GO_BACKEND_URL", "http://localhost:8080")
    try:
        import httpx
    except ImportError as exc:
        components["go_backend"] = {"status": "unhealthy", "error": str(exc)}
        status = "unhealthy"
    else:
        try:
            async with httpx.AsyncClient(timeout=5.0) as client:
                resp = await client.get(f"{go_backend_url}/health")
                components["go_backend"] = {
                    "status": "healthy" if resp.status_code < HTTP_SERVER_ERROR_START else "unhealthy",
                    "http_status": resp.status_code,
                }
                if resp.status_code >= HTTP_SERVER_ERROR_START:
                    status = "unhealthy"
        except (httpx.HTTPError, OSError, RuntimeError, ValueError) as exc:
            components["go_backend"] = {"status": "unhealthy", "error": str(exc)}
            status = "unhealthy"

    body = {
        "status": status,
        "service": "tracertm-api",
        "components": components,
    }
    # Fail clearly: return 503 when any required component is unhealthy (CLAUDE.md).
    if status == "unhealthy":
        failed = [name for name, c in components.items() if c.get("status") == "unhealthy"]
        body["detail"] = "Unhealthy components: " + "; ".join(failed)
        return JSONResponse(status_code=503, content=body)
    return body


@app.get("/api/v1/csrf-token")
async def get_csrf_token() -> dict[str, Any]:
    """Get CSRF token for client-side requests."""
    import secrets

    token = secrets.token_urlsafe(32)
    return {
        "token": token,
        "valid": True,
    }


@app.get("/api/v1/cache/stats")
async def cache_stats(
    cache: Annotated[CacheService, Depends(get_cache_service)],
) -> dict[str, Any]:
    """Get cache statistics for monitoring."""
    stats = await cache.get_stats()
    healthy = await cache.health_check()
    return {
        "healthy": healthy,
        "hits": stats.hits,
        "misses": stats.misses,
        "hit_rate": round(stats.hit_rate, 2),
        "total_size_bytes": stats.total_size_bytes,
        "evictions": stats.evictions,
    }


@app.post("/api/v1/cache/clear")
async def cache_clear(
    prefix: str | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    cache: CacheService = Depends(get_cache_service),
) -> dict[str, Any]:
    """Clear cache (admin only). Optionally specify a prefix to clear."""
    ensure_write_permission(claims, action="clear_cache")

    if prefix:
        deleted = await cache.clear_prefix(prefix)
        return {"cleared": True, "prefix": prefix, "keys_deleted": deleted}
    await cache.clear()
    return {"cleared": True, "all": True}


@app.get("/api/v1/mcp/config")
async def mcp_config() -> dict[str, Any]:
    """Return MCP configuration for frontend clients."""
    base_url = os.getenv("TRACERTM_MCP_BASE_URL") or os.getenv("MCP_BASE_URL") or os.getenv("FASTMCP_SERVER_BASE_URL")
    auth_mode = (os.getenv("TRACERTM_MCP_AUTH_MODE") or "oauth").lower().strip()
    return {
        "mcp_base_url": base_url,
        "auth_mode": auth_mode,
        "requires_auth": auth_mode not in {"disabled", "none", "off"},
    }


@app.post("/api/v1/auth/callback")
async def auth_callback(payload: AuthCallbackPayload):
    """Exchange authorization code for user data and tokens (B2B flow)."""
    return await auth_callback_endpoint(payload)


# WebSocket endpoint for real-time updates
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
    """WebSocket endpoint for real-time updates - wrapper for extracted implementation."""
    await _websocket_endpoint_impl(websocket, verify_token)


# Items endpoints
@app.get("/api/v1/items")
async def list_items(
    request: Request,
    project_id: str | None = None,
    view: str | None = None,
    status: str | None = None,
    parent_id: str | None = None,
    skip: int = 0,
    limit: int = 100,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
    cache: CacheService = Depends(get_cache_service),
):
    """List items in a project. Returns empty list on any backend error so callers (e.g. home loader) do not get 500.

Functional Requirements:
- FR-APP-005

User Stories:
- US-ITEM-005

Epics:
- EPIC-003
    """
    try:
        return await _list_items_impl(
            project_id=project_id,
            view=view,
            status=status,
            parent_id=parent_id,
            skip=skip,
            limit=limit,
            claims=claims,
            db=db,
            cache=cache,
            request=request,
        )
    except Exception as exc:
        logger.warning("list_items failed (returning empty): %s", exc, exc_info=True)
        return {"total": 0, "items": []}


async def _list_items_impl(
    project_id: str | None,
    view: str | None,
    status: str | None,
    parent_id: str | None,
    skip: int,
    limit: int,
    claims: dict[str, Any],
    db: AsyncSession,
    cache: CacheService,
    request: Request | None,
):
    """Implementation of list items; errors are caught by list_items."""
    # Skip rate limiting for bulk operations (e.g., fetching counts)
    if not (request and request.headers.get("X-Bulk-Operation") == "true"):
        enforce_rate_limit(request, claims)
    if project_id:
        ensure_project_access(project_id, claims)

    # Try to get from cache (only for simple queries without view resolution)
    params = ItemListParams(
        project_id=project_id,
        view=view,
        status=status,
        parent_id=parent_id,
        skip=skip,
        limit=limit,
    )
    cache_key, cached = await try_get_from_cache(cache, params)
    if cached is not None:
        return cached

    # Resolve view name to actual view values
    matches = await resolve_view_matches(view, project_id, db)

    # Build query conditions
    conditions = build_query_conditions(params, matches)

    # Execute query
    total_count, items = await execute_item_query(db, conditions, params)

    # Build response
    result = build_list_response(items, total_count, project_id, limit)

    # Cache the result if we have a cache key
    if cache_key:
        await cache.set(cache_key, result, cache_type="items")

    return result


def _serialize_item_for_response(item: object) -> dict[str, Any]:
    """Build response dict for a single item; safe for None/missing attributes."""
    created_at = getattr(item, "created_at", None)
    updated_at = getattr(item, "updated_at", None)
    return {
        "id": str(getattr(item, "id", "")),
        "title": getattr(item, "title", ""),
        "description": getattr(item, "description", ""),
        "view": getattr(item, "view", ""),
        "type": getattr(item, "item_type", getattr(item, "view", "")),
        "status": getattr(item, "status", ""),
        "priority": getattr(item, "priority", "medium"),
        "project_id": str(getattr(item, "project_id", "") or ""),
        "created_at": created_at.isoformat() if created_at else None,
        "updated_at": updated_at.isoformat() if updated_at else None,
    }


@app.get("/api/v1/items/{item_id}")
async def get_item(
    item_id: str,
    request: Request,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get a specific item.

Functional Requirements:
- FR-APP-002

User Stories:
- US-ITEM-002

Epics:
- EPIC-003
    """
    try:
        enforce_rate_limit(request, claims)

        repo = item_repository.ItemRepository(db)
        item = await _maybe_await(repo.get_by_id(item_id))

        if not item:
            raise HTTPException(status_code=404, detail="Item not found")

        return _serialize_item_for_response(item)
    except HTTPException:
        raise
    except Exception as e:
        logger.exception("GET /api/v1/items/%s failed", item_id)
        raise HTTPException(
            status_code=500,
            detail="Internal server error",
        ) from e


# Links endpoints
@app.get("/api/v1/links")
async def list_links(
    request: Request,
    project_id: str | None = None,
    source_id: str | None = None,
    target_id: str | None = None,
    skip: int = 0,
    limit: int = 100,
    exclude_types: str | None = None,  # Comma-separated list of types to exclude
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
    cache: CacheService = Depends(get_cache_service),
):
    """List links, optionally filtered by project, source, or target, with support for excluding specific link types.

Functional Requirements:
- FR-APP-010

User Stories:
- US-LINK-005

Epics:
- EPIC-004
    """
    try:
        # Skip rate limiting for bulk operations
        if not (request and request.headers.get("X-Bulk-Operation") == "true"):
            enforce_rate_limit(request, claims)

        if project_id:
            ensure_project_access(project_id, claims)

        # Parse exclude_types from comma-separated string
        exclude_types_list = parse_exclude_types(exclude_types)

        # Try to get from cache
        params = LinkQueryParams(
            project_id=project_id,
            source_id=source_id,
            target_id=target_id,
            exclude_types=exclude_types_list,
            skip=skip,
            limit=limit,
        )
        cache_key, cached = await try_get_links_from_cache(cache, params)
        if cached is not None:
            return cached

        # Detect actual column names from the database
        columns = await detect_link_columns(db)

        # Query links based on filter criteria
        total_count, links_result = await execute_links_query(db, params, columns)

        # Build response
        result = build_links_response(links_result, total_count, project_id)

        # Cache the result
        if cache_key:
            await cache.set(cache_key, result, cache_type="links")
    except HTTPException:
        raise
    except Exception as e:
        logger.exception(
            "GET /api/v1/links failed project_id=%s exclude_types=%s",
            project_id,
            exclude_types,
        )
        raise HTTPException(status_code=500, detail="Internal server error") from e
    else:
        return result


@app.get("/api/v1/links/grouped")
async def list_links_grouped(
    request: Request,
    project_id: str,
    item_id: str,
    view: str | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Show links for an item grouped as incoming/outgoing."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    resolved = await db.execute(
        select(Item.id)
        .where(
            Item.project_id == project_id,
            Item.deleted_at.is_(None),
            or_(Item.id == item_id, Item.external_id.ilike(f"{item_id}%")),
        )
        .limit(1),
    )
    resolved_id = resolved.scalar_one_or_none()
    if not resolved_id:
        raise HTTPException(status_code=404, detail="Item not found")

    item_row = (await db.execute(select(Item).where(Item.id == resolved_id))).scalar_one_or_none()

    view_upper = view.upper() if view else None

    outgoing = (
        (
            await db.execute(
                select(Link)
                .join(Item, Link.target_item_id == Item.id)
                .where(
                    Link.project_id == project_id,
                    Link.source_item_id == resolved_id,
                    Item.deleted_at.is_(None),
                    *([Item.view == view_upper] if view_upper else []),
                ),
            )
        )
        .scalars()
        .all()
    )

    incoming = (
        (
            await db.execute(
                select(Link)
                .join(Item, Link.source_item_id == Item.id)
                .where(
                    Link.project_id == project_id,
                    Link.target_item_id == resolved_id,
                    Item.deleted_at.is_(None),
                    *([Item.view == view_upper] if view_upper else []),
                ),
            )
        )
        .scalars()
        .all()
    )

    async def _item_brief(item_id_value: str | uuid.UUID) -> dict[str, Any]:
        row = await db.execute(select(Item).where(Item.id == item_id_value))
        found = row.scalar_one_or_none()
        return {
            "id": str(found.id) if found else item_id_value,
            "external_id": getattr(found, "external_id", None) if found else None,
            "title": getattr(found, "title", None) if found else None,
            "view": getattr(found, "view", None) if found else None,
            "status": getattr(found, "status", None) if found else None,
        }

    return {
        "item": {
            "id": str(item_row.id) if item_row else resolved_id,
            "external_id": getattr(item_row, "external_id", None) if item_row else None,
            "title": getattr(item_row, "title", None) if item_row else None,
            "view": getattr(item_row, "view", None) if item_row else None,
        },
        "outgoing": [
            {
                "link_id": str(link.id),
                "link_type": link.link_type,
                "direction": "outgoing",
                "item": await _item_brief(link.target_item_id),
            }
            for link in outgoing
        ],
        "incoming": [
            {
                "link_id": str(link.id),
                "link_type": link.link_type,
                "direction": "incoming",
                "item": await _item_brief(link.source_item_id),
            }
            for link in incoming
        ],
    }


class LinkCreate(BaseModel):
    """Request payload for creating a link."""

    project_id: str
    source_id: str
    target_id: str
    type: str
    metadata: dict[str, Any] | None = None


class LinkUpdate(BaseModel):
    """Request payload for updating a link."""

    link_type: str | None = None
    metadata: dict[str, Any] | None = None


@app.post("/api/v1/links")
async def create_link(
    request: Request,
    payload: LinkCreate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
    cache: Annotated[CacheService, Depends(get_cache_service)],
):
    """Create a new link.

Functional Requirements:
- FR-APP-006

User Stories:
- US-LINK-001

Epics:
- EPIC-004
    """
    ensure_write_permission(claims, action="create")
    # Skip rate limiting for bulk operations
    if not (request and request.headers.get("X-Bulk-Operation") == "true"):
        enforce_rate_limit(request, claims)
    ensure_project_access(payload.project_id, claims)

    repo = link_repository.LinkRepository(db)
    link = await repo.create(
        project_id=payload.project_id,
        source_item_id=payload.source_id,
        target_item_id=payload.target_id,
        link_type=payload.type,
        link_metadata=payload.metadata,
    )

    # Invalidate caches for this project
    await cache.invalidate_project(payload.project_id)

    return {
        "id": str(link.id),
        "source_id": str(link.source_item_id),
        "target_id": str(link.target_item_id),
        "type": link.link_type,
        "metadata": link.link_metadata if hasattr(link, "link_metadata") else {},
    }


@app.put("/api/v1/links/{link_id}")
async def update_link(
    request: Request,
    link_id: str,
    request_body: LinkUpdate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Update link fields.

Functional Requirements:
- FR-APP-008

User Stories:
- US-LINK-003

Epics:
- EPIC-004
    """
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, action="update")

    repo = link_repository.LinkRepository(db)
    link_obj = await _maybe_await(repo.get_by_id(link_id))
    if not link_obj:
        raise HTTPException(status_code=404, detail="Link not found")
    link = cast("Link", link_obj)

    if request_body.link_type:
        link.link_type = request_body.link_type
    if request_body.metadata is not None:
        setattr(link, "metadata", request_body.metadata)

    # Flush/refresh if available
    flush = getattr(db, "flush", None)
    if callable(flush):
        await _maybe_await(flush())
    refresh = getattr(db, "refresh", None)
    if callable(refresh):
        await _maybe_await(refresh(link))

    return {
        "id": str(getattr(link, "id", link_id)),
        "source_id": getattr(link, "source_item_id", None),
        "target_id": getattr(link, "target_item_id", None),
        "type": getattr(link, "link_type", request_body.link_type),
        "metadata": getattr(link, "metadata", request_body.metadata),
    }


@app.delete("/api/v1/links/{link_id}")
async def delete_link(
    request: Request,
    link_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Delete link.

Functional Requirements:
- FR-APP-009

User Stories:
- US-LINK-004

Epics:
- EPIC-004
    """
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, action="delete")
    repo = link_repository.LinkRepository(db)
    link = await _maybe_await(repo.get_by_id(link_id))
    if not link:
        raise HTTPException(status_code=404, detail="Link not found")
    ensure_project_access(str(getattr(link, "project_id", "")), claims)
    deleted = await repo.delete(link_id)
    await db.commit()
    return {"deleted": bool(deleted), "id": link_id}


# Analysis endpoints
@app.get("/api/v1/analysis/gaps")
async def get_traceability_gaps(
    request: Request,
    project_id: str,
    from_view: str,
    to_view: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Find coverage gaps between two views."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    service = traceability_service.TraceabilityService(db)
    gaps_result = await _maybe_await(
        service.find_gaps(project_id=project_id, source_view=from_view, target_view=to_view)
    )
    gaps = cast("list[Any]", gaps_result)

    return {
        "project_id": project_id,
        "from_view": from_view.upper(),
        "to_view": to_view.upper(),
        "gap_count": len(gaps),
        "gaps": [
            {
                "id": item.id,
                "external_id": getattr(item, "external_id", None),
                "title": getattr(item, "title", None),
                "status": getattr(item, "status", None),
            }
            for item in gaps
        ],
    }


@app.get("/api/v1/analysis/trace-matrix")
async def get_traceability_matrix(
    request: Request,
    project_id: str,
    source_view: str | None = None,
    target_view: str | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Generate traceability matrix."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    service = traceability_matrix_service.TraceabilityMatrixService(db)
    matrix = await _maybe_await(
        service.generate_matrix(
            project_id=project_id,
            source_view=source_view,
            target_view=target_view,
        ),
    )
    return {
        "project_id": project_id,
        "source_view": source_view,
        "target_view": target_view,
        "matrix": matrix.to_dict() if hasattr(matrix, "to_dict") else matrix,
    }


@app.get("/api/v1/analysis/reverse-impact/{item_id}")
async def get_reverse_impact(
    request: Request,
    item_id: str,
    project_id: str,
    max_depth: int = 5,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Analyze upstream dependencies of an item."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    service = impact_analysis_service.ImpactAnalysisService(db)
    result = await _maybe_await(service.analyze_reverse_impact(item_id=item_id, max_depth=max_depth))

    return {
        "root_item_id": item_id,
        "max_depth": max_depth,
        "dependencies": result.to_dict() if hasattr(result, "to_dict") else result,
    }


@app.get("/api/v1/analysis/health/{project_id}")
async def get_project_health(
    request: Request,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get high-level health metrics for a project."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    service = performance_service.PerformanceService(db)
    stats_result = await _maybe_await(service.get_project_statistics(project_id))
    stats = cast("dict[str, Any]", stats_result)
    return {
        "project_id": project_id,
        "item_count": stats.get("item_count"),
        "link_count": stats.get("link_count"),
        "density": stats.get("density"),
        "complexity": stats.get("complexity"),
        "views": stats.get("views"),
        "statuses": stats.get("statuses"),
    }


@app.get("/api/v1/analysis/impact/{item_id}")
async def get_impact_analysis(
    request: Request,
    item_id: str,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get impact analysis for an item."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    service = impact_analysis_service.ImpactAnalysisService(db)
    try:
        result_obj = await _maybe_await(service.analyze_impact(item_id))
    except Exception as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    result = cast("Any", result_obj)

    return {
        "root_item_id": result.root_item_id,
        "total_affected": result.total_affected,
        "max_depth": result.max_depth_reached,
        "affected_items": result.affected_items,
    }


@app.get("/api/v1/analysis/cycles/{project_id}")
async def detect_cycles(
    request: Request,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Detect cycles in project dependency graph."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    service = cycle_detection_service.CycleDetectionService(db)
    result_obj = await _maybe_await(service.detect_cycles(project_id))
    result = cast("Any", result_obj)

    return {
        "has_cycles": result.has_cycles,
        "total_cycles": result.total_cycles,
        "severity": result.severity,
        "affected_items": list(result.affected_items),
    }


@app.get("/api/v1/analysis/shortest-path")
async def find_shortest_path(
    request: Request,
    project_id: str,
    source_id: str,
    target_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
    cache: Annotated[CacheService, Depends(get_cache_service)],
):
    """Find shortest path between two items with Redis caching for 10x performance."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    service = shortest_path_service.ShortestPathService(db, cache)
    result_obj = await _maybe_await(service.find_shortest_path(project_id, source_id, target_id))
    result = cast("Any", result_obj)

    return {
        "exists": result.exists,
        "distance": result.distance,
        "path": result.path,
        "link_types": result.link_types,
    }


class ItemCreate(BaseModel):
    """Request payload for creating an item."""

    project_id: str
    title: str
    type: str
    description: str | None = None
    view: str | None = None
    status: str | None = "pending"
    priority: str | None = "medium"
    parent_id: str | None = None
    metadata: dict[str, Any] | None = None


class ItemUpdate(BaseModel):
    """Request payload for updating an item."""

    title: str | None = None
    description: str | None = None
    status: str | None = None
    priority: str | None = None
    owner: str | None = None
    metadata: dict[str, Any] | None = None
    expected_version: int | None = None


@app.post("/api/v1/items")
async def create_item_endpoint(
    request: Request,
    payload: ItemCreate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
    cache: Annotated[CacheService, Depends(get_cache_service)],
):
    """Create an item with simple permission checks.

Functional Requirements:
- FR-APP-001

User Stories:
- US-ITEM-001

Epics:
- EPIC-003
    """
    ensure_write_permission(claims, action="create")
    # Skip rate limiting for bulk operations
    if not (request and request.headers.get("X-Bulk-Operation") == "true"):
        enforce_rate_limit(request, claims)
    ensure_project_access(payload.project_id, claims)

    try:
        repo = item_repository.ItemRepository(db)
        # Determine view from type if not provided
        view = payload.view or (payload.type.upper() if payload.type else "")
        item = await repo.create(
            project_id=payload.project_id,
            title=payload.title,
            view=view.upper(),
            item_type=payload.type,
            description=payload.description,
            status=payload.status or "pending",
            parent_id=payload.parent_id,
            metadata=payload.metadata,
            priority=payload.priority or "medium",
        )
        await db.commit()

        # Invalidate caches for this project
        await cache.invalidate_project(payload.project_id)

        return {
            "id": str(item.id),
            "title": item.title,
            "view": item.view,
            "status": item.status,
            "type": item.item_type,
            "description": item.description,
            "priority": item.priority,
        }
    except Exception as exc:
        await db.rollback()
        logger.exception("Error creating item")
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@app.put("/api/v1/items/{item_id}")
async def update_item_endpoint(
    request: Request,
    item_id: str,
    payload: ItemUpdate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
    cache: Annotated[CacheService, Depends(get_cache_service)],
):
    """Update an item with optimistic locking (if expected_version provided).

Functional Requirements:
- FR-APP-003

User Stories:
- US-ITEM-003

Epics:
- EPIC-003
    """
    ensure_write_permission(claims, action="update")
    enforce_rate_limit(request, claims)

    repo = item_repository.ItemRepository(db)
    existing = await _maybe_await(repo.get_by_id(item_id))
    if not existing:
        raise HTTPException(status_code=404, detail="Item not found")

    ensure_project_access(str(getattr(existing, "project_id", "")), claims)

    update_fields = {
        key: value for key, value in payload.model_dump().items() if key != "expected_version" and value is not None
    }
    expected_version = payload.expected_version
    if expected_version is None:
        expected_version = getattr(existing, "version", 0)

    try:
        updated = await repo.update(item_id, expected_version, **update_fields)
        await db.commit()
        await cache.invalidate_project(str(getattr(updated, "project_id", "")))
        return {
            "id": str(updated.id),
            "title": updated.title,
            "description": updated.description,
            "view": updated.view,
            "type": getattr(updated, "item_type", updated.view),
            "status": updated.status,
            "priority": updated.priority,
            "owner": getattr(updated, "owner", None),
            "project_id": str(getattr(updated, "project_id", "")),
            "version": getattr(updated, "version", None),
            "updated_at": updated.updated_at.isoformat() if getattr(updated, "updated_at", None) else None,
        }
    except ConcurrencyError as exc:
        await db.rollback()
        raise HTTPException(status_code=409, detail=str(exc)) from exc
    except Exception as exc:
        await db.rollback()
        logger.exception("Error updating item")
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@app.delete("/api/v1/items/{item_id}")
async def delete_item_endpoint(
    request: Request,
    item_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Delete an item (permission-gated).

Functional Requirements:
- FR-APP-004

User Stories:
- US-ITEM-004

Epics:
- EPIC-003
    """
    ensure_write_permission(claims, action="delete")
    enforce_rate_limit(request, claims)
    repo = item_repository.ItemRepository(db)
    existing = await _maybe_await(repo.get_by_id(item_id))
    if not existing:
        raise HTTPException(status_code=404, detail="Item not found")
    ensure_project_access(str(getattr(existing, "project_id", "")), claims)
    deleted = await repo.delete(item_id, soft=True)
    await db.commit()
    return {"status": "deleted" if deleted else "not_found", "id": item_id}


class ItemBulkUpdate(BaseModel):
    """Request payload for bulk-updating item status."""

    project_id: str
    view: str | None = None
    status: str | None = None
    new_status: str
    preview: bool | None = None


@app.post("/api/v1/items/bulk-update")
async def bulk_update_items_endpoint(
    request: Request,
    payload: ItemBulkUpdate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
    cache: Annotated[CacheService, Depends(get_cache_service)],
):
    """Bulk update item status with optional preview."""
    ensure_write_permission(claims, action="bulk_update")
    enforce_rate_limit(request, claims)
    ensure_project_access(payload.project_id, claims)

    conditions = [Item.project_id == payload.project_id, Item.deleted_at.is_(None)]
    if payload.view:
        conditions.append(Item.view == payload.view.upper())
    if payload.status:
        conditions.append(Item.status == payload.status)

    count_query = select(func.count(Item.id)).where(*conditions)
    count_result = await db.execute(count_query)
    total = count_result.scalar() or 0

    if payload.preview:
        return {
            "project_id": payload.project_id,
            "matched": total,
            "updated": 0,
            "preview": True,
        }

    update_query = update(Item).where(*conditions).values(status=payload.new_status, updated_at=func.now())
    await db.execute(update_query)
    await db.commit()
    await cache.invalidate_project(payload.project_id)

    return {
        "project_id": payload.project_id,
        "matched": total,
        "updated": total,
        "new_status": payload.new_status,
        "preview": False,
    }


@app.get("/api/v1/items/summary")
async def summarize_items_endpoint(
    request: Request,
    project_id: str,
    view: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Summarize items in a view (counts by status + samples)."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    view_upper = view.upper()

    status_counts = (
        await db.execute(
            select(Item.status, func.count(Item.id))
            .where(
                Item.project_id == project_id,
                Item.view == view_upper,
                Item.deleted_at.is_(None),
            )
            .group_by(Item.status),
        )
    ).all()

    samples = (
        (
            await db.execute(
                select(Item)
                .where(
                    Item.project_id == project_id,
                    Item.view == view_upper,
                    Item.deleted_at.is_(None),
                )
                .order_by(Item.updated_at.desc())
                .limit(5),
            )
        )
        .scalars()
        .all()
    )

    return {
        "project_id": project_id,
        "view": view_upper,
        "status_counts": {str(k): v for k, v in status_counts},
        "total": sum(count for _, count in status_counts),
        "samples": [
            {
                "id": str(item.id),
                "external_id": getattr(item, "external_id", None),
                "title": item.title,
                "status": item.status,
                "updated_at": item.updated_at.isoformat() if item.updated_at else None,
            }
            for item in samples
        ],
    }


@app.post("/api/auth/refresh")
async def refresh_access_token_endpoint(payload: dict[str, Any]):
    """Refresh access tokens."""
    refresh_token = payload.get("refresh_token") if payload else None
    if not refresh_token or not isinstance(refresh_token, str):
        raise HTTPException(status_code=400, detail="Missing refresh_token")

    result = verify_refresh_token(refresh_token)
    if not isinstance(result, dict):
        raise HTTPException(status_code=401, detail="Invalid refresh token")
    return {
        "access_token": result.get("access_token"),
        "refresh_token": result.get("refresh_token"),
        "token_type": result.get("token_type", "bearer"),
        "expires_in": result.get("expires_in"),
    }


# auth_me_endpoint is imported from tracertm.api.handlers.auth
app.get("/api/v1/auth/me")(auth_me_endpoint)


# auth_logout_endpoint is imported from tracertm.api.handlers.auth
app.post("/api/v1/auth/logout")(auth_logout_endpoint)


# signup_endpoint is imported from tracertm.api.handlers.auth
app.post("/api/v1/auth/signup")(signup_endpoint)


# login_endpoint is imported from tracertm.api.handlers.auth
app.post("/api/v1/auth/login")(login_endpoint)


# Device Authorization Flow (RFC 8628)
# Store pending device codes in memory (use Redis/database for production)
_device_code_store: dict[str, dict] = {}


# device_code_endpoint is imported from tracertm.api.handlers.auth
app.post("/api/v1/auth/device/code")(device_code_endpoint)


# device_token_endpoint is imported from tracertm.api.handlers.auth
app.post("/api/v1/auth/device/token")(device_token_endpoint)


# Internal endpoint for the browser to complete device authorization
# device_complete_endpoint is imported from tracertm.api.handlers.auth
app.post("/api/v1/auth/device/complete")(device_complete_endpoint)


@app.get("/api/v1/accounts")
async def list_accounts_endpoint(
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """List all accounts for the current user."""
    from tracertm.repositories.account_repository import AccountRepository
    from tracertm.schemas.account import AccountResponse

    user_id = claims.get("sub") if isinstance(claims, dict) else None
    if not user_id or not isinstance(user_id, str):
        raise HTTPException(status_code=401, detail="Authentication required")

    account_repo = AccountRepository(db)
    accounts = await account_repo.list_by_user(user_id)

    return {
        "accounts": [AccountResponse.model_validate(acc) for acc in accounts],
        "total": len(accounts),
    }


@app.post("/api/v1/accounts")
async def create_account_endpoint(
    data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create a new account."""
    import hashlib
    import re

    from tracertm.models.account_user import AccountRole
    from tracertm.repositories.account_repository import AccountRepository
    from tracertm.schemas.account import AccountCreate, AccountResponse

    user_id = claims.get("sub") if isinstance(claims, dict) else None
    if not user_id or not isinstance(user_id, str):
        raise HTTPException(status_code=401, detail="Authentication required")

    try:
        account_data = AccountCreate.model_validate(data)
    except ValidationError as e:
        raise HTTPException(status_code=400, detail=str(e))

    # Generate slug if not provided
    account_slug = account_data.slug
    if not account_slug:
        account_slug = re.sub(r"[^a-z0-9-]", "", account_data.name.lower().replace(" ", "-"))
        if not account_slug:
            account_slug = "account-" + hashlib.md5(account_data.name.encode(), usedforsecurity=False).hexdigest()[:8]

    account_repo = AccountRepository(db)

    # Check if slug exists
    existing = await account_repo.get_by_slug(account_slug)
    if existing:
        raise HTTPException(status_code=400, detail=f"Account slug '{account_slug}' already exists")

    # Create account
    account = await account_repo.create(
        name=account_data.name,
        slug=account_slug,
        account_type=account_data.account_type,
    )

    # Add user as owner
    await account_repo.add_user(account.id, user_id, AccountRole.OWNER)

    await db.commit()

    return AccountResponse.model_validate(account)


@app.post("/api/v1/accounts/{account_id}/switch")
async def switch_account_endpoint(
    account_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Switch active account context."""
    from tracertm.repositories.account_repository import AccountRepository

    user_id = claims.get("sub") if isinstance(claims, dict) else None
    if not user_id or not isinstance(user_id, str):
        raise HTTPException(status_code=401, detail="Authentication required")

    account_repo = AccountRepository(db)

    # Verify user has access to account
    role = await account_repo.get_user_role(account_id, user_id)
    if not role:
        raise HTTPException(status_code=403, detail="Access denied to this account")

    account = await account_repo.get_by_id(account_id)
    if not account:
        raise HTTPException(status_code=404, detail="Account not found")

    # Return account info - frontend will store account_id in token/session
    return {
        "account": {
            "id": account.id,
            "name": account.name,
            "slug": account.slug,
            "account_type": account.account_type,
        },
        "message": "Account switched successfully",
    }


@app.post("/api/v1/workflows/trigger")
async def trigger_workflow_endpoint(
    payload: WorkflowTriggerPayload,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Trigger a Temporal workflow by name."""
    ensure_write_permission(claims, action="trigger_workflow")
    service = TemporalService()
    workflow_map = {
        "graph.snapshot": "GraphSnapshotWorkflow",
        "graph.validate": "GraphValidationWorkflow",
        "graph.export": "GraphExportWorkflow",
        "graph.diff": "GraphDiffWorkflow",
        "integrations.sync": "IntegrationSyncWorkflow",
        "integrations.retry": "IntegrationRetryWorkflow",
    }
    workflow_name = workflow_map.get(payload.workflow_name, payload.workflow_name)
    result = await service.start_workflow(workflow_name, **(payload.input or {}))
    try:
        from tracertm.repositories.workflow_run_repository import WorkflowRunRepository

        repo = WorkflowRunRepository(db)
        input_dict = payload.input or {}
        user_id_obj = claims.get("sub") if claims else None
        user_id = cast("str | None", user_id_obj) if user_id_obj else None
        await repo.create_run(
            workflow_name=payload.workflow_name,
            payload=payload.input or {},
            project_id=cast("str | None", input_dict.get("project_id")),
            graph_id=cast("str | None", input_dict.get("graph_id")),
            external_run_id=result.get("workflow_id") or result.get("run_id"),
            created_by_user_id=user_id,
        )
        await db.commit()
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Failed to record workflow run: {exc}") from exc
    return {"status": "queued", "result": result}


@app.post("/api/v1/workflows/graph-snapshot")
async def trigger_graph_snapshot(
    project_id: str,
    graph_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Trigger graph snapshot workflow in Temporal."""
    ensure_write_permission(claims, action="graph_snapshot")
    service = TemporalService()
    result = await service.start_workflow(
        "GraphSnapshotWorkflow",
        project_id=project_id,
        graph_id=graph_id,
    )
    try:
        from tracertm.repositories.workflow_run_repository import WorkflowRunRepository

        repo = WorkflowRunRepository(db)
        await repo.create_run(
            workflow_name="graph.snapshot",
            payload={"project_id": project_id, "graph_id": graph_id},
            project_id=project_id,
            graph_id=graph_id,
            external_run_id=result.get("workflow_id") or result.get("run_id"),
            created_by_user_id=cast("str | None", claims.get("sub")) if claims else None,
        )
        await db.commit()
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Failed to record workflow run: {exc}") from exc
    return {"status": "queued", "result": result}


@app.post("/api/v1/workflows/graph-validate")
async def trigger_graph_validation(
    project_id: str,
    graph_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Trigger graph validation workflow in Temporal."""
    ensure_write_permission(claims, action="graph_validate")
    service = TemporalService()
    result = await service.start_workflow(
        "GraphValidationWorkflow",
        project_id=project_id,
        graph_id=graph_id,
    )
    try:
        from tracertm.repositories.workflow_run_repository import WorkflowRunRepository

        repo = WorkflowRunRepository(db)
        await repo.create_run(
            workflow_name="graph.validate",
            payload={"project_id": project_id, "graph_id": graph_id},
            project_id=project_id,
            graph_id=graph_id,
            external_run_id=result.get("workflow_id") or result.get("run_id"),
            created_by_user_id=cast("str | None", claims.get("sub")) if claims else None,
        )
        await db.commit()
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Failed to record workflow run: {exc}") from exc
    return {"status": "queued", "result": result}


@app.post("/api/v1/workflows/graph-export")
async def trigger_graph_export(
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Trigger graph export workflow in Temporal."""
    ensure_write_permission(claims, action="graph_export")
    service = TemporalService()
    result = await service.start_workflow(
        "GraphExportWorkflow",
        project_id=project_id,
    )
    try:
        from tracertm.repositories.workflow_run_repository import WorkflowRunRepository

        repo = WorkflowRunRepository(db)
        await repo.create_run(
            workflow_name="graph.export",
            payload={"project_id": project_id},
            project_id=project_id,
            external_run_id=result.get("workflow_id") or result.get("run_id"),
            created_by_user_id=cast("str | None", claims.get("sub")) if claims else None,
        )
        await db.commit()
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Failed to record workflow run: {exc}") from exc
    return {"status": "queued", "result": result}


@app.post("/api/v1/workflows/graph-diff")
async def trigger_graph_diff(
    project_id: str,
    graph_id: str,
    from_version: int,
    to_version: int,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Trigger graph diff workflow in Temporal."""
    ensure_write_permission(claims, action="graph_diff")
    service = TemporalService()
    result = await service.start_workflow(
        "GraphDiffWorkflow",
        project_id=project_id,
        graph_id=graph_id,
        from_version=from_version,
        to_version=to_version,
    )
    try:
        from tracertm.repositories.workflow_run_repository import WorkflowRunRepository

        repo = WorkflowRunRepository(db)
        await repo.create_run(
            workflow_name="graph.diff",
            payload={
                "project_id": project_id,
                "graph_id": graph_id,
                "from_version": from_version,
                "to_version": to_version,
            },
            project_id=project_id,
            graph_id=graph_id,
            external_run_id=result.get("workflow_id") or result.get("run_id"),
            created_by_user_id=cast("str | None", claims.get("sub")) if claims else None,
        )
        await db.commit()
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Failed to record workflow run: {exc}") from exc
    return {"status": "queued", "result": result}


@app.post("/api/v1/workflows/integrations-sync")
async def trigger_integrations_sync(
    limit: int = 50,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Trigger integration sync workflow in Temporal."""
    ensure_write_permission(claims, action="integrations_sync")
    service = TemporalService()
    result = await service.start_workflow(
        "IntegrationSyncWorkflow",
        limit=limit,
    )
    try:
        from tracertm.repositories.workflow_run_repository import WorkflowRunRepository

        repo = WorkflowRunRepository(db)
        await repo.create_run(
            workflow_name="integrations.sync",
            payload={"limit": limit},
            project_id=None,
            external_run_id=result.get("workflow_id") or result.get("run_id"),
            created_by_user_id=cast("str | None", claims.get("sub")) if claims else None,
        )
        await db.commit()
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Failed to record workflow run: {exc}") from exc
    return {"status": "queued", "result": result}


@app.post("/api/v1/workflows/integrations-retry")
async def trigger_integrations_retry(
    limit: int = 50,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Trigger integration retry workflow in Temporal."""
    ensure_write_permission(claims, action="integrations_retry")
    service = TemporalService()
    result = await service.start_workflow(
        "IntegrationRetryWorkflow",
        limit=limit,
    )
    try:
        from tracertm.repositories.workflow_run_repository import WorkflowRunRepository

        repo = WorkflowRunRepository(db)
        await repo.create_run(
            workflow_name="integrations.retry",
            payload={"limit": limit},
            project_id=None,
            external_run_id=result.get("workflow_id") or result.get("run_id"),
            created_by_user_id=cast("str | None", claims.get("sub")) if claims else None,
        )
        await db.commit()
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Failed to record workflow run: {exc}") from exc
    return {"status": "queued", "result": result}


@app.get("/api/v1/temporal/summary")
async def get_temporal_summary(
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    workflow_limit: int = 100,
    schedule_limit: int = 200,
):
    """Get Temporal health and summary metrics for dashboards."""
    check_permissions(claims=claims, action="temporal_summary")
    service = TemporalService()
    try:
        return await service.get_summary(
            workflow_limit=workflow_limit,
            schedule_limit=schedule_limit,
        )
    except Exception as exc:
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@app.get("/api/v1/projects/{project_id}/workflows/runs")
async def list_workflow_runs(
    project_id: str,
    status: str | None = None,
    workflow_name: str | None = None,
    limit: int = 100,
    offset: int = 0,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List workflow runs for a project."""
    ensure_project_access(project_id, claims)
    from tracertm.repositories.workflow_run_repository import WorkflowRunRepository

    repo = WorkflowRunRepository(db)
    runs_result = await repo.list_runs(
        project_id=project_id,
        status=status,
        workflow_name=workflow_name,
        limit=limit,
        offset=offset,
    )
    runs = cast("list[Any]", runs_result)
    return {
        "runs": [
            {
                "id": r.id,
                "project_id": r.project_id,
                "graph_id": r.graph_id,
                "workflow_name": r.workflow_name,
                "status": r.status,
                "external_run_id": r.external_run_id,
                "payload": r.payload,
                "result": r.result,
                "error_message": r.error_message,
                "created_by_user_id": r.created_by_user_id,
                "started_at": r.started_at.isoformat() if r.started_at else None,
                "completed_at": r.completed_at.isoformat() if r.completed_at else None,
                "created_at": r.created_at.isoformat() if r.created_at else None,
                "updated_at": r.updated_at.isoformat() if r.updated_at else None,
            }
            for r in runs
        ],
        "total": len(runs),
    }


@app.post("/api/v1/projects/{project_id}/workflows/schedules/bootstrap")
async def bootstrap_workflow_schedules(
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create default Temporal schedules for graph snapshots and integration retries."""
    ensure_project_access(project_id, claims)
    from sqlalchemy import select

    from tracertm.models.graph import Graph
    from tracertm.repositories.workflow_schedule_repository import WorkflowScheduleRepository

    service = TemporalService()
    repo = WorkflowScheduleRepository(db)

    created: list[dict[str, Any]] = []
    skipped: list[dict[str, Any]] = []
    errors: list[dict[str, Any]] = []

    graph_result = await db.execute(
        select(Graph.id).where(
            Graph.project_id == project_id,
            Graph.graph_type == "default",
        ),
    )
    graph_id = graph_result.scalar_one_or_none()
    if not graph_id:
        errors.append({"message": "Default graph not found for project"})
        return {"created": created, "skipped": skipped, "errors": errors}

    timezone = os.getenv("TEMPORAL_SCHEDULE_TIMEZONE", "UTC")
    snapshot_cron = os.getenv("TEMPORAL_SCHEDULE_GRAPH_SNAPSHOT_CRON", "0 2 * * *")
    retry_interval = int(os.getenv("TEMPORAL_SCHEDULE_INTEGRATION_RETRY_INTERVAL_SECONDS", "900"))
    retry_limit = int(os.getenv("TEMPORAL_SCHEDULE_INTEGRATION_RETRY_LIMIT", "50"))
    created_by_obj = claims.get("sub")
    created_by = cast("str", created_by_obj) if created_by_obj else "" if claims else None

    schedules: list[dict[str, Any]] = [
        {
            "schedule_id": f"project-{project_id}-graph-snapshot",
            "workflow_name": "GraphSnapshotWorkflow",
            "schedule_type": "cron",
            "cron_expressions": [snapshot_cron],
            "args": [project_id, graph_id, created_by, "Automated snapshot"],
            "description": "Automated graph snapshot",
        },
        {
            "schedule_id": f"project-{project_id}-integration-retry",
            "workflow_name": "IntegrationRetryWorkflow",
            "schedule_type": "interval",
            "interval_seconds": retry_interval,
            "args": [retry_limit],
            "description": "Automated integration retries",
        },
    ]

    for schedule in schedules:
        schedule_dict = schedule
        existing = await repo.get_by_schedule_id(cast("str", schedule_dict["schedule_id"]))
        if existing:
            skipped.append({
                "schedule_id": schedule_dict["schedule_id"],
                "reason": "already tracked",
            })
            continue
        try:
            await service.create_schedule(
                schedule_id=cast("str", schedule_dict["schedule_id"]),
                workflow_name=cast("str", schedule_dict["workflow_name"]),
                args=cast("list[Any]", schedule_dict["args"]),
                cron_expressions=cast("list[str] | None", schedule_dict.get("cron_expressions")),
                interval_seconds=cast("int | None", schedule_dict.get("interval_seconds")),
                timezone=timezone,
            )
            await repo.create_schedule(
                schedule_id=cast("str", schedule_dict["schedule_id"]),
                workflow_name=cast("str", schedule_dict["workflow_name"]),
                schedule_type=cast("str", schedule_dict["schedule_type"]),
                schedule_spec={
                    "cron_expressions": schedule_dict.get("cron_expressions"),
                    "interval_seconds": schedule_dict.get("interval_seconds"),
                    "timezone": timezone,
                },
                project_id=project_id,
                task_queue=service.settings.task_queue if service.settings else None,
                created_by_user_id=created_by,
                description=cast("str | None", schedule_dict.get("description")),
            )
            created.append({
                "schedule_id": schedule["schedule_id"],
                "workflow_name": schedule["workflow_name"],
            })
        except (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError) as exc:
            errors.append({
                "schedule_id": schedule["schedule_id"],
                "message": str(exc),
            })

    if created:
        await db.commit()
    return {"created": created, "skipped": skipped, "errors": errors}


@app.get("/api/v1/projects/{project_id}/workflows/schedules")
async def list_workflow_schedules(
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    limit: int = 100,
    offset: int = 0,
    db: AsyncSession = Depends(get_db),
):
    """List Temporal schedules scoped to a project."""
    ensure_project_access(project_id, claims)
    from tracertm.repositories.workflow_schedule_repository import WorkflowScheduleRepository

    repo = WorkflowScheduleRepository(db)
    schedules = await repo.list_schedules(project_id=project_id, limit=limit, offset=offset)
    return {
        "schedules": [
            {
                "id": s.id,
                "project_id": s.project_id,
                "schedule_id": s.schedule_id,
                "workflow_name": s.workflow_name,
                "schedule_type": s.schedule_type,
                "schedule_spec": s.schedule_spec,
                "task_queue": s.task_queue,
                "status": s.status,
                "created_by_user_id": s.created_by_user_id,
                "last_run_at": s.last_run_at.isoformat() if s.last_run_at else None,
                "description": s.description,
                "created_at": s.created_at.isoformat() if s.created_at else None,
                "updated_at": s.updated_at.isoformat() if s.updated_at else None,
            }
            for s in schedules
        ],
        "total": len(schedules),
    }


@app.delete("/api/v1/projects/{project_id}/workflows/schedules/{cron_id}")
async def delete_workflow_schedule(
    project_id: str,
    cron_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Delete a Temporal schedule."""
    ensure_project_access(project_id, claims)
    from tracertm.repositories.workflow_schedule_repository import WorkflowScheduleRepository

    repo = WorkflowScheduleRepository(db)
    schedule = await repo.get_by_schedule_id(cron_id)
    if schedule and schedule.project_id != project_id:
        raise HTTPException(status_code=403, detail="Schedule does not belong to this project")
    if schedule is None:
        raise HTTPException(status_code=404, detail="Schedule not found")

    service = TemporalService()
    try:
        await service.delete_schedule(cron_id)
    except Exception as exc:
        raise HTTPException(status_code=500, detail=str(exc)) from exc

    deleted = await repo.delete_by_schedule_id(cron_id)
    await db.commit()
    return {"deleted": deleted > 0, "schedule_id": cron_id}


# Projects endpoints
async def _ensure_default_account_for_user(db: AsyncSession, user_id: str) -> str:
    """Ensure user has at least one account (for RLS). Create default personal account if none. Returns account id."""
    import hashlib

    from tracertm.models.account_user import AccountRole
    from tracertm.repositories.account_repository import AccountRepository

    account_repo = AccountRepository(db)
    accounts = await account_repo.list_by_user(user_id)
    if accounts:
        return accounts[0].id
    slug = "personal-" + hashlib.md5(user_id.encode(), usedforsecurity=False).hexdigest()[:12]
    account = await account_repo.create(
        name="My Workspace",
        slug=slug,
        account_type="personal",
    )
    await account_repo.add_user(account.id, user_id, AccountRole.OWNER)
    await db.commit()
    return account.id


@app.get("/api/v1/projects")
async def list_projects(
    skip: int = 0,
    limit: int = 100,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
    cache: CacheService = Depends(get_cache_service),
):
    """List all projects (cached for 10 minutes)."""
    from tracertm.repositories.project_repository import ProjectRepository

    user_id = claims.get("sub") if isinstance(claims, dict) else None
    if user_id and isinstance(user_id, str):
        await _ensure_default_account_for_user(db, user_id)

    # Generate cache key (per user so RLS-filtered list is not shared)
    cache_key = cache._generate_key("projects", user_id=user_id or "", skip=skip, limit=limit)

    # Try cache first
    cached = await cache.get(cache_key)
    if cached is not None:
        return cached

    # Fetch from database
    repo = ProjectRepository(db)
    projects = await repo.get_all()

    result = {
        "total": len(projects),
        "projects": [
            {
                "id": str(project.id),
                "name": project.name,
                "description": (project.metadata.get("description") if isinstance(project.metadata, dict) else None)
                if hasattr(project, "metadata") and project.metadata
                else None,
                "metadata": project.metadata if hasattr(project, "metadata") else {},
                "created_at": project.created_at.isoformat()
                if hasattr(project, "created_at") and project.created_at
                else None,
            }
            for project in projects[skip : skip + limit]
        ],
    }

    # Cache the result
    await cache.set(cache_key, result, cache_type="projects")

    return result


@app.get("/api/v1/projects/{project_id}")
async def get_project(
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
    cache: Annotated[CacheService, Depends(get_cache_service)],
):
    """Get a specific project (cached for 5 minutes)."""
    ensure_project_access(project_id, claims)

    # Generate cache key
    cache_key = cache._generate_key("project", project_id=project_id)

    # Try cache first
    cached = await cache.get(cache_key)
    if cached is not None:
        return cached

    from tracertm.repositories.project_repository import ProjectRepository

    repo = ProjectRepository(db)
    project = await repo.get_by_id(project_id)

    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    # Get metadata from project_metadata or metadata alias
    project_metadata = getattr(project, "project_metadata", None) or getattr(project, "metadata", None) or {}
    description = getattr(project, "description", None)
    if not description and isinstance(project_metadata, dict):
        description = project_metadata.get("description")

    result = {
        "id": str(project.id),
        "name": project.name,
        "description": description,
        "metadata": project_metadata,
        "created_at": project.created_at.isoformat() if hasattr(project, "created_at") and project.created_at else None,
        "updated_at": project.updated_at.isoformat() if hasattr(project, "updated_at") and project.updated_at else None,
    }

    # Cache the result
    await cache.set(cache_key, result, cache_type="project")

    return result


class CreateProjectRequest(BaseModel):
    """Request model for create project endpoint."""

    name: str
    description: str | None = None
    metadata: dict[str, Any] | None = None


@app.post("/api/v1/projects")
async def create_project(
    request: CreateProjectRequest,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
    cache: Annotated[CacheService, Depends(get_cache_service)],
):
    """Create a new project."""
    ensure_write_permission(claims, action="create_project")
    from tracertm.repositories.project_repository import ProjectRepository

    user_id = claims.get("sub") if isinstance(claims, dict) else None
    account_id = None
    if user_id and isinstance(user_id, str):
        account_id = await _ensure_default_account_for_user(db, user_id)

    repo = ProjectRepository(db)
    try:
        project = await repo.create(
            name=request.name,
            description=request.description,
            metadata=request.metadata,
            account_id=account_id,
        )
        await db.commit()

        # Invalidate projects list cache
        await cache.clear_prefix("projects")

        return {
            "id": str(project.id),
            "name": project.name,
            "description": project.description,
            "metadata": project.metadata,
        }
    except Exception as exc:
        await db.rollback()
        logger.exception("Error creating project")
        raise HTTPException(status_code=500, detail=str(exc)) from exc


class UpdateProjectRequest(BaseModel):
    """Request model for update project endpoint."""

    name: str | None = None
    description: str | None = None
    metadata: dict[str, Any] | None = None


@app.put("/api/v1/projects/{project_id}")
async def update_project(
    project_id: str,
    request: UpdateProjectRequest,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Update a project."""
    ensure_project_access(project_id, claims)
    ensure_write_permission(claims, action="update_project")
    from tracertm.repositories.project_repository import ProjectRepository

    repo = ProjectRepository(db)
    project = await repo.update(
        project_id=project_id,
        name=request.name,
        description=request.description,
        metadata=request.metadata,
    )

    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    return {
        "id": str(project.id),
        "name": project.name,
        "description": project.description,
        "metadata": project.metadata,
    }


@app.delete("/api/v1/projects/{project_id}")
async def delete_project(
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Delete a project."""
    ensure_project_access(project_id, claims)
    ensure_write_permission(claims, action="delete_project")
    from sqlalchemy import delete

    from tracertm.models.item import Item
    from tracertm.models.project import Project

    repo = project_repository.ProjectRepository(db)
    project = await _maybe_await(repo.get_by_id(project_id))

    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    # Delete all items and links for this project (cascade delete)
    link_repo = link_repository.LinkRepository(db)
    item_repo = item_repository.ItemRepository(db)

    # Get all links and items for the project
    links_result = await _maybe_await(link_repo.get_by_project(project_id))
    links = cast("list[Any]", links_result)
    await _maybe_await(item_repo.list_all(project_id))

    # Delete links
    for link in links:
        await _maybe_await(link_repo.delete(str(link.id)))

    # Delete items (this should cascade delete their links too)
    await _maybe_await(db.execute(delete(Item).where(Item.project_id == project_id)))

    # Delete project
    await _maybe_await(db.execute(delete(Project).where(Project.id == project_id)))
    await _maybe_await(db.commit())

    return {"success": True, "message": "Project deleted successfully"}


# Export/Import endpoints
@app.get("/api/v1/projects/{project_id}/export")
async def export_project(
    project_id: str,
    format: str = "json",
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Export project data to various formats. Use format=full for canonical JSON (project+items+links)."""
    ensure_project_access(project_id, claims)

    if format == "full":
        from tracertm.services.export_service import ExportService

        export_service = ExportService(db)
        json_str = await export_service.export_to_json(project_id)
        import json

        return json.loads(json_str)
    from tracertm.services.export_import_service import ExportImportService

    service = ExportImportService(db)
    result: dict[str, Any]
    if format == "json":
        result = await service.export_to_json(project_id)
    elif format == "csv":
        result = await service.export_to_csv(project_id)
    elif format == "markdown":
        result = await service.export_to_markdown(project_id)
    else:
        raise HTTPException(
            status_code=400,
            detail=f"Unsupported format: {format}. Supported formats: json, csv, markdown, full",
        )
    if "error" in result:
        raise HTTPException(status_code=404, detail=result["error"])
    return result


class ImportRequest(BaseModel):
    """Request model for import endpoint."""

    format: str
    data: str


@app.post("/api/v1/projects/{project_id}/import")
async def import_project(
    project_id: str,
    request: ImportRequest,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Import project data into an existing project (items only for json/csv)."""
    ensure_project_access(project_id, claims)
    ensure_write_permission(claims, action="import_project")
    from tracertm.services.export_import_service import ExportImportService

    service = ExportImportService(db)
    if request.format == "json":
        result = await service.import_from_json(project_id, request.data)
    elif request.format == "csv":
        result = await service.import_from_csv(project_id, request.data)
    else:
        raise HTTPException(
            status_code=400,
            detail=f"Unsupported format: {request.format}. Supported formats: json, csv",
        )

    if "error" in result:
        raise HTTPException(status_code=400, detail=result["error"])

    return result


@app.post("/api/v1/import")
async def import_full_project(
    body: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Import a full project from canonical JSON (project + items + links). Creates new project and resolves item ids for links."""
    ensure_write_permission(claims, action="import_project")
    import json

    from tracertm.services.import_service import ImportService

    service = ImportService(db)
    try:
        json_str = json.dumps(body) if isinstance(body, dict) else body
    except TypeError:
        raise HTTPException(status_code=400, detail="Request body must be JSON object (canonical format)")
    result = await service.import_from_json(json_str)
    await db.commit()
    return result


# --- Execution (QA Integration) endpoints ---
@app.post("/api/v1/projects/{project_id}/executions")
async def create_execution(
    project_id: str,
    body: ExecutionCreate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create a new execution (status=pending)."""
    ensure_project_access(project_id, claims)
    from tracertm.services.execution import ExecutionService

    service = ExecutionService(db)
    execution_obj = await service.create(
        project_id=project_id,
        execution_type=body.execution_type,
        trigger_source=body.trigger_source,
        trigger_ref=body.trigger_ref,
        test_run_id=body.test_run_id,
        item_id=body.item_id,
        config=body.config,
    )
    execution = cast("Any", execution_obj)
    await db.commit()
    from tracertm.schemas.execution import ExecutionResponse

    return ExecutionResponse(
        id=execution.id,
        project_id=execution.project_id,
        test_run_id=execution.test_run_id,
        item_id=execution.item_id,
        execution_type=execution.execution_type,
        trigger_source=execution.trigger_source,
        trigger_ref=execution.trigger_ref,
        status=execution.status,
        container_id=execution.container_id,
        container_image=execution.container_image,
        config=execution.config,
        started_at=execution.started_at,
        completed_at=execution.completed_at,
        duration_ms=execution.duration_ms,
        exit_code=execution.exit_code,
        error_message=execution.error_message,
        output_summary=execution.output_summary,
        created_at=execution.created_at,
        updated_at=execution.updated_at,
        artifact_count=0,
    )


@app.get("/api/v1/projects/{project_id}/executions")
async def list_executions(
    project_id: str,
    status: str | None = None,
    execution_type: str | None = None,
    limit: int = 100,
    offset: int = 0,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List executions for a project."""
    ensure_project_access(project_id, claims)
    from tracertm.schemas.execution import ExecutionListResponse, ExecutionResponse
    from tracertm.services.execution import ExecutionService

    service = ExecutionService(db)
    executions_result = await service.list_by_project(
        project_id,
        status=status,
        execution_type=execution_type,
        limit=limit,
        offset=offset,
    )
    executions = cast("list[Any]", executions_result)
    return ExecutionListResponse(
        executions=[
            ExecutionResponse(
                id=e.id,
                project_id=e.project_id,
                test_run_id=e.test_run_id,
                item_id=e.item_id,
                execution_type=e.execution_type,
                trigger_source=e.trigger_source,
                trigger_ref=e.trigger_ref,
                status=e.status,
                container_id=e.container_id,
                container_image=e.container_image,
                config=e.config,
                started_at=e.started_at,
                completed_at=e.completed_at,
                duration_ms=e.duration_ms,
                exit_code=e.exit_code,
                error_message=e.error_message,
                output_summary=e.output_summary,
                created_at=e.created_at,
                updated_at=e.updated_at,
                artifact_count=len(e.artifacts) if hasattr(e, "artifacts") else 0,
            )
            for e in executions
        ],
        total=len(executions),
    )


@app.get("/api/v1/projects/{project_id}/executions/{execution_id}")
async def get_execution(
    project_id: str,
    execution_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get a single execution by ID."""
    ensure_project_access(project_id, claims)
    from tracertm.schemas.execution import ExecutionResponse
    from tracertm.services.execution import ExecutionService

    service = ExecutionService(db)
    execution_obj = await service.get(execution_id)
    execution = cast("Any", execution_obj)
    if not execution or execution.project_id != project_id:
        raise HTTPException(status_code=404, detail="Execution not found")
    artifacts = await service.list_artifacts(execution_id)
    artifacts = cast("Any", artifacts)
    return ExecutionResponse(
        id=execution.id,
        project_id=execution.project_id,
        test_run_id=execution.test_run_id,
        item_id=execution.item_id,
        execution_type=execution.execution_type,
        trigger_source=execution.trigger_source,
        trigger_ref=execution.trigger_ref,
        status=execution.status,
        container_id=execution.container_id,
        container_image=execution.container_image,
        config=execution.config,
        started_at=execution.started_at,
        completed_at=execution.completed_at,
        duration_ms=execution.duration_ms,
        exit_code=execution.exit_code,
        error_message=execution.error_message,
        output_summary=execution.output_summary,
        created_at=execution.created_at,
        updated_at=execution.updated_at,
        artifact_count=len(artifacts),
    )


@app.post("/api/v1/projects/{project_id}/executions/{execution_id}/start")
async def start_execution(
    project_id: str,
    execution_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Start execution (create container, set status=running)."""
    ensure_project_access(project_id, claims)
    from tracertm.services.execution import ExecutionService

    service = ExecutionService(db)
    execution_obj = await service.get(execution_id)
    execution = cast("Any", execution_obj)
    if not execution or execution.project_id != project_id:
        raise HTTPException(status_code=404, detail="Execution not found")
    if execution.status != "pending":
        raise HTTPException(status_code=400, detail=f"Execution already {execution.status}")
    ok = await service.start(execution_id)
    await db.commit()
    return {"started": ok, "execution_id": execution_id}


@app.post("/api/v1/projects/{project_id}/executions/{execution_id}/complete")
async def complete_execution(
    project_id: str,
    execution_id: str,
    body: ExecutionComplete,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Complete execution (stop container, set status and duration)."""
    ensure_project_access(project_id, claims)
    from tracertm.services.execution import ExecutionService

    service = ExecutionService(db)
    execution_obj = await service.get(execution_id)
    execution = cast("Any", execution_obj)
    if not execution or execution.project_id != project_id:
        raise HTTPException(status_code=404, detail="Execution not found")
    await service.complete(
        execution_id,
        exit_code=body.exit_code or (0 if body.status == "passed" else 1),
        error_message=body.error_message,
        output_summary=body.output_summary,
    )
    await db.commit()
    return {"completed": True, "execution_id": execution_id}


@app.get("/api/v1/projects/{project_id}/executions/{execution_id}/artifacts")
async def list_execution_artifacts(
    project_id: str,
    execution_id: str,
    artifact_type: str | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List artifacts for an execution."""
    ensure_project_access(project_id, claims)
    from tracertm.schemas.execution import ExecutionArtifactListResponse, ExecutionArtifactResponse
    from tracertm.services.execution import ExecutionService

    service = ExecutionService(db)
    execution_obj = await service.get(execution_id)
    execution = cast("Any", execution_obj)
    if not execution or execution.project_id != project_id:
        raise HTTPException(status_code=404, detail="Execution not found")
    artifacts = await service.list_artifacts(execution_id, artifact_type=artifact_type)
    artifacts = cast("Any", artifacts)
    return ExecutionArtifactListResponse(
        artifacts=[
            ExecutionArtifactResponse(
                id=a.id,
                execution_id=a.execution_id,
                item_id=a.item_id,
                artifact_type=a.artifact_type,
                file_path=a.file_path,
                thumbnail_path=a.thumbnail_path,
                file_size=a.file_size,
                mime_type=a.mime_type,
                metadata=getattr(a, "artifact_metadata", None) or a.artifact_metadata,
                captured_at=a.captured_at,
                created_at=a.created_at,
            )
            for a in artifacts
        ],
        total=len(artifacts),
    )


@app.get("/api/v1/projects/{project_id}/execution-config")
async def get_execution_config(
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get execution environment config for project."""
    ensure_project_access(project_id, claims)
    from tracertm.schemas.execution import ExecutionEnvironmentConfigResponse
    from tracertm.services.execution import ExecutionService

    service = ExecutionService(db)
    config = await service.get_config(project_id)
    if not config:
        raise HTTPException(status_code=404, detail="Config not found")
    return ExecutionEnvironmentConfigResponse.model_validate(config)


@app.put("/api/v1/projects/{project_id}/execution-config")
async def upsert_execution_config(
    project_id: str,
    body: ExecutionEnvironmentConfigUpdate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create or update execution environment config for project."""
    ensure_project_access(project_id, claims)
    from tracertm.schemas.execution import ExecutionEnvironmentConfigResponse
    from tracertm.services.execution import ExecutionService

    service = ExecutionService(db)
    payload = body.model_dump(exclude_unset=True)
    config = await service.upsert_config(project_id, **payload)
    await db.commit()
    return ExecutionEnvironmentConfigResponse.model_validate(config)


@app.get("/api/v1/projects/{project_id}/executions/{execution_id}/artifacts/{artifact_id}/download")
async def download_artifact(
    project_id: str,
    execution_id: str,
    artifact_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Download an artifact file."""
    from fastapi.responses import FileResponse

    from tracertm.services.execution import ExecutionService

    ensure_project_access(project_id, claims)
    service = ExecutionService(db)
    execution_obj = await service.get(execution_id)
    execution = cast("Any", execution_obj)
    if not execution or execution.project_id != project_id:
        raise HTTPException(status_code=404, detail="Execution not found")

    artifact = await service.list_artifacts(execution_id)
    artifact = cast("Any", artifact)
    artifact_obj = next((a for a in artifact if a.id == artifact_id), None)
    if not artifact_obj:
        raise HTTPException(status_code=404, detail="Artifact not found")

    path_str = artifact_obj.file_path
    if not await asyncio.to_thread(_path_exists_str, path_str):
        raise HTTPException(status_code=404, detail="Artifact file not found")

    filename = await asyncio.to_thread(_path_name_str, path_str)
    return FileResponse(
        path_str,
        media_type=artifact_obj.mime_type or "application/octet-stream",
        filename=filename,
    )


# --- Codex Agent endpoints ---
@app.post("/api/v1/projects/{project_id}/codex/review-image")
async def codex_review_image(
    project_id: str,
    body: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Have Codex review an image artifact."""
    ensure_project_access(project_id, claims)
    from tracertm.services.agents import CodexAgentService
    from tracertm.services.execution import ExecutionService

    execution_service = ExecutionService(db)
    codex_service = CodexAgentService(db, execution_service)

    artifact_id = body.get("artifact_id")
    prompt = body.get("prompt", "Review this image and provide feedback")
    execution_id = body.get("execution_id")

    if not artifact_id:
        raise HTTPException(status_code=400, detail="artifact_id required")

    interaction = await codex_service.review_image(artifact_id, prompt, project_id, execution_id=execution_id)
    await db.commit()

    from tracertm.schemas.execution import CodexAgentTaskResponse

    return CodexAgentTaskResponse(
        id=interaction.id,
        execution_id=interaction.execution_id,
        project_id=interaction.project_id,
        artifact_id=interaction.artifact_id,
        task_type=interaction.task_type,
        input_data=interaction.input_data,
        output_data=interaction.output_data,
        prompt=interaction.prompt,
        response=interaction.response,
        status=interaction.status,
        started_at=interaction.started_at,
        completed_at=interaction.completed_at,
        duration_ms=interaction.duration_ms,
        tokens_used=interaction.tokens_used,
        model_used=interaction.model_used,
        error_message=interaction.error_message,
        retry_count=interaction.retry_count,
        created_at=interaction.created_at,
    )


@app.post("/api/v1/projects/{project_id}/codex/review-video")
async def codex_review_video(
    project_id: str,
    body: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Have Codex review a video artifact."""
    ensure_project_access(project_id, claims)
    from tracertm.services.agents import CodexAgentService
    from tracertm.services.execution import ExecutionService

    execution_service = ExecutionService(db)
    codex_service = CodexAgentService(db, execution_service)

    artifact_id = body.get("artifact_id")
    prompt = body.get("prompt", "Review this video and provide feedback")
    execution_id = body.get("execution_id")
    max_frames = body.get("max_frames", 10)

    if not artifact_id:
        raise HTTPException(status_code=400, detail="artifact_id required")

    interaction = await codex_service.review_video(
        artifact_id,
        prompt,
        project_id,
        execution_id=execution_id,
        max_frames=max_frames,
    )
    await db.commit()

    from tracertm.schemas.execution import CodexAgentTaskResponse

    return CodexAgentTaskResponse(
        id=interaction.id,
        execution_id=interaction.execution_id,
        project_id=interaction.project_id,
        artifact_id=interaction.artifact_id,
        task_type=interaction.task_type,
        input_data=interaction.input_data,
        output_data=interaction.output_data,
        prompt=interaction.prompt,
        response=interaction.response,
        status=interaction.status,
        started_at=interaction.started_at,
        completed_at=interaction.completed_at,
        duration_ms=interaction.duration_ms,
        tokens_used=interaction.tokens_used,
        model_used=interaction.model_used,
        error_message=interaction.error_message,
        retry_count=interaction.retry_count,
        created_at=interaction.created_at,
    )


@app.get("/api/v1/projects/{project_id}/codex/interactions")
async def list_codex_interactions(
    project_id: str,
    limit: int = 20,
    offset: int = 0,
    status: str | None = None,
    task_type: str | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List Codex agent interactions for a project."""
    ensure_project_access(project_id, claims)
    from sqlalchemy import select

    from tracertm.models.codex_agent import CodexAgentInteraction
    from tracertm.schemas.execution import CodexAgentTaskListResponse, CodexAgentTaskResponse

    q = select(CodexAgentInteraction).where(CodexAgentInteraction.project_id == project_id)
    if status:
        q = q.where(CodexAgentInteraction.status == status)
    if task_type:
        q = q.where(CodexAgentInteraction.task_type == task_type)
    q = q.order_by(CodexAgentInteraction.created_at.desc()).offset(offset).limit(limit)

    result = await db.execute(q)
    interactions = list(result.scalars().all())

    return CodexAgentTaskListResponse(
        tasks=[
            CodexAgentTaskResponse(
                id=i.id,
                execution_id=i.execution_id,
                project_id=i.project_id,
                artifact_id=i.artifact_id,
                task_type=i.task_type,
                input_data=i.input_data,
                output_data=i.output_data,
                prompt=i.prompt,
                response=i.response,
                status=i.status,
                started_at=i.started_at,
                completed_at=i.completed_at,
                duration_ms=i.duration_ms,
                tokens_used=i.tokens_used,
                model_used=i.model_used,
                error_message=i.error_message,
                retry_count=i.retry_count,
                created_at=i.created_at,
            )
            for i in interactions
        ],
        total=len(interactions),
    )


@app.get("/api/v1/projects/{project_id}/codex/auth-status")
async def codex_auth_status(
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Check Codex CLI authentication status."""
    ensure_project_access(project_id, claims)
    from tracertm.services.agents import CodexAgentService
    from tracertm.services.execution import ExecutionService

    execution_service = ExecutionService(db)
    codex_service = CodexAgentService(db, execution_service)

    available, version = await codex_service.check_availability()
    authenticated, status = await codex_service.check_auth_status()

    return {
        "available": available,
        "version": version if available else None,
        "authenticated": authenticated,
        "status": status,
    }


# Sync endpoints
@app.get("/api/v1/projects/{project_id}/sync/status")
async def get_sync_status(
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get sync status for a project."""
    ensure_project_access(project_id, claims)
    from tracertm.services.sync_service import SyncService

    SyncService(db)
    # In a real implementation, this would check actual sync status
    # For now, return a mock status
    return {
        "project_id": project_id,
        "status": "synced",
        "last_synced": None,
        "pending_changes": 0,
    }


@app.post("/api/v1/projects/{project_id}/sync")
async def sync_project(
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Execute sync for a project."""
    ensure_project_access(project_id, claims)
    ensure_write_permission(claims, action="sync_project")
    from tracertm.services.sync_service import SyncService

    service = SyncService(db)
    result = await service.sync()

    return {
        "project_id": project_id,
        "status": "synced",
        "result": result,
    }


# Advanced Search endpoint
class AdvancedSearchRequest(BaseModel):
    """Request model for advanced search endpoint."""

    query: str | None = None
    filters: dict[str, Any] | None = None


@app.post("/api/v1/projects/{project_id}/search/advanced")
async def advanced_search(
    project_id: str,
    request: AdvancedSearchRequest,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Advanced search with filters and query."""
    ensure_project_access(project_id, claims)
    from tracertm.services.search_service import SearchService

    service = SearchService(db)
    results = await service.search(_query=request.query, _filters=request.filters or {})

    return {
        "project_id": project_id,
        "query": request.query,
        "filters": request.filters,
        "results": results,
        "total": len(results),
    }


# Graph neighbors endpoint
@app.get("/api/v1/projects/{project_id}/graph/neighbors")
async def get_graph_neighbors(
    project_id: str,
    item_id: str,
    direction: str = "both",  # "in", "out", "both"
    graph_id: str | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Get neighbors of an item in the graph."""
    ensure_project_access(project_id, claims)
    from tracertm.repositories.link_repository import LinkRepository

    repo = LinkRepository(db)

    neighbors: list[dict[str, Any]] = []
    if direction in {"out", "both"}:
        out_links = await repo.get_by_source(item_id, graph_id=graph_id)
        neighbors.extend(
            {
                "id": str(link.id),
                "item_id": str(link.target_item_id),
                "link_type": link.link_type,
                "graph_id": link.graph_id,
                "direction": "out",
            }
            for link in out_links
        )

    if direction in {"in", "both"}:
        in_links = await repo.get_by_target(item_id, graph_id=graph_id)
        neighbors.extend(
            {
                "id": str(link.id),
                "item_id": str(link.source_item_id),
                "link_type": link.link_type,
                "graph_id": link.graph_id,
                "direction": "in",
            }
            for link in in_links
        )

    return {
        "project_id": project_id,
        "item_id": item_id,
        "graph_id": graph_id,
        "direction": direction,
        "neighbors": neighbors,
        "total": len(neighbors),
    }


# Graph endpoints
@app.get("/api/v1/projects/{project_id}/graphs")
async def list_graphs(
    project_id: str,
    graph_type: str | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
    cache: CacheService = Depends(get_cache_service),
):
    """List graphs for a project (cached for 5 minutes)."""
    ensure_project_access(project_id, claims)

    # Generate cache key
    cache_key = cache._generate_key("graph", project_id=project_id, graph_type=graph_type)

    # Try cache first
    cached = await cache.get(cache_key)
    if cached is not None:
        return cached

    from sqlalchemy import select

    from tracertm.models.graph import Graph

    query = select(Graph).where(Graph.project_id == project_id)
    if graph_type:
        query = query.where(Graph.graph_type == graph_type)
    result = await db.execute(query.order_by(Graph.name))
    graphs = result.scalars().all()

    response = {
        "project_id": project_id,
        "graphs": [
            {
                "id": graph.id,
                "name": graph.name,
                "graph_type": graph.graph_type,
                "description": graph.description,
                "root_item_id": graph.root_item_id,
                "graph_version": graph.graph_version,
                "graph_rules": graph.graph_rules,
                "metadata": graph.graph_metadata,
            }
            for graph in graphs
        ],
        "total": len(graphs),
    }

    # Cache the result
    await cache.set(cache_key, response, cache_type="graph")

    return response


@app.get("/api/v1/projects/{project_id}/graph")
async def get_graph_projection(
    project_id: str,
    graph_id: str | None = None,
    graph_type: str | None = None,
    include_nodes: bool = True,
    include_links: bool = True,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
    cache: CacheService = Depends(get_cache_service),
):
    """Get a graph projection by graph_id or graph_type (cached for 10 minutes)."""
    ensure_project_access(project_id, claims)

    # Generate cache key - graph projections are expensive, cache longer
    cache_key = cache._generate_key(
        "graph_full",
        project_id=project_id,
        graph_id=graph_id,
        graph_type=graph_type,
        include_nodes=include_nodes,
        include_links=include_links,
    )

    # Try cache first
    cached = await cache.get(cache_key)
    if cached is not None:
        return cached

    from tracertm.services.graph_service import GraphService

    service = GraphService(db)
    data = await service.get_graph(
        project_id=project_id,
        graph_id=graph_id,
        graph_type=graph_type,
        include_nodes=include_nodes,
        include_links=include_links,
    )

    result = {
        "project_id": project_id,
        **data,
    }

    # Cache the result - graph projections are expensive
    await cache.set(cache_key, result, cache_type="graph_full")

    return result


@app.get("/api/v1/projects/{project_id}/graphs/{graph_id}/validate")
async def validate_graph(
    project_id: str,
    graph_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Validate a graph against edge/node rules."""
    ensure_project_access(project_id, claims)
    from tracertm.services.graph_validation_service import GraphValidationService

    service = GraphValidationService(db)
    return await service.validate_graph(project_id=project_id, graph_id=graph_id)


@app.post("/api/v1/projects/{project_id}/graphs/{graph_id}/snapshot")
async def create_graph_snapshot(
    project_id: str,
    graph_id: str,
    created_by: str | None = None,
    description: str | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Create a graph snapshot."""
    ensure_project_access(project_id, claims)
    ensure_write_permission(claims, action="create_snapshot")
    from tracertm.services.graph_snapshot_service import GraphSnapshotService

    service = GraphSnapshotService(db)
    snapshot = await service.create_snapshot(
        project_id=project_id,
        graph_id=graph_id,
        created_by=created_by,
        description=description,
    )
    return {
        "snapshot_id": snapshot.id,
        "version": snapshot.version,
        "hash": snapshot.snapshot_hash,
    }


@app.get("/api/v1/projects/{project_id}/graphs/{graph_id}/snapshot")
async def get_graph_snapshot(
    project_id: str,
    graph_id: str,
    version: int | None = None,
    db: AsyncSession = Depends(get_db),
):
    """Get a graph snapshot by version (latest if omitted)."""
    from tracertm.services.graph_snapshot_service import GraphSnapshotService

    service = GraphSnapshotService(db)
    snapshot = await service.get_snapshot(
        project_id=project_id,
        graph_id=graph_id,
        version=version,
    )
    if not snapshot:
        return {"error": "snapshot_not_found"}
    return {
        "snapshot_id": snapshot.id,
        "version": snapshot.version,
        "hash": snapshot.snapshot_hash,
        "payload": snapshot.snapshot_json,
    }


@app.get("/api/v1/projects/{project_id}/graphs/{graph_id}/diff")
async def diff_graph_snapshots(
    project_id: str,
    graph_id: str,
    from_version: int,
    to_version: int,
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Diff two graph snapshots."""
    from tracertm.services.graph_snapshot_service import GraphSnapshotService

    service = GraphSnapshotService(db)
    return await service.diff_snapshots(
        project_id=project_id,
        graph_id=graph_id,
        from_version=from_version,
        to_version=to_version,
    )


@app.get("/api/v1/projects/{project_id}/graphs/{graph_id}/report")
async def graph_report(
    project_id: str,
    graph_id: str,
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Graph QA report for coverage and missing mappings."""
    from tracertm.services.graph_report_service import GraphReportService

    service = GraphReportService(db)
    return await service.build_report(project_id=project_id, graph_id=graph_id)


# =============================================================================
# Problem Management Endpoints
# =============================================================================

from tracertm.repositories.problem_repository import ProblemRepository
from tracertm.schemas.problem import (
    PermanentFixUpdate,
    ProblemClosure,
    ProblemCreate,
    ProblemStatusTransition,
    ProblemUpdate,
    RCARequest,
    WorkaroundUpdate,
)


@app.get("/api/v1/problems")
async def list_problems(
    request: Request,
    project_id: str,
    status: str | None = None,
    priority: str | None = None,
    impact_level: str | None = None,
    category: str | None = None,
    assigned_to: str | None = None,
    skip: int = 0,
    limit: int = 100,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List problems in a project with optional filters."""
    if not (request and request.headers.get("X-Bulk-Operation") == "true"):
        enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    repo = ProblemRepository(db)
    problems = await repo.list_all(
        project_id=project_id,
        status=status,
        priority=priority,
        impact_level=impact_level,
        category=category,
        assigned_to=assigned_to,
        limit=limit,
        offset=skip,
    )

    return {
        "total": len(problems),
        "problems": [
            {
                "id": str(p.id),
                "problem_number": p.problem_number,
                "project_id": str(p.project_id),
                "title": p.title,
                "status": p.status,
                "priority": p.priority,
                "impact_level": p.impact_level,
                "category": p.category,
                "assigned_to": p.assigned_to,
                "assigned_team": p.assigned_team,
                "root_cause_identified": p.root_cause_identified,
                "workaround_available": p.workaround_available,
                "created_at": p.created_at.isoformat() if p.created_at else None,
                "updated_at": p.updated_at.isoformat() if p.updated_at else None,
            }
            for p in problems
        ],
    }


@app.get("/api/v1/problems/{problem_id}")
async def get_problem(
    request: Request,
    problem_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get a specific problem by ID."""
    enforce_rate_limit(request, claims)

    repo = ProblemRepository(db)
    problem = await repo.get_by_id(problem_id)

    if not problem:
        raise HTTPException(status_code=404, detail="Problem not found")

    return {
        "id": str(problem.id),
        "problem_number": problem.problem_number,
        "project_id": str(problem.project_id),
        "title": problem.title,
        "description": problem.description,
        "status": problem.status,
        "resolution_type": problem.resolution_type,
        "category": problem.category,
        "sub_category": problem.sub_category,
        "tags": problem.tags,
        "impact_level": problem.impact_level,
        "urgency": problem.urgency,
        "priority": problem.priority,
        "affected_systems": problem.affected_systems,
        "affected_users_estimated": problem.affected_users_estimated,
        "business_impact_description": problem.business_impact_description,
        "rca_performed": problem.rca_performed,
        "rca_method": problem.rca_method,
        "rca_notes": problem.rca_notes,
        "rca_data": problem.rca_data,
        "root_cause_identified": problem.root_cause_identified,
        "root_cause_description": problem.root_cause_description,
        "root_cause_category": problem.root_cause_category,
        "root_cause_confidence": problem.root_cause_confidence,
        "rca_completed_at": problem.rca_completed_at.isoformat() if problem.rca_completed_at else None,
        "rca_completed_by": problem.rca_completed_by,
        "workaround_available": problem.workaround_available,
        "workaround_description": problem.workaround_description,
        "workaround_effectiveness": problem.workaround_effectiveness,
        "permanent_fix_available": problem.permanent_fix_available,
        "permanent_fix_description": problem.permanent_fix_description,
        "permanent_fix_implemented_at": problem.permanent_fix_implemented_at.isoformat()
        if problem.permanent_fix_implemented_at
        else None,
        "permanent_fix_change_id": problem.permanent_fix_change_id,
        "known_error_id": problem.known_error_id,
        "knowledge_article_id": problem.knowledge_article_id,
        "assigned_to": problem.assigned_to,
        "assigned_team": problem.assigned_team,
        "owner": problem.owner,
        "closed_by": problem.closed_by,
        "closed_at": problem.closed_at.isoformat() if problem.closed_at else None,
        "closure_notes": problem.closure_notes,
        "target_resolution_date": problem.target_resolution_date.isoformat()
        if problem.target_resolution_date
        else None,
        "metadata": problem.problem_metadata,
        "version": problem.version,
        "created_at": problem.created_at.isoformat() if problem.created_at else None,
        "updated_at": problem.updated_at.isoformat() if problem.updated_at else None,
    }


@app.post("/api/v1/problems")
async def create_problem(
    request: Request,
    project_id: str,
    problem_data: ProblemCreate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create a new problem."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)
    ensure_write_permission(claims, "create")

    repo = ProblemRepository(db)
    problem = await repo.create(
        project_id=project_id,
        title=problem_data.title,
        description=problem_data.description,
        category=problem_data.category,
        sub_category=problem_data.sub_category,
        tags=problem_data.tags,
        impact_level=problem_data.impact_level.value,
        urgency=problem_data.urgency.value,
        priority=problem_data.priority.value,
        affected_systems=problem_data.affected_systems,
        affected_users_estimated=problem_data.affected_users_estimated,
        business_impact_description=problem_data.business_impact_description,
        assigned_to=problem_data.assigned_to,
        assigned_team=problem_data.assigned_team,
        owner=problem_data.owner,
        target_resolution_date=problem_data.target_resolution_date,
        metadata=problem_data.metadata,
        created_by=claims.get("sub", "system"),
    )
    await db.commit()

    return {"id": str(problem.id), "problem_number": problem.problem_number}


@app.put("/api/v1/problems/{problem_id}")
async def update_problem(
    request: Request,
    problem_id: str,
    problem_data: ProblemUpdate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Update a problem."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = ProblemRepository(db)
    problem = await repo.get_by_id(problem_id)
    if not problem:
        raise HTTPException(status_code=404, detail="Problem not found")

    updates = problem_data.model_dump(exclude_unset=True)
    # Convert enums to values
    for key in ["impact_level", "urgency", "priority"]:
        if key in updates and updates[key] is not None:
            updates[key] = updates[key].value

    if "metadata" in updates:
        updates["problem_metadata"] = updates.pop("metadata")

    problem = await repo.update(
        problem_id=problem_id,
        expected_version=problem.version,
        performed_by=claims.get("sub", "system"),
        **updates,
    )
    await db.commit()

    return {"id": str(problem.id), "version": problem.version}


@app.post("/api/v1/problems/{problem_id}/status")
async def transition_problem_status(
    request: Request,
    problem_id: str,
    transition: ProblemStatusTransition,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Transition problem to a new status."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = ProblemRepository(db)
    try:
        problem = await repo.transition_status(
            problem_id=problem_id,
            to_status=transition.to_status.value,
            reason=transition.reason,
            performed_by=transition.performed_by or claims.get("sub", "system"),
        )
        await db.commit()
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    return {"id": str(problem.id), "status": problem.status, "version": problem.version}


@app.post("/api/v1/problems/{problem_id}/rca")
async def record_problem_rca(
    request: Request,
    problem_id: str,
    rca_data: RCARequest,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Record Root Cause Analysis for a problem."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = ProblemRepository(db)
    problem = await repo.record_rca(
        problem_id=problem_id,
        rca_method=rca_data.rca_method.value,
        rca_notes=rca_data.rca_notes,
        rca_data=rca_data.rca_data,
        root_cause_identified=rca_data.root_cause_identified,
        root_cause_description=rca_data.root_cause_description,
        root_cause_category=rca_data.root_cause_category.value if rca_data.root_cause_category else None,
        root_cause_confidence=rca_data.root_cause_confidence,
        performed_by=rca_data.performed_by or claims.get("sub", "system"),
    )
    await db.commit()

    return {
        "id": str(problem.id),
        "rca_performed": problem.rca_performed,
        "root_cause_identified": problem.root_cause_identified,
    }


@app.put("/api/v1/problems/{problem_id}/workaround")
async def update_problem_workaround(
    request: Request,
    problem_id: str,
    workaround_data: WorkaroundUpdate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Update workaround information for a problem."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = ProblemRepository(db)
    problem = await repo.update_workaround(
        problem_id=problem_id,
        workaround_available=workaround_data.workaround_available,
        workaround_description=workaround_data.workaround_description,
        workaround_effectiveness=workaround_data.workaround_effectiveness,
        performed_by=claims.get("sub", "system"),
    )
    await db.commit()

    return {"id": str(problem.id), "workaround_available": problem.workaround_available}


@app.put("/api/v1/problems/{problem_id}/permanent-fix")
async def update_problem_permanent_fix(
    request: Request,
    problem_id: str,
    fix_data: PermanentFixUpdate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Update permanent fix information for a problem."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = ProblemRepository(db)
    problem = await repo.update_permanent_fix(
        problem_id=problem_id,
        permanent_fix_available=fix_data.permanent_fix_available,
        permanent_fix_description=fix_data.permanent_fix_description,
        permanent_fix_change_id=fix_data.permanent_fix_change_id,
        performed_by=claims.get("sub", "system"),
    )
    await db.commit()

    return {"id": str(problem.id), "permanent_fix_available": problem.permanent_fix_available}


@app.post("/api/v1/problems/{problem_id}/close")
async def close_problem(
    request: Request,
    problem_id: str,
    closure_data: ProblemClosure,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Close a problem."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = ProblemRepository(db)
    problem = await repo.close(
        problem_id=problem_id,
        resolution_type=closure_data.resolution_type.value,
        closure_notes=closure_data.closure_notes,
        closed_by=closure_data.closed_by or claims.get("sub", "system"),
    )
    await db.commit()

    return {"id": str(problem.id), "status": problem.status, "resolution_type": problem.resolution_type}


@app.get("/api/v1/problems/{problem_id}/activities")
async def get_problem_activities(
    request: Request,
    problem_id: str,
    limit: int = 50,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Get activity log for a problem."""
    enforce_rate_limit(request, claims)

    repo = ProblemRepository(db)
    activities = await repo.get_activities(problem_id, limit=limit)

    return {
        "problem_id": problem_id,
        "activities": [
            {
                "id": str(a.id),
                "problem_id": str(a.problem_id),
                "activity_type": a.activity_type,
                "from_value": a.from_value,
                "to_value": a.to_value,
                "description": a.description,
                "performed_by": a.performed_by,
                "metadata": a.activity_metadata,
                "created_at": a.created_at.isoformat() if a.created_at else None,
            }
            for a in activities
        ],
    }


@app.delete("/api/v1/problems/{problem_id}")
async def delete_problem(
    request: Request,
    problem_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Delete a problem (soft delete)."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "delete")

    repo = ProblemRepository(db)
    success = await repo.delete(problem_id, soft=True)
    await db.commit()

    if not success:
        raise HTTPException(status_code=404, detail="Problem not found")

    return {"deleted": True, "id": problem_id}


@app.get("/api/v1/projects/{project_id}/problems/stats")
async def get_problem_stats(
    request: Request,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get problem statistics for a project."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    repo = ProblemRepository(db)
    by_status = await repo.count_by_status(project_id)
    by_priority = await repo.count_by_priority(project_id)

    return {
        "project_id": project_id,
        "by_status": by_status,
        "by_priority": by_priority,
        "total": sum(by_status.values()),
    }


# =============================================================================
# Process Management Endpoints
# =============================================================================

from tracertm.repositories.process_repository import ProcessRepository
from tracertm.schemas.process import (
    ProcessActivation,
    ProcessCreate,
    ProcessDeprecation,
    ProcessExecutionComplete,
    ProcessExecutionCreate,
    ProcessUpdate,
    ProcessVersionCreate,
)


@app.get("/api/v1/processes")
async def list_processes(
    request: Request,
    project_id: str,
    status: str | None = None,
    category: str | None = None,
    owner: str | None = None,
    active_only: bool = False,
    skip: int = 0,
    limit: int = 100,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List processes in a project with optional filters."""
    if not (request and request.headers.get("X-Bulk-Operation") == "true"):
        enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    repo = ProcessRepository(db)
    processes = await repo.list_all(
        project_id=project_id,
        status=status,
        category=category,
        owner=owner,
        active_only=active_only,
        limit=limit,
        offset=skip,
    )

    return {
        "total": len(processes),
        "processes": [
            {
                "id": str(p.id),
                "process_number": p.process_number,
                "project_id": str(p.project_id),
                "name": p.name,
                "status": p.status,
                "category": p.category,
                "owner": p.owner,
                "version_number": p.version_number,
                "is_active_version": p.is_active_version,
                "created_at": p.created_at.isoformat() if p.created_at else None,
                "updated_at": p.updated_at.isoformat() if p.updated_at else None,
            }
            for p in processes
        ],
    }


@app.get("/api/v1/processes/{process_id}")
async def get_process(
    request: Request,
    process_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get a specific process by ID."""
    enforce_rate_limit(request, claims)

    repo = ProcessRepository(db)
    process = await repo.get_by_id(process_id)

    if not process:
        raise HTTPException(status_code=404, detail="Process not found")

    return {
        "id": str(process.id),
        "process_number": process.process_number,
        "project_id": str(process.project_id),
        "name": process.name,
        "description": process.description,
        "purpose": process.purpose,
        "status": process.status,
        "category": process.category,
        "tags": process.tags,
        "version_number": process.version_number,
        "is_active_version": process.is_active_version,
        "parent_version_id": process.parent_version_id,
        "version_notes": process.version_notes,
        "stages": process.stages,
        "swimlanes": process.swimlanes,
        "inputs": process.inputs,
        "outputs": process.outputs,
        "triggers": process.triggers,
        "exit_criteria": process.exit_criteria,
        "bpmn_xml": process.bpmn_xml,
        "bpmn_diagram_url": process.bpmn_diagram_url,
        "owner": process.owner,
        "responsible_team": process.responsible_team,
        "expected_duration_hours": process.expected_duration_hours,
        "sla_hours": process.sla_hours,
        "activated_at": process.activated_at.isoformat() if process.activated_at else None,
        "activated_by": process.activated_by,
        "deprecated_at": process.deprecated_at.isoformat() if process.deprecated_at else None,
        "deprecated_by": process.deprecated_by,
        "deprecation_reason": process.deprecation_reason,
        "related_process_ids": process.related_process_ids,
        "metadata": process.process_metadata,
        "version": process.version,
        "created_at": process.created_at.isoformat() if process.created_at else None,
        "updated_at": process.updated_at.isoformat() if process.updated_at else None,
    }


@app.post("/api/v1/processes")
async def create_process(
    request: Request,
    project_id: str,
    process_data: ProcessCreate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create a new process."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)
    ensure_write_permission(claims, "create")

    repo = ProcessRepository(db)

    # Convert Pydantic models to dicts
    stages = [s.model_dump() for s in process_data.stages] if process_data.stages else None
    swimlanes = [s.model_dump() for s in process_data.swimlanes] if process_data.swimlanes else None
    inputs = [i.model_dump() for i in process_data.inputs] if process_data.inputs else None
    outputs = [o.model_dump() for o in process_data.outputs] if process_data.outputs else None
    triggers = [t.model_dump() for t in process_data.triggers] if process_data.triggers else None

    process = await repo.create(
        project_id=project_id,
        name=process_data.name,
        description=process_data.description,
        purpose=process_data.purpose,
        category=process_data.category.value if process_data.category else None,
        tags=process_data.tags,
        stages=stages,
        swimlanes=swimlanes,
        inputs=inputs,
        outputs=outputs,
        triggers=triggers,
        exit_criteria=process_data.exit_criteria,
        bpmn_xml=process_data.bpmn_xml,
        owner=process_data.owner,
        responsible_team=process_data.responsible_team,
        expected_duration_hours=process_data.expected_duration_hours,
        sla_hours=process_data.sla_hours,
        related_process_ids=process_data.related_process_ids,
        metadata=process_data.metadata,
        _created_by=str(claims.get("sub", "system")),
    )
    await db.commit()

    return {"id": str(process.id), "process_number": process.process_number}


@app.put("/api/v1/processes/{process_id}")
async def update_process(
    request: Request,
    process_id: str,
    process_data: ProcessUpdate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Update a process."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = ProcessRepository(db)
    process = await repo.get_by_id(process_id)
    if not process:
        raise HTTPException(status_code=404, detail="Process not found")

    updates = process_data.model_dump(exclude_unset=True)

    # Convert category enum
    if "category" in updates and updates["category"] is not None:
        updates["category"] = updates["category"].value

    # Convert nested models to dicts
    for key in ["stages", "swimlanes", "inputs", "outputs", "triggers"]:
        if key in updates and updates[key] is not None:
            updates[key] = [item.model_dump() if hasattr(item, "model_dump") else item for item in updates[key]]

    if "metadata" in updates:
        updates["process_metadata"] = updates.pop("metadata")

    process = await repo.update(
        process_id=process_id,
        expected_version=process.version,
        **updates,
    )
    await db.commit()

    return {"id": str(process.id), "version": process.version}


@app.post("/api/v1/processes/{process_id}/versions")
async def create_process_version(
    request: Request,
    process_id: str,
    version_data: ProcessVersionCreate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create a new version of a process."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "create")

    repo = ProcessRepository(db)
    try:
        new_process = await repo.create_version(
            process_id=process_id,
            version_notes=version_data.version_notes,
            _created_by=str(claims.get("sub", "system")),
        )
        await db.commit()
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))

    return {
        "id": str(new_process.id),
        "process_number": new_process.process_number,
        "version_number": new_process.version_number,
        "parent_version_id": new_process.parent_version_id,
    }


@app.put("/api/v1/processes/{process_id}/activate")
async def activate_process(
    request: Request,
    process_id: str,
    activation_data: ProcessActivation,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Activate a process version."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = ProcessRepository(db)
    try:
        process = await repo.activate_version(
            process_id=process_id,
            activated_by=activation_data.activated_by or claims.get("sub", "system"),
        )
        await db.commit()
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))

    return {
        "id": str(process.id),
        "status": process.status,
        "is_active_version": process.is_active_version,
    }


@app.put("/api/v1/processes/{process_id}/deprecate")
async def deprecate_process(
    request: Request,
    process_id: str,
    deprecation_data: ProcessDeprecation,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Deprecate a process."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = ProcessRepository(db)
    try:
        process = await repo.deprecate(
            process_id=process_id,
            deprecation_reason=deprecation_data.deprecation_reason,
            deprecated_by=deprecation_data.deprecated_by or claims.get("sub", "system"),
        )
        await db.commit()
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))

    return {"id": str(process.id), "status": process.status}


@app.delete("/api/v1/processes/{process_id}")
async def delete_process(
    request: Request,
    process_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Delete a process (soft delete)."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "delete")

    repo = ProcessRepository(db)
    success = await repo.delete(process_id, soft=True)
    await db.commit()

    if not success:
        raise HTTPException(status_code=404, detail="Process not found")

    return {"deleted": True, "id": process_id}


@app.get("/api/v1/projects/{project_id}/processes/stats")
async def get_process_stats(
    request: Request,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get process statistics for a project."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    repo = ProcessRepository(db)
    by_status = await repo.count_by_status(project_id)
    by_category = await repo.count_by_category(project_id)

    return {
        "project_id": project_id,
        "by_status": by_status,
        "by_category": by_category,
        "total": sum(by_status.values()),
    }


# Process Execution endpoints


@app.post("/api/v1/processes/{process_id}/executions")
async def create_process_execution(
    request: Request,
    process_id: str,
    execution_data: ProcessExecutionCreate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Start a new execution of a process."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "create")

    repo = ProcessRepository(db)
    try:
        execution = await repo.create_execution(
            process_id=process_id,
            initiated_by=claims.get("sub", "system"),
            trigger_item_id=execution_data.trigger_item_id,
            context_data=execution_data.context_data,
        )
        await db.commit()
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))

    return {"id": str(execution.id), "execution_number": execution.execution_number}


@app.get("/api/v1/processes/{process_id}/executions")
async def list_process_executions(
    request: Request,
    process_id: str,
    status: str | None = None,
    skip: int = 0,
    limit: int = 50,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List executions for a process."""
    enforce_rate_limit(request, claims)

    repo = ProcessRepository(db)
    executions = await repo.list_executions(
        process_id=process_id,
        status=status,
        limit=limit,
        offset=skip,
    )

    return {
        "total": len(executions),
        "executions": [
            {
                "id": str(e.id),
                "process_id": str(e.process_id),
                "execution_number": e.execution_number,
                "status": e.status,
                "current_stage_id": e.current_stage_id,
                "started_at": e.started_at.isoformat() if e.started_at else None,
                "completed_at": e.completed_at.isoformat() if e.completed_at else None,
                "initiated_by": e.initiated_by,
                "created_at": e.created_at.isoformat() if e.created_at else None,
            }
            for e in executions
        ],
    }


@app.get("/api/v1/executions/{execution_id}")
async def get_execution_by_id_endpoint(
    request: Request,
    execution_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get a specific process execution."""
    enforce_rate_limit(request, claims)

    repo = ProcessRepository(db)
    execution = await repo.get_execution_by_id(execution_id)

    if not execution:
        raise HTTPException(status_code=404, detail="Execution not found")

    return {
        "id": str(execution.id),
        "process_id": str(execution.process_id),
        "execution_number": execution.execution_number,
        "status": execution.status,
        "current_stage_id": execution.current_stage_id,
        "completed_stages": execution.completed_stages,
        "started_at": execution.started_at.isoformat() if execution.started_at else None,
        "completed_at": execution.completed_at.isoformat() if execution.completed_at else None,
        "initiated_by": execution.initiated_by,
        "completed_by": execution.completed_by,
        "trigger_item_id": execution.trigger_item_id,
        "context_data": execution.context_data,
        "result_summary": execution.result_summary,
        "output_item_ids": execution.output_item_ids,
        "created_at": execution.created_at.isoformat() if execution.created_at else None,
        "updated_at": execution.updated_at.isoformat() if execution.updated_at else None,
    }


@app.post("/api/v1/executions/{execution_id}/start")
async def start_execution_endpoint(
    request: Request,
    execution_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Start a pending execution."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = ProcessRepository(db)
    try:
        execution = await repo.start_execution(execution_id)
        await db.commit()
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))

    return {"id": str(execution.id), "status": execution.status}


@app.post("/api/v1/executions/{execution_id}/advance")
async def advance_execution(
    request: Request,
    execution_id: str,
    stage_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Advance execution to next stage."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = ProcessRepository(db)
    try:
        execution = await repo.advance_execution(execution_id, stage_id)
        await db.commit()
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))

    return {
        "id": str(execution.id),
        "current_stage_id": execution.current_stage_id,
        "completed_stages": execution.completed_stages,
    }


@app.post("/api/v1/executions/{execution_id}/complete")
async def complete_execution_endpoint(
    request: Request,
    execution_id: str,
    completion_data: ProcessExecutionComplete,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Complete a process execution."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = ProcessRepository(db)
    try:
        execution = await repo.complete_execution(
            execution_id=execution_id,
            completed_by=completion_data.completed_by or claims.get("sub", "system"),
            result_summary=completion_data.result_summary,
            output_item_ids=completion_data.output_item_ids,
        )
        await db.commit()
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))

    return {"id": str(execution.id), "status": execution.status}


# =============================================================================
# Test Case Management Endpoints (Quality Engineering)
# =============================================================================

import operator

from tracertm.repositories.test_case_repository import TestCaseRepository
from tracertm.schemas.test_case import (
    TestCaseCreate,
    TestCaseDeprecation,
    TestCaseReview,
    TestCaseStatusTransition,
    TestCaseUpdate,
)


@app.get("/api/v1/test-cases")
async def list_test_cases(
    request: Request,
    project_id: str,
    status: str | None = None,
    test_type: str | None = None,
    priority: str | None = None,
    automation_status: str | None = None,
    category: str | None = None,
    assigned_to: str | None = None,
    search: str | None = None,
    skip: int = 0,
    limit: int = 100,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List test cases in a project with optional filters."""
    if not (request and request.headers.get("X-Bulk-Operation") == "true"):
        enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    repo = TestCaseRepository(db)
    test_cases = await repo.list_all(
        project_id=project_id,
        status=status,
        test_type=test_type,
        priority=priority,
        automation_status=automation_status,
        category=category,
        assigned_to=assigned_to,
        search=search,
        limit=limit,
        offset=skip,
    )

    return {
        "total": len(test_cases),
        "test_cases": [
            {
                "id": str(tc.id),
                "test_case_number": tc.test_case_number,
                "project_id": str(tc.project_id),
                "title": tc.title,
                "status": tc.status,
                "test_type": tc.test_type,
                "priority": tc.priority,
                "category": tc.category,
                "automation_status": tc.automation_status,
                "assigned_to": tc.assigned_to,
                "last_executed_at": tc.last_executed_at.isoformat() if tc.last_executed_at else None,
                "last_execution_result": tc.last_execution_result,
                "total_executions": tc.total_executions,
                "pass_count": tc.pass_count,
                "fail_count": tc.fail_count,
                "created_at": tc.created_at.isoformat() if tc.created_at else None,
                "updated_at": tc.updated_at.isoformat() if tc.updated_at else None,
            }
            for tc in test_cases
        ],
    }


@app.get("/api/v1/test-cases/{test_case_id}")
async def get_test_case(
    request: Request,
    test_case_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get a specific test case by ID."""
    enforce_rate_limit(request, claims)

    repo = TestCaseRepository(db)
    tc = await repo.get_by_id(test_case_id)

    if not tc:
        raise HTTPException(status_code=404, detail="Test case not found")

    return {
        "id": str(tc.id),
        "test_case_number": tc.test_case_number,
        "project_id": str(tc.project_id),
        "title": tc.title,
        "description": tc.description,
        "objective": tc.objective,
        "status": tc.status,
        "test_type": tc.test_type,
        "priority": tc.priority,
        "category": tc.category,
        "tags": tc.tags,
        "preconditions": tc.preconditions,
        "test_steps": tc.test_steps,
        "expected_result": tc.expected_result,
        "postconditions": tc.postconditions,
        "test_data": tc.test_data,
        "automation_status": tc.automation_status,
        "automation_script_path": tc.automation_script_path,
        "automation_framework": tc.automation_framework,
        "automation_notes": tc.automation_notes,
        "estimated_duration_minutes": tc.estimated_duration_minutes,
        "created_by": tc.created_by,
        "assigned_to": tc.assigned_to,
        "reviewed_by": tc.reviewed_by,
        "approved_by": tc.approved_by,
        "reviewed_at": tc.reviewed_at.isoformat() if tc.reviewed_at else None,
        "approved_at": tc.approved_at.isoformat() if tc.approved_at else None,
        "deprecated_at": tc.deprecated_at.isoformat() if tc.deprecated_at else None,
        "deprecation_reason": tc.deprecation_reason,
        "last_executed_at": tc.last_executed_at.isoformat() if tc.last_executed_at else None,
        "last_execution_result": tc.last_execution_result,
        "total_executions": tc.total_executions,
        "pass_count": tc.pass_count,
        "fail_count": tc.fail_count,
        "metadata": tc.test_case_metadata,
        "version": tc.version,
        "created_at": tc.created_at.isoformat() if tc.created_at else None,
        "updated_at": tc.updated_at.isoformat() if tc.updated_at else None,
    }


@app.post("/api/v1/test-cases")
async def create_test_case(
    request: Request,
    project_id: str,
    test_case_data: TestCaseCreate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create a new test case."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)
    ensure_write_permission(claims, "create")

    repo = TestCaseRepository(db)

    # Convert test steps to dicts
    test_steps = None
    if test_case_data.test_steps:
        test_steps = [step.model_dump() for step in test_case_data.test_steps]

    tc = await repo.create(
        project_id=project_id,
        title=test_case_data.title,
        description=test_case_data.description,
        objective=test_case_data.objective,
        test_type=test_case_data.test_type.value,
        priority=test_case_data.priority.value,
        category=test_case_data.category,
        tags=test_case_data.tags,
        preconditions=test_case_data.preconditions,
        test_steps=test_steps,
        expected_result=test_case_data.expected_result,
        postconditions=test_case_data.postconditions,
        test_data=test_case_data.test_data,
        automation_status=test_case_data.automation_status.value,
        automation_script_path=test_case_data.automation_script_path,
        automation_framework=test_case_data.automation_framework,
        automation_notes=test_case_data.automation_notes,
        estimated_duration_minutes=test_case_data.estimated_duration_minutes,
        assigned_to=test_case_data.assigned_to,
        metadata=test_case_data.metadata,
        created_by=claims.get("sub", "system"),
    )
    await db.commit()

    return {"id": str(tc.id), "test_case_number": tc.test_case_number}


@app.put("/api/v1/test-cases/{test_case_id}")
async def update_test_case(
    request: Request,
    test_case_id: str,
    test_case_data: TestCaseUpdate,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Update a test case."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = TestCaseRepository(db)
    tc = await repo.get_by_id(test_case_id)
    if not tc:
        raise HTTPException(status_code=404, detail="Test case not found")

    updates = test_case_data.model_dump(exclude_unset=True)

    # Convert enums to values
    for key in ["test_type", "priority", "automation_status"]:
        if key in updates and updates[key] is not None:
            updates[key] = updates[key].value

    # Convert test steps to dicts
    if "test_steps" in updates and updates["test_steps"] is not None:
        updates["test_steps"] = [
            step.model_dump() if hasattr(step, "model_dump") else step for step in updates["test_steps"]
        ]

    if "metadata" in updates:
        updates["test_case_metadata"] = updates.pop("metadata")

    tc = await repo.update(
        test_case_id=test_case_id,
        expected_version=tc.version,
        performed_by=claims.get("sub", "system"),
        **updates,
    )
    await db.commit()

    return {"id": str(tc.id), "version": tc.version}


@app.post("/api/v1/test-cases/{test_case_id}/status")
async def transition_test_case_status(
    request: Request,
    test_case_id: str,
    transition: TestCaseStatusTransition,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Transition test case to a new status."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = TestCaseRepository(db)
    try:
        tc = await repo.transition_status(
            test_case_id=test_case_id,
            to_status=transition.new_status.value,
            reason=transition.reason,
            performed_by=transition.performed_by or claims.get("sub", "system"),
        )
        await db.commit()
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    return {"id": str(tc.id), "status": tc.status, "version": tc.version}


@app.post("/api/v1/test-cases/{test_case_id}/submit-review")
async def submit_test_case_for_review(
    request: Request,
    test_case_id: str,
    review_data: TestCaseReview,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Submit a test case for review."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = TestCaseRepository(db)
    try:
        tc = await repo.submit_for_review(
            test_case_id=test_case_id,
            reviewer=review_data.reviewer,
            performed_by=claims.get("sub", "system"),
        )
        await db.commit()
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    return {"id": str(tc.id), "status": tc.status, "reviewed_by": tc.reviewed_by}


@app.post("/api/v1/test-cases/{test_case_id}/approve")
async def approve_test_case(
    request: Request,
    test_case_id: str,
    review_data: TestCaseReview,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Approve a test case after review."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = TestCaseRepository(db)
    try:
        tc = await repo.approve(
            test_case_id=test_case_id,
            reviewer_notes=review_data.notes,
            performed_by=claims.get("sub", "system"),
        )
        await db.commit()
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    return {"id": str(tc.id), "status": tc.status, "approved_by": tc.approved_by}


@app.post("/api/v1/test-cases/{test_case_id}/deprecate")
async def deprecate_test_case(
    request: Request,
    test_case_id: str,
    deprecation_data: TestCaseDeprecation,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Deprecate a test case."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "update")

    repo = TestCaseRepository(db)
    try:
        tc = await repo.deprecate(
            test_case_id=test_case_id,
            reason=deprecation_data.reason,
            replacement_test_case_id=deprecation_data.replacement_test_case_id,
            performed_by=deprecation_data.deprecated_by or claims.get("sub", "system"),
        )
        await db.commit()
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    return {"id": str(tc.id), "status": tc.status}


@app.get("/api/v1/test-cases/{test_case_id}/activities")
async def get_test_case_activities(
    request: Request,
    test_case_id: str,
    limit: int = 50,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Get activity log for a test case."""
    enforce_rate_limit(request, claims)

    repo = TestCaseRepository(db)
    activities = await repo.get_activities(test_case_id, limit=limit)

    return {
        "test_case_id": test_case_id,
        "activities": [
            {
                "id": str(a.id),
                "test_case_id": str(a.test_case_id),
                "activity_type": a.activity_type,
                "from_value": a.from_value,
                "to_value": a.to_value,
                "description": a.description,
                "performed_by": a.performed_by,
                "metadata": a.activity_metadata,
                "created_at": a.created_at.isoformat() if a.created_at else None,
            }
            for a in activities
        ],
    }


@app.delete("/api/v1/test-cases/{test_case_id}")
async def delete_test_case(
    request: Request,
    test_case_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Delete a test case (soft delete)."""
    enforce_rate_limit(request, claims)
    ensure_write_permission(claims, "delete")

    repo = TestCaseRepository(db)
    success = await repo.delete(test_case_id, soft=True)
    await db.commit()

    if not success:
        raise HTTPException(status_code=404, detail="Test case not found")

    return {"deleted": True, "id": test_case_id}


@app.get("/api/v1/projects/{project_id}/test-cases/stats")
async def get_test_case_stats(
    request: Request,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get test case statistics for a project."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    repo = TestCaseRepository(db)
    by_status = await repo.count_by_status(project_id)
    by_type = await repo.count_by_type(project_id)
    by_priority = await repo.count_by_priority(project_id)
    by_automation_status = await repo.count_by_automation_status(project_id)
    execution_summary = await repo.get_execution_summary(project_id)

    return {
        "project_id": project_id,
        "total": sum(by_status.values()),
        "by_status": by_status,
        "by_type": by_type,
        "by_priority": by_priority,
        "by_automation_status": by_automation_status,
        "execution_summary": execution_summary,
    }


# =============================================================================
# Test Suite Endpoints (Phase 2)
# =============================================================================


@app.get("/api/v1/test-suites")
async def list_test_suites(
    request: Request,
    project_id: str,
    status: str | None = None,
    category: str | None = None,
    parent_id: str | None = None,
    owner: str | None = None,
    search: str | None = None,
    skip: int = 0,
    limit: int = 50,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List test suites for a project with filtering."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from tracertm.repositories.test_suite_repository import TestSuiteRepository

    repo = TestSuiteRepository(db)
    suites, total = await repo.list_by_project(
        project_id=project_id,
        status=status,
        category=category,
        parent_id=parent_id,
        owner=owner,
        search=search,
        skip=skip,
        limit=limit,
    )

    return {
        "test_suites": [
            {
                "id": s.id,
                "suite_number": s.suite_number,
                "project_id": s.project_id,
                "name": s.name,
                "description": s.description,
                "objective": s.objective,
                "status": s.status.value if hasattr(s.status, "value") else s.status,
                "parent_id": s.parent_id,
                "order_index": s.order_index,
                "category": s.category,
                "tags": s.tags,
                "is_parallel_execution": s.is_parallel_execution,
                "estimated_duration_minutes": s.estimated_duration_minutes,
                "required_environment": s.required_environment,
                "owner": s.owner,
                "responsible_team": s.responsible_team,
                "total_test_cases": s.total_test_cases,
                "automated_count": s.automated_count,
                "last_run_at": s.last_run_at.isoformat() if s.last_run_at else None,
                "last_run_result": s.last_run_result,
                "pass_rate": s.pass_rate,
                "metadata": s.suite_metadata,
                "version": s.version,
                "created_at": s.created_at.isoformat(),
                "updated_at": s.updated_at.isoformat(),
            }
            for s in suites
        ],
        "total": total,
    }


@app.get("/api/v1/test-suites/{suite_id}")
async def get_test_suite(
    request: Request,
    suite_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get a test suite by ID."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_suite_repository import TestSuiteRepository

    repo = TestSuiteRepository(db)
    suite = await repo.get_by_id(suite_id)
    if not suite:
        raise HTTPException(status_code=404, detail="Test suite not found")
    assert suite is not None

    ensure_project_access(suite.project_id, claims)

    return {
        "id": suite.id,
        "suite_number": suite.suite_number,
        "project_id": suite.project_id,
        "name": suite.name,
        "description": suite.description,
        "objective": suite.objective,
        "status": suite.status.value if hasattr(suite.status, "value") else suite.status,
        "parent_id": suite.parent_id,
        "order_index": suite.order_index,
        "category": suite.category,
        "tags": suite.tags,
        "is_parallel_execution": suite.is_parallel_execution,
        "estimated_duration_minutes": suite.estimated_duration_minutes,
        "required_environment": suite.required_environment,
        "environment_variables": suite.environment_variables,
        "setup_instructions": suite.setup_instructions,
        "teardown_instructions": suite.teardown_instructions,
        "owner": suite.owner,
        "responsible_team": suite.responsible_team,
        "total_test_cases": suite.total_test_cases,
        "automated_count": suite.automated_count,
        "last_run_at": suite.last_run_at.isoformat() if suite.last_run_at else None,
        "last_run_result": suite.last_run_result,
        "pass_rate": suite.pass_rate,
        "metadata": suite.suite_metadata,
        "version": suite.version,
        "created_at": suite.created_at.isoformat(),
        "updated_at": suite.updated_at.isoformat(),
    }


@app.post("/api/v1/test-suites")
async def create_test_suite(
    request: Request,
    project_id: str,
    suite_data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create a new test suite."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from tracertm.repositories.test_suite_repository import TestSuiteRepository

    repo = TestSuiteRepository(db)
    suite = await repo.create(
        project_id=project_id,
        name=suite_data["name"],
        description=suite_data.get("description"),
        objective=suite_data.get("objective"),
        parent_id=suite_data.get("parent_id"),
        order_index=suite_data.get("order_index", 0),
        category=suite_data.get("category"),
        tags=suite_data.get("tags"),
        is_parallel_execution=suite_data.get("is_parallel_execution", False),
        estimated_duration_minutes=suite_data.get("estimated_duration_minutes"),
        required_environment=suite_data.get("required_environment"),
        environment_variables=suite_data.get("environment_variables"),
        setup_instructions=suite_data.get("setup_instructions"),
        teardown_instructions=suite_data.get("teardown_instructions"),
        owner=suite_data.get("owner"),
        responsible_team=suite_data.get("responsible_team"),
        metadata=suite_data.get("metadata"),
        created_by=claims.get("sub"),
    )
    await db.commit()

    return {"id": suite.id, "suite_number": suite.suite_number}


@app.put("/api/v1/test-suites/{suite_id}")
async def update_test_suite(
    request: Request,
    suite_id: str,
    suite_data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Update a test suite."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_suite_repository import TestSuiteRepository

    repo = TestSuiteRepository(db)
    suite = await repo.get_by_id(suite_id)
    if not suite:
        raise HTTPException(status_code=404, detail="Test suite not found")
    assert suite is not None

    ensure_project_access(suite.project_id, claims)

    # Map metadata to suite_metadata
    updates = {k: v for k, v in suite_data.items() if v is not None}
    if "metadata" in updates:
        updates["suite_metadata"] = updates.pop("metadata")

    updated = await repo.update(suite_id, updated_by=claims.get("sub"), **updates)
    if not updated:
        raise HTTPException(status_code=404, detail="Test suite not found")
    await db.commit()

    return {"id": updated.id, "version": updated.version}


@app.post("/api/v1/test-suites/{suite_id}/status")
async def transition_test_suite_status(
    request: Request,
    suite_id: str,
    status_data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Transition test suite status."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_suite_repository import TestSuiteRepository

    repo = TestSuiteRepository(db)
    suite = await repo.get_by_id(suite_id)
    if not suite:
        raise HTTPException(status_code=404, detail="Test suite not found")
    assert suite is not None

    ensure_project_access(suite.project_id, claims)

    updated = await repo.transition_status(
        suite_id=suite_id,
        new_status=status_data["new_status"],
        reason=status_data.get("reason"),
        performed_by=claims.get("sub"),
    )
    await db.commit()

    if updated is None:
        raise HTTPException(status_code=404, detail="Test suite not found")
    return {
        "id": updated.id,
        "status": updated.status.value if hasattr(updated.status, "value") else updated.status,
        "version": updated.version,
    }


@app.post("/api/v1/test-suites/{suite_id}/test-cases")
async def add_test_case_to_suite(
    request: Request,
    suite_id: str,
    tc_data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Add a test case to a suite."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_suite_repository import TestSuiteRepository

    repo = TestSuiteRepository(db)
    suite = await repo.get_by_id(suite_id)
    if not suite:
        raise HTTPException(status_code=404, detail="Test suite not found")
    assert suite is not None

    ensure_project_access(suite.project_id, claims)

    association = await repo.add_test_case(
        suite_id=suite_id,
        test_case_id=tc_data["test_case_id"],
        order_index=tc_data.get("order_index", 0),
        is_mandatory=tc_data.get("is_mandatory", True),
        skip_reason=tc_data.get("skip_reason"),
        custom_parameters=tc_data.get("custom_parameters"),
        added_by=claims.get("sub"),
    )
    await db.commit()

    return {"id": association.id, "suite_id": suite_id, "test_case_id": tc_data["test_case_id"]}


@app.delete("/api/v1/test-suites/{suite_id}/test-cases/{test_case_id}")
async def remove_test_case_from_suite(
    request: Request,
    suite_id: str,
    test_case_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Remove a test case from a suite."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_suite_repository import TestSuiteRepository

    repo = TestSuiteRepository(db)
    suite = await repo.get_by_id(suite_id)
    if not suite:
        raise HTTPException(status_code=404, detail="Test suite not found")
    assert suite is not None

    ensure_project_access(suite.project_id, claims)

    success = await repo.remove_test_case(suite_id, test_case_id, claims.get("sub"))
    await db.commit()

    if not success:
        raise HTTPException(status_code=404, detail="Test case not in suite")

    return {"removed": True}


@app.get("/api/v1/test-suites/{suite_id}/test-cases")
async def get_suite_test_cases(
    request: Request,
    suite_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get test cases in a suite."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_suite_repository import TestSuiteRepository

    repo = TestSuiteRepository(db)
    suite = await repo.get_by_id(suite_id)
    if not suite:
        raise HTTPException(status_code=404, detail="Test suite not found")
    assert suite is not None

    ensure_project_access(suite.project_id, claims)

    associations = await repo.get_test_cases(suite_id)

    return {
        "suite_id": suite_id,
        "test_cases": [
            {
                "id": a.id,
                "test_case_id": a.test_case_id,
                "order_index": a.order_index,
                "is_mandatory": a.is_mandatory,
                "skip_reason": a.skip_reason,
                "custom_parameters": a.custom_parameters,
            }
            for a in associations
        ],
    }


@app.get("/api/v1/test-suites/{suite_id}/activities")
async def get_test_suite_activities(
    request: Request,
    suite_id: str,
    limit: int = 50,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Get activity log for a test suite."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_suite_repository import TestSuiteRepository

    repo = TestSuiteRepository(db)
    suite = await repo.get_by_id(suite_id)
    if not suite:
        raise HTTPException(status_code=404, detail="Test suite not found")
    assert suite is not None

    ensure_project_access(suite.project_id, claims)

    activities = await repo.get_activities(suite_id, limit)

    return {
        "suite_id": suite_id,
        "activities": [
            {
                "id": a.id,
                "suite_id": a.suite_id,
                "activity_type": a.activity_type,
                "from_value": a.from_value,
                "to_value": a.to_value,
                "description": a.description,
                "performed_by": a.performed_by,
                "metadata": a.activity_metadata,
                "created_at": a.created_at.isoformat(),
            }
            for a in activities
        ],
    }


@app.delete("/api/v1/test-suites/{suite_id}")
async def delete_test_suite(
    request: Request,
    suite_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Delete a test suite."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_suite_repository import TestSuiteRepository

    repo = TestSuiteRepository(db)
    suite = await repo.get_by_id(suite_id)
    if not suite:
        raise HTTPException(status_code=404, detail="Test suite not found")
    assert suite is not None

    ensure_project_access(suite.project_id, claims)

    await repo.delete(suite_id)
    await db.commit()

    return {"deleted": True, "id": suite_id}


@app.get("/api/v1/projects/{project_id}/test-suites/stats")
async def get_test_suite_stats(
    request: Request,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get test suite statistics for a project."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from tracertm.repositories.test_suite_repository import TestSuiteRepository

    repo = TestSuiteRepository(db)
    return await repo.get_stats(project_id)


# =============================================================================
# Test Run Endpoints (Phase 2)
# =============================================================================


@app.get("/api/v1/test-runs")
async def list_test_runs(
    request: Request,
    project_id: str,
    status: str | None = None,
    run_type: str | None = None,
    suite_id: str | None = None,
    environment: str | None = None,
    initiated_by: str | None = None,
    skip: int = 0,
    limit: int = 50,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List test runs for a project with filtering."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from tracertm.repositories.test_run_repository import TestRunRepository

    repo = TestRunRepository(db)
    runs, total = await repo.list_by_project(
        project_id=project_id,
        status=status,
        run_type=run_type,
        suite_id=suite_id,
        environment=environment,
        initiated_by=initiated_by,
        skip=skip,
        limit=limit,
    )

    return {
        "test_runs": [
            {
                "id": r.id,
                "run_number": r.run_number,
                "project_id": r.project_id,
                "suite_id": r.suite_id,
                "name": r.name,
                "description": r.description,
                "status": r.status.value if hasattr(r.status, "value") else r.status,
                "run_type": r.run_type.value if hasattr(r.run_type, "value") else r.run_type,
                "environment": r.environment,
                "build_number": r.build_number,
                "build_url": r.build_url,
                "branch": r.branch,
                "commit_sha": r.commit_sha,
                "scheduled_at": r.scheduled_at.isoformat() if r.scheduled_at else None,
                "started_at": r.started_at.isoformat() if r.started_at else None,
                "completed_at": r.completed_at.isoformat() if r.completed_at else None,
                "duration_seconds": r.duration_seconds,
                "initiated_by": r.initiated_by,
                "executed_by": r.executed_by,
                "total_tests": r.total_tests,
                "passed_count": r.passed_count,
                "failed_count": r.failed_count,
                "skipped_count": r.skipped_count,
                "blocked_count": r.blocked_count,
                "error_count": r.error_count,
                "pass_rate": r.pass_rate,
                "tags": r.tags,
                "external_run_id": r.external_run_id,
                "metadata": r.run_metadata,
                "version": r.version,
                "created_at": r.created_at.isoformat(),
                "updated_at": r.updated_at.isoformat(),
            }
            for r in runs
        ],
        "total": total,
    }


@app.get("/api/v1/test-runs/{run_id}")
async def get_test_run(
    request: Request,
    run_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get a test run by ID."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_run_repository import TestRunRepository

    repo = TestRunRepository(db)
    run = await repo.get_by_id(run_id)
    if not run:
        raise HTTPException(status_code=404, detail="Test run not found")
    assert run is not None

    ensure_project_access(run.project_id, claims)

    return {
        "id": run.id,
        "run_number": run.run_number,
        "project_id": run.project_id,
        "suite_id": run.suite_id,
        "name": run.name,
        "description": run.description,
        "status": run.status.value if hasattr(run.status, "value") else run.status,
        "run_type": run.run_type.value if hasattr(run.run_type, "value") else run.run_type,
        "environment": run.environment,
        "build_number": run.build_number,
        "build_url": run.build_url,
        "branch": run.branch,
        "commit_sha": run.commit_sha,
        "scheduled_at": run.scheduled_at.isoformat() if run.scheduled_at else None,
        "started_at": run.started_at.isoformat() if run.started_at else None,
        "completed_at": run.completed_at.isoformat() if run.completed_at else None,
        "duration_seconds": run.duration_seconds,
        "initiated_by": run.initiated_by,
        "executed_by": run.executed_by,
        "total_tests": run.total_tests,
        "passed_count": run.passed_count,
        "failed_count": run.failed_count,
        "skipped_count": run.skipped_count,
        "blocked_count": run.blocked_count,
        "error_count": run.error_count,
        "pass_rate": run.pass_rate,
        "notes": run.notes,
        "failure_summary": run.failure_summary,
        "tags": run.tags,
        "external_run_id": run.external_run_id,
        "webhook_id": run.webhook_id,
        "metadata": run.run_metadata,
        "version": run.version,
        "created_at": run.created_at.isoformat(),
        "updated_at": run.updated_at.isoformat(),
    }


@app.post("/api/v1/test-runs")
async def create_test_run(
    request: Request,
    project_id: str,
    run_data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create a new test run."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from tracertm.repositories.test_run_repository import TestRunRepository

    repo = TestRunRepository(db)
    run = await repo.create(
        project_id=project_id,
        name=run_data["name"],
        description=run_data.get("description"),
        suite_id=run_data.get("suite_id"),
        run_type=run_data.get("run_type", "manual"),
        environment=run_data.get("environment"),
        build_number=run_data.get("build_number"),
        build_url=run_data.get("build_url"),
        branch=run_data.get("branch"),
        commit_sha=run_data.get("commit_sha"),
        scheduled_at=run_data.get("scheduled_at"),
        initiated_by=claims.get("sub"),
        tags=run_data.get("tags"),
        external_run_id=run_data.get("external_run_id"),
        webhook_id=run_data.get("webhook_id"),
        metadata=run_data.get("metadata"),
    )
    await db.commit()

    return {"id": run.id, "run_number": run.run_number}


@app.put("/api/v1/test-runs/{run_id}")
async def update_test_run(
    request: Request,
    run_id: str,
    run_data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Update a test run."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_run_repository import TestRunRepository

    repo = TestRunRepository(db)
    run = await repo.get_by_id(run_id)
    if not run:
        raise HTTPException(status_code=404, detail="Test run not found")
    assert run is not None

    ensure_project_access(run.project_id, claims)

    # Map metadata to run_metadata
    updates = {k: v for k, v in run_data.items() if v is not None}
    if "metadata" in updates:
        updates["run_metadata"] = updates.pop("metadata")

    updated = await repo.update(run_id, updated_by=claims.get("sub"), **updates)
    await db.commit()

    if updated is None:
        raise HTTPException(status_code=404, detail="Test run not found after update")
    return {"id": updated.id, "version": updated.version}


@app.post("/api/v1/test-runs/{run_id}/start")
async def start_test_run(
    request: Request,
    run_id: str,
    start_data: dict[str, Any] | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Start a test run."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_run_repository import TestRunRepository

    repo = TestRunRepository(db)
    run = await repo.get_by_id(run_id)
    if not run:
        raise HTTPException(status_code=404, detail="Test run not found")
    assert run is not None

    ensure_project_access(run.project_id, claims)

    start_data = start_data or {}
    updated = await repo.start(
        run_id=run_id,
        executed_by=start_data.get("executed_by") or claims.get("sub"),
    )
    await db.commit()

    if updated is None:
        raise HTTPException(status_code=404, detail="Test run not found after start")
    return {
        "id": updated.id,
        "status": updated.status.value if hasattr(updated.status, "value") else updated.status,
        "started_at": updated.started_at.isoformat() if updated.started_at else None,
    }


@app.post("/api/v1/test-runs/{run_id}/complete")
async def complete_test_run(
    request: Request,
    run_id: str,
    complete_data: dict[str, Any] | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Complete a test run."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_run_repository import TestRunRepository

    repo = TestRunRepository(db)
    run = await repo.get_by_id(run_id)
    if not run:
        raise HTTPException(status_code=404, detail="Test run not found")
    assert run is not None

    ensure_project_access(run.project_id, claims)

    complete_data = complete_data or {}
    updated = await repo.complete(
        run_id=run_id,
        status=complete_data.get("status"),
        notes=complete_data.get("notes"),
        failure_summary=complete_data.get("failure_summary"),
        completed_by=claims.get("sub"),
    )
    await db.commit()

    if updated is None:
        raise HTTPException(status_code=404, detail="Test run not found after complete")
    return {
        "id": updated.id,
        "status": updated.status.value if hasattr(updated.status, "value") else updated.status,
        "completed_at": updated.completed_at.isoformat() if updated.completed_at else None,
        "pass_rate": updated.pass_rate,
    }


@app.post("/api/v1/test-runs/{run_id}/cancel")
async def cancel_test_run(
    request: Request,
    run_id: str,
    cancel_data: dict[str, Any] | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Cancel a test run."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_run_repository import TestRunRepository

    repo = TestRunRepository(db)
    run = await repo.get_by_id(run_id)
    if not run:
        raise HTTPException(status_code=404, detail="Test run not found")
    assert run is not None

    ensure_project_access(run.project_id, claims)

    cancel_data = cancel_data or {}
    updated = await repo.cancel(
        run_id=run_id,
        reason=cancel_data.get("reason"),
        cancelled_by=claims.get("sub"),
    )
    await db.commit()

    if updated is None:
        raise HTTPException(status_code=404, detail="Test run not found after cancel")
    return {
        "id": updated.id,
        "status": updated.status.value if hasattr(updated.status, "value") else updated.status,
    }


@app.post("/api/v1/test-runs/{run_id}/results")
async def submit_test_result(
    request: Request,
    run_id: str,
    result_data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Submit a single test result."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_run_repository import TestRunRepository

    repo = TestRunRepository(db)
    run = await repo.get_by_id(run_id)
    if not run:
        raise HTTPException(status_code=404, detail="Test run not found")
    assert run is not None

    ensure_project_access(run.project_id, claims)

    result = await repo.add_result(
        run_id=run_id,
        test_case_id=result_data["test_case_id"],
        status=result_data["status"],
        started_at=result_data.get("started_at"),
        completed_at=result_data.get("completed_at"),
        duration_seconds=result_data.get("duration_seconds"),
        executed_by=result_data.get("executed_by") or claims.get("sub"),
        actual_result=result_data.get("actual_result"),
        failure_reason=result_data.get("failure_reason"),
        error_message=result_data.get("error_message"),
        stack_trace=result_data.get("stack_trace"),
        screenshots=result_data.get("screenshots"),
        logs_url=result_data.get("logs_url"),
        attachments=result_data.get("attachments"),
        step_results=result_data.get("step_results"),
        linked_defect_ids=result_data.get("linked_defect_ids"),
        created_defect_id=result_data.get("created_defect_id"),
        retry_count=result_data.get("retry_count", 0),
        is_flaky=result_data.get("is_flaky", False),
        notes=result_data.get("notes"),
        metadata=result_data.get("metadata"),
    )
    await db.commit()

    return {"id": result.id, "run_id": run_id, "test_case_id": result_data["test_case_id"]}


@app.post("/api/v1/test-runs/{run_id}/bulk-results")
async def submit_bulk_test_results(
    request: Request,
    run_id: str,
    bulk_data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Submit multiple test results at once."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_run_repository import TestRunRepository

    repo = TestRunRepository(db)
    run = await repo.get_by_id(run_id)
    if not run:
        raise HTTPException(status_code=404, detail="Test run not found")
    assert run is not None

    ensure_project_access(run.project_id, claims)

    results = await repo.add_bulk_results(run_id, bulk_data.get("results", []))
    await db.commit()

    return {
        "run_id": run_id,
        "submitted_count": len(results),
        "result_ids": [r.id for r in results],
    }


@app.get("/api/v1/test-runs/{run_id}/results")
async def get_test_run_results(
    request: Request,
    run_id: str,
    status: str | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Get all results for a test run."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_run_repository import TestRunRepository

    repo = TestRunRepository(db)
    run = await repo.get_by_id(run_id)
    if not run:
        raise HTTPException(status_code=404, detail="Test run not found")
    assert run is not None

    ensure_project_access(run.project_id, claims)

    results = await repo.get_results(run_id, status)

    return {
        "run_id": run_id,
        "results": [
            {
                "id": r.id,
                "run_id": r.run_id,
                "test_case_id": r.test_case_id,
                "status": r.status.value if hasattr(r.status, "value") else r.status,
                "started_at": r.started_at.isoformat() if r.started_at else None,
                "completed_at": r.completed_at.isoformat() if r.completed_at else None,
                "duration_seconds": r.duration_seconds,
                "executed_by": r.executed_by,
                "actual_result": r.actual_result,
                "failure_reason": r.failure_reason,
                "error_message": r.error_message,
                "screenshots": r.screenshots,
                "logs_url": r.logs_url,
                "step_results": r.step_results,
                "linked_defect_ids": r.linked_defect_ids,
                "retry_count": r.retry_count,
                "is_flaky": r.is_flaky,
                "notes": r.notes,
                "metadata": r.run_metadata,
                "created_at": r.created_at.isoformat(),
            }
            for r in results
        ],
        "total": len(results),
    }


@app.get("/api/v1/test-runs/{run_id}/activities")
async def get_test_run_activities(
    request: Request,
    run_id: str,
    limit: int = 50,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Get activity log for a test run."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_run_repository import TestRunRepository

    repo = TestRunRepository(db)
    run = await repo.get_by_id(run_id)
    if not run:
        raise HTTPException(status_code=404, detail="Test run not found")
    assert run is not None

    ensure_project_access(run.project_id, claims)

    activities = await repo.get_activities(run_id, limit)

    return {
        "run_id": run_id,
        "activities": [
            {
                "id": a.id,
                "run_id": a.run_id,
                "activity_type": a.activity_type,
                "from_value": a.from_value,
                "to_value": a.to_value,
                "description": a.description,
                "performed_by": a.performed_by,
                "metadata": a.run_metadata,
                "created_at": a.created_at.isoformat(),
            }
            for a in activities
        ],
    }


@app.delete("/api/v1/test-runs/{run_id}")
async def delete_test_run(
    request: Request,
    run_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Delete a test run."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_run_repository import TestRunRepository

    repo = TestRunRepository(db)
    run = await repo.get_by_id(run_id)
    if not run:
        raise HTTPException(status_code=404, detail="Test run not found")
    assert run is not None

    ensure_project_access(run.project_id, claims)

    await repo.delete(run_id)
    await db.commit()

    return {"deleted": True, "id": run_id}


@app.get("/api/v1/projects/{project_id}/test-runs/stats")
async def get_test_run_stats(
    request: Request,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get test run statistics for a project."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from tracertm.repositories.test_run_repository import TestRunRepository

    repo = TestRunRepository(db)
    stats = await repo.get_stats(project_id)

    # Convert recent runs to dict format
    stats["recent_runs"] = [
        {
            "id": r.id,
            "run_number": r.run_number,
            "name": r.name,
            "status": r.status.value if hasattr(r.status, "value") else r.status,
            "pass_rate": r.pass_rate,
            "created_at": r.created_at.isoformat(),
        }
        for r in stats.get("recent_runs", [])
    ]

    return stats


# =============================================================================
# Test Coverage Endpoints (Phase 3)
# =============================================================================


@app.get("/api/v1/coverage")
async def list_test_coverage(
    request: Request,
    project_id: str,
    coverage_type: str | None = None,
    status: str | None = None,
    test_case_id: str | None = None,
    requirement_id: str | None = None,
    skip: int = 0,
    limit: int = 100,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List test coverage mappings for a project with filtering."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from tracertm.repositories.test_coverage_repository import TestCoverageRepository

    repo = TestCoverageRepository(db)
    coverages, total = await repo.list_by_project(
        project_id=project_id,
        coverage_type=coverage_type,
        status=status,
        test_case_id=test_case_id,
        requirement_id=requirement_id,
        skip=skip,
        limit=limit,
    )

    return {
        "coverages": [
            {
                "id": c.id,
                "project_id": c.project_id,
                "test_case_id": c.test_case_id,
                "requirement_id": c.requirement_id,
                "coverage_type": c.coverage_type.value if hasattr(c.coverage_type, "value") else c.coverage_type,
                "status": c.status.value if hasattr(c.status, "value") else c.status,
                "coverage_percentage": c.coverage_percentage,
                "rationale": c.rationale,
                "notes": c.notes,
                "last_verified_at": c.last_verified_at.isoformat() if c.last_verified_at else None,
                "verified_by": c.verified_by,
                "last_test_result": c.last_test_result,
                "last_tested_at": c.last_tested_at.isoformat() if c.last_tested_at else None,
                "created_by": c.created_by,
                "coverage_metadata": c.coverage_metadata,
                "version": c.version,
                "created_at": c.created_at.isoformat(),
                "updated_at": c.updated_at.isoformat(),
            }
            for c in coverages
        ],
        "total": total,
        "skip": skip,
        "limit": limit,
    }


@app.post("/api/v1/coverage")
async def create_test_coverage(
    request: Request,
    project_id: str,
    test_case_id: str,
    requirement_id: str,
    coverage_type: str = "direct",
    coverage_percentage: int | None = None,
    rationale: str | None = None,
    notes: str | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Create a new test coverage mapping."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from tracertm.repositories.test_coverage_repository import TestCoverageRepository

    repo = TestCoverageRepository(db)

    # Check if mapping already exists
    existing = await repo.get_by_test_case_and_requirement(test_case_id, requirement_id)
    if existing:
        raise HTTPException(
            status_code=400,
            detail="Coverage mapping already exists for this test case and requirement",
        )

    coverage = await repo.create(
        project_id=project_id,
        test_case_id=test_case_id,
        requirement_id=requirement_id,
        coverage_type=coverage_type,
        coverage_percentage=coverage_percentage,
        rationale=rationale,
        notes=notes,
        created_by=claims.get("user_id"),
    )
    await db.commit()

    return {
        "id": coverage.id,
        "project_id": coverage.project_id,
        "test_case_id": coverage.test_case_id,
        "requirement_id": coverage.requirement_id,
        "coverage_type": coverage.coverage_type.value,
        "status": coverage.status.value,
    }


@app.get("/api/v1/coverage/{coverage_id}")
async def get_test_coverage(
    request: Request,
    coverage_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get a test coverage mapping by ID."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_coverage_repository import TestCoverageRepository

    repo = TestCoverageRepository(db)
    coverage = await repo.get_by_id(coverage_id)
    if not coverage:
        raise HTTPException(status_code=404, detail="Coverage mapping not found")
    assert coverage is not None

    ensure_project_access(coverage.project_id, claims)

    return {
        "id": coverage.id,
        "project_id": coverage.project_id,
        "test_case_id": coverage.test_case_id,
        "requirement_id": coverage.requirement_id,
        "coverage_type": coverage.coverage_type.value
        if hasattr(coverage.coverage_type, "value")
        else coverage.coverage_type,
        "status": coverage.status.value if hasattr(coverage.status, "value") else coverage.status,
        "coverage_percentage": coverage.coverage_percentage,
        "rationale": coverage.rationale,
        "notes": coverage.notes,
        "last_verified_at": coverage.last_verified_at.isoformat() if coverage.last_verified_at else None,
        "verified_by": coverage.verified_by,
        "last_test_result": coverage.last_test_result,
        "last_tested_at": coverage.last_tested_at.isoformat() if coverage.last_tested_at else None,
        "created_by": coverage.created_by,
        "coverage_metadata": coverage.coverage_metadata,
        "version": coverage.version,
        "created_at": coverage.created_at.isoformat(),
        "updated_at": coverage.updated_at.isoformat(),
    }


@app.put("/api/v1/coverage/{coverage_id}")
async def update_test_coverage(
    request: Request,
    coverage_id: str,
    coverage_type: str | None = None,
    status: str | None = None,
    coverage_percentage: int | None = None,
    rationale: str | None = None,
    notes: str | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Update a test coverage mapping."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_coverage_repository import TestCoverageRepository

    repo = TestCoverageRepository(db)
    coverage = await repo.get_by_id(coverage_id)
    if not coverage:
        raise HTTPException(status_code=404, detail="Coverage mapping not found")
    assert coverage is not None

    ensure_project_access(coverage.project_id, claims)

    updates: dict[str, Any] = {}
    if coverage_type is not None:
        updates["coverage_type"] = coverage_type
    if status is not None:
        updates["status"] = status
    if coverage_percentage is not None:
        updates["coverage_percentage"] = coverage_percentage
    if rationale is not None:
        updates["rationale"] = rationale
    if notes is not None:
        updates["notes"] = notes

    coverage = await repo.update(
        coverage_id,
        updated_by=claims.get("user_id"),
        **updates,
    )
    await db.commit()

    if coverage is None:
        raise HTTPException(status_code=404, detail="Test coverage not found after update")
    return {"id": coverage.id, "version": coverage.version}


@app.delete("/api/v1/coverage/{coverage_id}")
async def delete_test_coverage(
    request: Request,
    coverage_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Delete a test coverage mapping."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_coverage_repository import TestCoverageRepository

    repo = TestCoverageRepository(db)
    coverage = await repo.get_by_id(coverage_id)
    if not coverage:
        raise HTTPException(status_code=404, detail="Coverage mapping not found")
    assert coverage is not None

    ensure_project_access(coverage.project_id, claims)

    await repo.delete(coverage_id)
    await db.commit()

    return {"deleted": True, "id": coverage_id}


@app.post("/api/v1/coverage/{coverage_id}/verify")
async def verify_test_coverage(
    request: Request,
    coverage_id: str,
    notes: str | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Mark a coverage mapping as verified."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_coverage_repository import TestCoverageRepository

    repo = TestCoverageRepository(db)
    coverage = await repo.get_by_id(coverage_id)
    if not coverage:
        raise HTTPException(status_code=404, detail="Coverage mapping not found")
    assert coverage is not None

    ensure_project_access(coverage.project_id, claims)

    coverage = await repo.verify_coverage(
        coverage_id,
        verified_by=str(claims.get("user_id", "unknown")),
        notes=notes,
    )
    await db.commit()

    if coverage is None:
        raise HTTPException(status_code=404, detail="Test coverage not found after verification")
    return {
        "id": coverage.id,
        "last_verified_at": coverage.last_verified_at.isoformat() if coverage.last_verified_at else None,
        "verified_by": coverage.verified_by,
    }


@app.get("/api/v1/coverage/matrix")
async def get_coverage_matrix_endpoint(
    request: Request,
    project_id: str,
    requirement_view: str | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Get the traceability matrix for a project."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from tracertm.repositories.test_coverage_repository import TestCoverageRepository

    repo = TestCoverageRepository(db)
    return await repo.get_traceability_matrix(project_id, requirement_view)


@app.get("/api/v1/coverage/gaps")
async def get_coverage_gaps(
    request: Request,
    project_id: str,
    requirement_view: str | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Find requirements that have no test coverage."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from tracertm.repositories.test_coverage_repository import TestCoverageRepository

    repo = TestCoverageRepository(db)
    return await repo.get_coverage_gaps(project_id, requirement_view)


@app.get("/api/v1/test-cases/{test_case_id}/coverage")
async def get_test_case_coverage(
    request: Request,
    test_case_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get coverage summary for a specific test case."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_coverage_repository import TestCoverageRepository

    repo = TestCoverageRepository(db)
    return await repo.get_test_case_coverage_summary(test_case_id)


@app.get("/api/v1/requirements/{requirement_id}/coverage")
async def get_requirement_coverage(
    request: Request,
    requirement_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get all test cases covering a specific requirement."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_coverage_repository import TestCoverageRepository

    repo = TestCoverageRepository(db)
    coverages = await repo.list_by_requirement(requirement_id)

    return {
        "requirement_id": requirement_id,
        "test_cases": [
            {
                "coverage_id": c.id,
                "test_case_id": c.test_case_id,
                "coverage_type": c.coverage_type.value if hasattr(c.coverage_type, "value") else c.coverage_type,
                "coverage_percentage": c.coverage_percentage,
                "last_test_result": c.last_test_result,
                "last_tested_at": c.last_tested_at.isoformat() if c.last_tested_at else None,
            }
            for c in coverages
        ],
        "total": len(coverages),
    }


@app.get("/api/v1/projects/{project_id}/coverage/stats")
async def get_coverage_stats(
    request: Request,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get coverage statistics for a project."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from tracertm.repositories.test_coverage_repository import TestCoverageRepository

    repo = TestCoverageRepository(db)
    return await repo.get_stats(project_id)


@app.get("/api/v1/coverage/{coverage_id}/activities")
async def get_coverage_activities(
    request: Request,
    coverage_id: str,
    limit: int = 50,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Get activity log for a coverage mapping."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.test_coverage_repository import TestCoverageRepository

    repo = TestCoverageRepository(db)
    coverage = await repo.get_by_id(coverage_id)
    if not coverage:
        raise HTTPException(status_code=404, detail="Coverage mapping not found")
    assert coverage is not None

    ensure_project_access(coverage.project_id, claims)

    activities = await repo.get_activities(coverage_id, limit)

    return {
        "coverage_id": coverage_id,
        "activities": [
            {
                "id": a.id,
                "coverage_id": a.coverage_id,
                "activity_type": a.activity_type,
                "from_value": a.from_value,
                "to_value": a.to_value,
                "description": a.description,
                "performed_by": a.performed_by,
                "activity_metadata": a.activity_metadata,
                "created_at": a.created_at.isoformat(),
            }
            for a in activities
        ],
    }


# =============================================================================
# Webhook Integration Endpoints
# =============================================================================


@app.get("/api/v1/webhooks")
async def list_webhooks(
    request: Request,
    project_id: str,
    provider: str | None = None,
    status: str | None = None,
    skip: int = 0,
    limit: int = 50,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List webhooks for a project."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from tracertm.repositories.webhook_repository import WebhookRepository

    repo = WebhookRepository(db)
    webhooks, total = await repo.list_by_project(
        project_id=project_id,
        provider=provider,
        status=status,
        skip=skip,
        limit=limit,
    )

    return {
        "webhooks": [
            {
                "id": w.id,
                "project_id": w.project_id,
                "name": w.name,
                "description": w.description,
                "provider": w.provider.value,
                "status": w.status.value,
                "webhook_secret": w.webhook_secret,
                "enabled_events": w.enabled_events,
                "callback_url": w.callback_url,
                "default_suite_id": w.default_suite_id,
                "rate_limit_per_minute": w.rate_limit_per_minute,
                "auto_create_run": w.auto_create_run,
                "auto_complete_run": w.auto_complete_run,
                "verify_signatures": w.verify_signatures,
                "total_requests": w.total_requests,
                "successful_requests": w.successful_requests,
                "failed_requests": w.failed_requests,
                "last_request_at": w.last_request_at.isoformat() if w.last_request_at else None,
                "last_success_at": w.last_success_at.isoformat() if w.last_success_at else None,
                "last_failure_at": w.last_failure_at.isoformat() if w.last_failure_at else None,
                "last_error_message": w.last_error_message,
                "version": w.version,
                "created_at": w.created_at.isoformat(),
                "updated_at": w.updated_at.isoformat(),
            }
            for w in webhooks
        ],
        "total": total,
        "skip": skip,
        "limit": limit,
    }


class WebhookCreateRequest(BaseModel):
    """Request schema for creating a webhook."""

    project_id: str
    name: str
    description: str | None = None
    provider: str = "custom"
    enabled_events: list[str] | None = None
    event_filters: dict[str, Any] | None = None
    callback_url: str | None = None
    callback_headers: dict[str, Any] | None = None
    default_suite_id: str | None = None
    rate_limit_per_minute: int = 60
    auto_create_run: bool = True
    auto_complete_run: bool = True
    verify_signatures: bool = True
    metadata: dict[str, Any] | None = None


@app.post("/api/v1/webhooks")
async def create_webhook(
    request: Request,
    payload: WebhookCreateRequest,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create a new webhook integration."""
    enforce_rate_limit(request, claims)
    ensure_project_access(payload.project_id, claims)

    from tracertm.repositories.webhook_repository import WebhookRepository

    repo = WebhookRepository(db)
    webhook = await repo.create(
        project_id=payload.project_id,
        name=payload.name,
        description=payload.description,
        provider=payload.provider,
        enabled_events=payload.enabled_events,
        event_filters=payload.event_filters,
        callback_url=payload.callback_url,
        callback_headers=payload.callback_headers,
        default_suite_id=payload.default_suite_id,
        rate_limit_per_minute=payload.rate_limit_per_minute,
        auto_create_run=payload.auto_create_run,
        auto_complete_run=payload.auto_complete_run,
        verify_signatures=payload.verify_signatures,
        metadata=payload.metadata,
    )
    await db.commit()

    return {
        "id": webhook.id,
        "project_id": webhook.project_id,
        "name": webhook.name,
        "description": webhook.description,
        "provider": webhook.provider.value,
        "status": webhook.status.value,
        "webhook_secret": webhook.webhook_secret,
        "enabled_events": webhook.enabled_events,
        "callback_url": webhook.callback_url,
        "default_suite_id": webhook.default_suite_id,
        "rate_limit_per_minute": webhook.rate_limit_per_minute,
        "auto_create_run": webhook.auto_create_run,
        "auto_complete_run": webhook.auto_complete_run,
        "verify_signatures": webhook.verify_signatures,
        "total_requests": webhook.total_requests,
        "successful_requests": webhook.successful_requests,
        "failed_requests": webhook.failed_requests,
        "version": webhook.version,
        "created_at": webhook.created_at.isoformat(),
        "updated_at": webhook.updated_at.isoformat(),
    }


@app.get("/api/v1/webhooks/{webhook_id}")
async def get_webhook(
    request: Request,
    webhook_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get a webhook by ID."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.webhook_repository import WebhookRepository

    repo = WebhookRepository(db)
    webhook = await repo.get_by_id(webhook_id)
    if not webhook:
        raise HTTPException(status_code=404, detail="Webhook not found")
    assert webhook is not None

    ensure_project_access(webhook.project_id, claims)

    return {
        "id": webhook.id,
        "project_id": webhook.project_id,
        "name": webhook.name,
        "description": webhook.description,
        "provider": webhook.provider.value,
        "status": webhook.status.value,
        "webhook_secret": webhook.webhook_secret,
        "api_key": webhook.api_key,
        "enabled_events": webhook.enabled_events,
        "event_filters": webhook.event_filters,
        "callback_url": webhook.callback_url,
        "callback_headers": webhook.callback_headers,
        "default_suite_id": webhook.default_suite_id,
        "rate_limit_per_minute": webhook.rate_limit_per_minute,
        "auto_create_run": webhook.auto_create_run,
        "auto_complete_run": webhook.auto_complete_run,
        "verify_signatures": webhook.verify_signatures,
        "total_requests": webhook.total_requests,
        "successful_requests": webhook.successful_requests,
        "failed_requests": webhook.failed_requests,
        "last_request_at": webhook.last_request_at.isoformat() if webhook.last_request_at else None,
        "last_success_at": webhook.last_success_at.isoformat() if webhook.last_success_at else None,
        "last_failure_at": webhook.last_failure_at.isoformat() if webhook.last_failure_at else None,
        "last_error_message": webhook.last_error_message,
        "webhook_metadata": webhook.webhook_metadata,
        "version": webhook.version,
        "created_at": webhook.created_at.isoformat(),
        "updated_at": webhook.updated_at.isoformat(),
    }


class WebhookUpdateRequest(BaseModel):
    """Request schema for updating a webhook."""

    name: str | None = None
    description: str | None = None
    enabled_events: list[str] | None = None
    event_filters: dict[str, Any] | None = None
    callback_url: str | None = None
    callback_headers: dict[str, Any] | None = None
    default_suite_id: str | None = None
    rate_limit_per_minute: int | None = None
    auto_create_run: bool | None = None
    auto_complete_run: bool | None = None
    verify_signatures: bool | None = None
    metadata: dict[str, Any] | None = None


@app.put("/api/v1/webhooks/{webhook_id}")
async def update_webhook(
    request: Request,
    webhook_id: str,
    payload: WebhookUpdateRequest,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Update a webhook."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.webhook_repository import WebhookRepository

    repo = WebhookRepository(db)
    webhook = await repo.get_by_id(webhook_id)
    if not webhook:
        raise HTTPException(status_code=404, detail="Webhook not found")
    assert webhook is not None

    ensure_project_access(webhook.project_id, claims)

    updates = payload.model_dump(exclude_unset=True)
    if "metadata" in updates:
        updates["webhook_metadata"] = updates.pop("metadata")

    updated_webhook = await repo.update(webhook_id, **updates)
    await db.commit()

    if updated_webhook is None:
        raise HTTPException(status_code=404, detail="Webhook not found after update")
    return {
        "id": updated_webhook.id,
        "project_id": updated_webhook.project_id,
        "name": updated_webhook.name,
        "description": updated_webhook.description,
        "provider": updated_webhook.provider.value,
        "status": updated_webhook.status.value,
        "webhook_secret": updated_webhook.webhook_secret,
        "enabled_events": updated_webhook.enabled_events,
        "callback_url": updated_webhook.callback_url,
        "default_suite_id": updated_webhook.default_suite_id,
        "rate_limit_per_minute": updated_webhook.rate_limit_per_minute,
        "auto_create_run": updated_webhook.auto_create_run,
        "auto_complete_run": updated_webhook.auto_complete_run,
        "verify_signatures": updated_webhook.verify_signatures,
        "version": updated_webhook.version,
        "updated_at": updated_webhook.updated_at.isoformat(),
    }


class WebhookStatusRequest(BaseModel):
    """Request schema for updating webhook status."""

    status: str


@app.post("/api/v1/webhooks/{webhook_id}/status")
async def set_webhook_status(
    request: Request,
    webhook_id: str,
    payload: WebhookStatusRequest,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Update webhook status."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.webhook_repository import WebhookRepository

    repo = WebhookRepository(db)
    webhook = await repo.get_by_id(webhook_id)
    if not webhook:
        raise HTTPException(status_code=404, detail="Webhook not found")
    assert webhook is not None

    ensure_project_access(webhook.project_id, claims)

    if payload.status not in {"active", "paused", "disabled"}:
        raise HTTPException(status_code=400, detail="Invalid status")

    updated_webhook = await repo.set_status(webhook_id, payload.status)
    await db.commit()

    if updated_webhook is None:
        raise HTTPException(status_code=404, detail="Webhook not found after status update")
    return {
        "id": updated_webhook.id,
        "status": updated_webhook.status.value,
        "version": updated_webhook.version,
    }


@app.post("/api/v1/webhooks/{webhook_id}/regenerate-secret")
async def regenerate_webhook_secret(
    request: Request,
    webhook_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Regenerate the webhook secret."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.webhook_repository import WebhookRepository

    repo = WebhookRepository(db)
    webhook = await repo.get_by_id(webhook_id)
    if not webhook:
        raise HTTPException(status_code=404, detail="Webhook not found")
    assert webhook is not None

    ensure_project_access(webhook.project_id, claims)

    updated_webhook = await repo.regenerate_secret(webhook_id)
    await db.commit()

    if updated_webhook is None:
        raise HTTPException(status_code=404, detail="Webhook not found after secret regeneration")
    return {
        "id": updated_webhook.id,
        "webhook_secret": updated_webhook.webhook_secret,
        "version": updated_webhook.version,
    }


@app.delete("/api/v1/webhooks/{webhook_id}")
async def delete_webhook(
    request: Request,
    webhook_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Delete a webhook."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.webhook_repository import WebhookRepository

    repo = WebhookRepository(db)
    webhook = await repo.get_by_id(webhook_id)
    if not webhook:
        raise HTTPException(status_code=404, detail="Webhook not found")
    assert webhook is not None

    ensure_project_access(webhook.project_id, claims)

    await repo.delete(webhook_id)
    await db.commit()

    return {"success": True}


@app.get("/api/v1/webhooks/{webhook_id}/logs")
async def get_webhook_logs(
    request: Request,
    webhook_id: str,
    success: bool | None = None,
    event_type: str | None = None,
    skip: int = 0,
    limit: int = 50,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Get webhook logs."""
    enforce_rate_limit(request, claims)

    from tracertm.repositories.webhook_repository import WebhookRepository

    repo = WebhookRepository(db)
    webhook = await repo.get_by_id(webhook_id)
    if not webhook:
        raise HTTPException(status_code=404, detail="Webhook not found")
    assert webhook is not None

    ensure_project_access(webhook.project_id, claims)

    logs, total = await repo.get_logs(
        webhook_id=webhook_id,
        success=success,
        event_type=event_type,
        skip=skip,
        limit=limit,
    )

    return {
        "logs": [
            {
                "id": log.id,
                "webhook_id": log.webhook_id,
                "request_id": log.request_id,
                "event_type": log.event_type,
                "http_method": log.http_method,
                "source_ip": log.source_ip,
                "user_agent": log.user_agent,
                "request_headers": log.request_headers,
                "request_body_preview": log.request_body_preview,
                "payload_size_bytes": log.payload_size_bytes,
                "success": log.success,
                "status_code": log.status_code,
                "error_message": log.error_message,
                "processing_time_ms": log.processing_time_ms,
                "test_run_id": log.test_run_id,
                "results_submitted": log.results_submitted,
                "created_at": log.created_at.isoformat(),
            }
            for log in logs
        ],
        "total": total,
        "skip": skip,
        "limit": limit,
    }


@app.get("/api/v1/projects/{project_id}/webhooks/stats")
async def get_webhook_stats(
    request: Request,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get webhook statistics for a project."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from tracertm.repositories.webhook_repository import WebhookRepository

    repo = WebhookRepository(db)
    return await repo.get_stats(project_id)


# =============================================================================
# Inbound Webhook Endpoint (for CI/CD integrations)
# =============================================================================


@app.post("/api/v1/webhooks/inbound/{webhook_id}")
async def receive_inbound_webhook(
    request: Request,
    webhook_id: str,
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Receive inbound webhook from CI/CD systems.

    This endpoint does NOT require auth_guard - it uses HMAC signature verification.
    """
    from tracertm.services.webhook_service import WebhookService

    # Get raw body for signature verification
    raw_body = await request.body()

    # Parse JSON payload
    try:
        payload = await request.json()
    except ValueError:
        return {
            "success": False,
            "message": "Invalid JSON payload",
            "error": "Could not parse request body as JSON",
        }

    # Extract headers
    headers = dict[str, Any](request.headers)

    # Get signature from headers
    signature = (
        headers.get("x-hub-signature-256")
        or headers.get("x-hub-signature")
        or headers.get("x-signature")
        or headers.get("x-webhook-signature")
    )

    # Get client info
    source_ip = request.client.host if request.client else None
    user_agent = headers.get("user-agent")

    # Process the webhook
    service = WebhookService(db)
    result = await service.process_inbound_webhook(
        webhook_id=webhook_id,
        payload=payload,
        raw_payload=raw_body,
        signature=signature,
        headers=headers,
        source_ip=source_ip,
        user_agent=user_agent,
    )
    await db.commit()

    # Return appropriate HTTP status based on result
    if not result["success"]:
        if "Rate limit" in result.get("message", ""):
            raise HTTPException(status_code=429, detail=result)
        if "signature" in result.get("message", "").lower() or "not found" in result.get("message", "").lower():
            raise HTTPException(status_code=401, detail=result)
        raise HTTPException(status_code=400, detail=result)

    return result


# =============================================================================
# QA Metrics Dashboard Endpoints
# =============================================================================


@app.get("/api/v1/qa/metrics/summary")
async def get_qa_metrics_summary(
    request: Request,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get comprehensive QA metrics summary for dashboard."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from tracertm.repositories.test_case_repository import TestCaseRepository
    from tracertm.repositories.test_coverage_repository import TestCoverageRepository
    from tracertm.repositories.test_run_repository import TestRunRepository
    from tracertm.repositories.test_suite_repository import TestSuiteRepository

    test_case_repo = TestCaseRepository(db)
    test_suite_repo = TestSuiteRepository(db)
    test_run_repo = TestRunRepository(db)
    coverage_repo = TestCoverageRepository(db)

    # Gather stats from all repositories
    test_case_stats = await test_case_repo.get_stats(project_id)
    test_suite_stats = await test_suite_repo.get_stats(project_id)
    test_run_stats = await test_run_repo.get_stats(project_id)
    coverage_stats = await coverage_repo.get_stats(project_id)

    # Get coverage matrix for overall coverage percentage
    coverage_matrix = await coverage_repo.get_traceability_matrix(project_id)

    return {
        "project_id": project_id,
        "test_cases": {
            "total": test_case_stats.get("total", 0),
            "by_status": test_case_stats.get("by_status", {}),
            "by_priority": test_case_stats.get("by_priority", {}),
            "automated_count": test_case_stats.get("automated_count", 0),
            "manual_count": test_case_stats.get("manual_count", 0),
            "automation_percentage": (
                round(test_case_stats.get("automated_count", 0) / test_case_stats.get("total", 1) * 100, 1)
                if test_case_stats.get("total", 0) > 0
                else 0
            ),
        },
        "test_suites": {
            "total": test_suite_stats.get("total", 0),
            "by_status": test_suite_stats.get("by_status", {}),
            "total_test_cases": test_suite_stats.get("total_test_cases", 0),
        },
        "test_runs": {
            "total": test_run_stats.get("total_runs", 0),
            "by_status": test_run_stats.get("by_status", {}),
            "by_type": test_run_stats.get("by_type", {}),
            "average_pass_rate": test_run_stats.get("average_pass_rate", 0),
            "average_duration_seconds": test_run_stats.get("average_duration_seconds", 0),
        },
        "coverage": {
            "total_requirements": coverage_matrix.get("total_requirements", 0),
            "covered_requirements": coverage_matrix.get("covered_requirements", 0),
            "uncovered_requirements": coverage_matrix.get("uncovered_requirements", 0),
            "coverage_percentage": coverage_matrix.get("coverage_percentage", 0),
            "total_mappings": coverage_stats.get("total_mappings", 0),
            "by_type": coverage_stats.get("by_type", {}),
        },
    }


@app.get("/api/v1/qa/metrics/pass-rate")
async def get_pass_rate_trend(
    request: Request,
    project_id: str,
    days: int = 30,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Get pass rate trend data for charts."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from datetime import datetime, timedelta

    from sqlalchemy import and_, func, select

    from tracertm.models.test_run import TestRun

    # Get test runs from the last N days
    cutoff_date = datetime.now(UTC) - timedelta(days=days)

    result = await db.execute(
        select(
            func.date(TestRun.completed_at).label("date"),
            func.count().label("total_runs"),
            func.avg(TestRun.pass_rate).label("avg_pass_rate"),
            func.sum(TestRun.passed_count).label("total_passed"),
            func.sum(TestRun.failed_count).label("total_failed"),
        )
        .where(
            and_(
                TestRun.project_id == project_id,
                TestRun.completed_at >= cutoff_date,
                TestRun.completed_at.isnot(None),
            ),
        )
        .group_by(func.date(TestRun.completed_at))
        .order_by(func.date(TestRun.completed_at)),
    )

    rows = result.all()

    return {
        "project_id": project_id,
        "days": days,
        "trend": [
            {
                "date": str(row.date) if row.date else None,
                "total_runs": row.total_runs or 0,
                "avg_pass_rate": round(float(row.avg_pass_rate or 0), 2),
                "total_passed": row.total_passed or 0,
                "total_failed": row.total_failed or 0,
            }
            for row in rows
        ],
    }


@app.get("/api/v1/qa/metrics/coverage")
async def get_coverage_metrics(
    request: Request,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get detailed coverage metrics."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from tracertm.repositories.test_coverage_repository import TestCoverageRepository

    repo = TestCoverageRepository(db)

    # Get traceability matrix
    matrix = await repo.get_traceability_matrix(project_id)

    # Get coverage gaps
    gaps = await repo.get_coverage_gaps(project_id)

    # Get coverage by view/category
    stats = await repo.get_stats(project_id)

    # Calculate coverage by requirement view
    coverage_by_view = {}
    for item in matrix.get("matrix", []):
        view = item.get("requirement_view", "Unknown")
        if view not in coverage_by_view:
            coverage_by_view[view] = {"total": 0, "covered": 0, "percentage": 0.0}
        coverage_by_view[view]["total"] += 1
        if item.get("is_covered"):
            coverage_by_view[view]["covered"] += 1

    # Calculate percentages
    for stats in coverage_by_view.values():
        total = stats["total"]
        covered = stats["covered"]
        stats["percentage"] = round(covered / total * 100, 1) if total > 0 else 0.0

    return {
        "project_id": project_id,
        "overall": {
            "total_requirements": matrix.get("total_requirements", 0),
            "covered_requirements": matrix.get("covered_requirements", 0),
            "coverage_percentage": matrix.get("coverage_percentage", 0),
        },
        "by_view": coverage_by_view,
        "by_type": stats.get("by_type", {}),
        "gaps_count": gaps.get("uncovered_count", 0),
        "high_priority_gaps": len([g for g in gaps.get("gaps", []) if g.get("priority") in {"high", "critical"}]),
    }


@app.get("/api/v1/qa/metrics/defect-density")
async def get_defect_density(
    request: Request,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get defect density metrics (failed tests per test case)."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from sqlalchemy import func, select

    from tracertm.models.test_run import TestResult, TestRun

    # Get test results with failure info
    result = await db.execute(
        select(
            TestResult.test_case_id,
            func.count().label("total_executions"),
            func.sum(func.case((TestResult.status == "failed", 1), else_=0)).label("failure_count"),
        )
        .join(TestRun, TestRun.id == TestResult.run_id)
        .where(TestRun.project_id == project_id)
        .group_by(TestResult.test_case_id),
    )

    rows = result.all()

    # Calculate defect density
    test_cases_with_failures = []
    total_executions = 0
    total_failures = 0

    for row in rows:
        total_executions += row.total_executions or 0
        failures = row.failure_count or 0
        total_failures += failures

        if failures > 0:
            failure_rate = round(failures / (row.total_executions or 1) * 100, 2)
            test_cases_with_failures.append({
                "test_case_id": row.test_case_id,
                "total_executions": row.total_executions,
                "failure_count": failures,
                "failure_rate": failure_rate,
            })

    # Sort by failure count
    test_cases_with_failures.sort(key=operator.itemgetter("failure_count"), reverse=True)

    return {
        "project_id": project_id,
        "overall_defect_density": (round(total_failures / total_executions * 100, 2) if total_executions > 0 else 0),
        "total_executions": total_executions,
        "total_failures": total_failures,
        "test_cases_with_failures": len(test_cases_with_failures),
        "top_failing_tests": test_cases_with_failures[:10],
    }


@app.get("/api/v1/qa/metrics/flaky-tests")
async def get_flaky_tests(
    request: Request,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get flaky test analysis."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from sqlalchemy import and_, func, select

    from tracertm.models.test_run import TestResult, TestRun

    # Find tests marked as flaky
    flaky_result = await db.execute(
        select(
            TestResult.test_case_id,
            func.count().label("flaky_count"),
        )
        .join(TestRun, TestRun.id == TestResult.run_id)
        .where(
            and_(
                TestRun.project_id == project_id,
                TestResult.is_flaky.is_(True),
            ),
        )
        .group_by(TestResult.test_case_id)
        .order_by(func.count().desc()),
    )

    flaky_tests = [
        {
            "test_case_id": row.test_case_id,
            "flaky_occurrences": row.flaky_count,
        }
        for row in flaky_result.all()
    ]

    # Find tests with inconsistent results (passed then failed in same day)
    inconsistent_result = await db.execute(
        select(
            TestResult.test_case_id,
            func.date(TestResult.completed_at).label("date"),
            func.count(func.distinct(TestResult.status)).label("status_count"),
        )
        .join(TestRun, TestRun.id == TestResult.run_id)
        .where(
            and_(
                TestRun.project_id == project_id,
                TestResult.completed_at.isnot(None),
            ),
        )
        .group_by(TestResult.test_case_id, func.date(TestResult.completed_at))
        .having(func.count(func.distinct(TestResult.status)) > 1),
    )

    inconsistent_days = {}
    for row in inconsistent_result.all():
        tc_id = row.test_case_id
        if tc_id not in inconsistent_days:
            inconsistent_days[tc_id] = 0
        inconsistent_days[tc_id] += 1

    potentially_flaky = [
        {
            "test_case_id": tc_id,
            "inconsistent_days": count,
        }
        for tc_id, count in sorted(inconsistent_days.items(), key=operator.itemgetter(1), reverse=True)
    ][:20]

    return {
        "project_id": project_id,
        "marked_flaky": flaky_tests[:20],
        "marked_flaky_count": len(flaky_tests),
        "potentially_flaky": potentially_flaky,
        "potentially_flaky_count": len(inconsistent_days),
    }


@app.get("/api/v1/qa/metrics/execution-history")
async def get_execution_history(
    request: Request,
    project_id: str,
    days: int = 7,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Get recent test execution history."""
    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    from datetime import datetime, timedelta

    from sqlalchemy import and_, select

    from tracertm.models.test_run import TestRun

    cutoff_date = datetime.now(UTC) - timedelta(days=days)

    result = await db.execute(
        select(TestRun)
        .where(
            and_(
                TestRun.project_id == project_id,
                TestRun.created_at >= cutoff_date,
            ),
        )
        .order_by(TestRun.created_at.desc())
        .limit(50),
    )

    runs = result.scalars().all()

    return {
        "project_id": project_id,
        "days": days,
        "runs": [
            {
                "id": run.id,
                "run_number": run.run_number,
                "name": run.name,
                "status": run.status.value if hasattr(run.status, "value") else run.status,
                "run_type": run.run_type.value if hasattr(run.run_type, "value") else run.run_type,
                "environment": run.environment,
                "build_number": run.build_number,
                "branch": run.branch,
                "started_at": run.started_at.isoformat() if run.started_at else None,
                "completed_at": run.completed_at.isoformat() if run.completed_at else None,
                "duration_seconds": run.duration_seconds,
                "total_tests": run.total_tests,
                "passed_count": run.passed_count,
                "failed_count": run.failed_count,
                "pass_rate": run.pass_rate,
            }
            for run in runs
        ],
    }


# ==================== EXTERNAL INTEGRATIONS ====================


@app.post("/api/v1/integrations/oauth/start")
async def start_oauth_flow(
    request: Request,
    data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    _db: Annotated[AsyncSession, Depends(get_db)],
):
    """Start OAuth flow for an external integration provider."""
    import secrets
    import urllib.parse

    from tracertm.schemas.integration import IntegrationProvider

    enforce_rate_limit(request, claims)

    project_id = data.get("project_id")
    provider = data.get("provider")
    redirect_uri = data.get("redirect_uri")
    scopes = data.get("scopes", [])
    credential_scope = data.get("credential_scope", "project")

    if project_id:
        ensure_project_access(project_id, claims)

    # Validate provider
    valid_providers = [p.value for p in IntegrationProvider]
    if provider not in valid_providers:
        raise HTTPException(status_code=400, detail=f"Invalid provider: {provider}")

    # Generate state token for CSRF protection
    state = secrets.token_urlsafe(32)

    # Store state in session or temporary storage (in production, use Redis/DB)
    # For now, encode project_id and provider in state
    state_data = f"{credential_scope}:{project_id or ''}:{provider}:{state}"

    # Build OAuth URL based on provider
    if provider == "github":
        client_id = os.environ.get("GITHUB_CLIENT_ID", "")
        oauth_url = "https://github.com/login/oauth/authorize"
        default_scopes = ["repo", "read:org", "read:user", "project"]
        scope_str = " ".join(scopes or default_scopes)
        params = {
            "client_id": client_id,
            "redirect_uri": redirect_uri,
            "scope": scope_str,
            "state": state_data,
        }
    elif provider == "linear":
        client_id = os.environ.get("LINEAR_CLIENT_ID", "")
        oauth_url = "https://linear.app/oauth/authorize"
        default_scopes = ["read", "write", "issues:create", "comments:create"]
        scope_str = ",".join(scopes or default_scopes)
        params = {
            "client_id": client_id,
            "redirect_uri": redirect_uri,
            "scope": scope_str,
            "state": state_data,
            "response_type": "code",
        }
    else:
        raise HTTPException(status_code=400, detail=f"OAuth not supported for {provider}")

    auth_url = f"{oauth_url}?{urllib.parse.urlencode(params)}"

    return {
        "auth_url": auth_url,
        "state": state_data,
        "provider": provider,
    }


@app.post("/api/v1/integrations/oauth/callback")
async def oauth_callback(
    request: Request,
    data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Handle OAuth callback and store credentials."""
    from tracertm.api.handlers.oauth import oauth_callback as oauth_callback_handler

    return await oauth_callback_handler(
        request=request,
        data=data,
        claims=claims,
        db=db,
        ensure_project_access_fn=ensure_project_access,
    )


@app.get("/api/v1/integrations/credentials")
async def list_credentials(
    request: Request,
    project_id: str | None = None,
    include_global: bool = True,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List integration credentials for a project."""
    from tracertm.repositories.integration_repository import IntegrationCredentialRepository
    from tracertm.services.encryption_service import EncryptionService

    enforce_rate_limit(request, claims)
    if project_id:
        ensure_project_access(project_id, claims)

    encryption_key = os.environ.get("ENCRYPTION_KEY", "")
    encryption_service = EncryptionService(encryption_key) if encryption_key else None
    repo = IntegrationCredentialRepository(db, encryption_service)

    if project_id:
        sub = claims.get("sub") if include_global else None
        credentials = await repo.get_by_project(
            project_id,
            include_global_user_id=sub if isinstance(sub, str) else None,
        )
    else:
        sub = claims.get("sub")
        if not isinstance(sub, str):
            credentials = []
        else:
            credentials = await repo.list_by_user(sub)

    return {
        "credentials": [
            {
                "id": c.id,
                "provider": c.provider,
                "credential_type": c.credential_type,
                "status": c.status,
                "scopes": c.scopes,
                "provider_user_id": c.provider_user_id,
                "provider_metadata": c.provider_metadata,
                "last_validated_at": c.last_validated_at.isoformat() if c.last_validated_at else None,
                "created_at": c.created_at.isoformat() if c.created_at else None,
            }
            for c in credentials
        ],
        "total": len(credentials),
    }


@app.post("/api/v1/integrations/credentials/{credential_id}/validate")
async def validate_credential(
    request: Request,
    credential_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Validate an integration credential."""
    from datetime import datetime

    from tracertm.repositories.integration_repository import IntegrationCredentialRepository
    from tracertm.services.encryption_service import EncryptionService

    enforce_rate_limit(request, claims)

    encryption_key = os.environ.get("ENCRYPTION_KEY", "")
    if not encryption_key:
        raise HTTPException(status_code=500, detail="Encryption key not configured")

    encryption_service = EncryptionService(encryption_key)
    repo = IntegrationCredentialRepository(db, encryption_service)

    credential = await repo.get_by_id(credential_id)
    if not credential:
        raise HTTPException(status_code=404, detail="Credential not found")

    ensure_credential_access(credential, claims)

    # Decrypt token and validate
    token = repo.decrypt_token(credential)
    valid = False
    user_info = {}
    error = None

    try:
        client: object = None
        if credential.provider == "github":
            from tracertm.clients.github_client import GitHubClient

            client = GitHubClient(token)
            try:
                user_info = await client.get_authenticated_user()
                valid = True
            finally:
                await client.close()
        elif credential.provider == "linear":
            from tracertm.clients.linear_client import LinearClient

            client = LinearClient(token)
            try:
                user_info = await client.get_viewer()
                valid = True
            finally:
                await client.close()
    except (ImportError, OSError, RuntimeError, TypeError, ValueError) as e:
        error = str(e)

    # Update validation status
    await repo.update_validation_status(
        credential_id,
        valid=valid,
        error=error,
    )

    return {
        "valid": valid,
        "credential_id": credential_id,
        "provider": credential.provider,
        "user": user_info if valid else None,
        "error": error,
        "validated_at": datetime.now(UTC).isoformat(),
    }


@app.delete("/api/v1/integrations/credentials/{credential_id}")
async def delete_credential(
    request: Request,
    credential_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Delete an integration credential."""
    from tracertm.repositories.integration_repository import IntegrationCredentialRepository
    from tracertm.services.encryption_service import EncryptionService

    enforce_rate_limit(request, claims)

    encryption_key = os.environ.get("ENCRYPTION_KEY", "")
    encryption_service = EncryptionService(encryption_key) if encryption_key else None
    repo = IntegrationCredentialRepository(db, encryption_service)

    credential = await repo.get_by_id(credential_id)
    if not credential:
        raise HTTPException(status_code=404, detail="Credential not found")

    ensure_credential_access(credential, claims)

    await repo.delete(credential_id)

    return {"success": True, "deleted_id": credential_id}


# ==================== INTEGRATION MAPPINGS ====================


@app.get("/api/v1/integrations/mappings")
async def list_mappings(
    request: Request,
    project_id: str,
    provider: str | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List integration mappings for a project."""
    from tracertm.repositories.integration_repository import IntegrationMappingRepository

    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    repo = IntegrationMappingRepository(db)
    mappings, _total = await repo.list_by_project(project_id, external_system=provider)

    return {
        "mappings": [
            {
                "id": m.id,
                "credential_id": m.integration_credential_id,
                "provider": m.external_system,
                "direction": m.direction,
                "local_item_id": m.project_id if m.tracertm_item_type == "project_root" else m.tracertm_item_id,
                "local_item_type": "project" if m.tracertm_item_type == "project_root" else m.tracertm_item_type,
                "external_id": m.external_id,
                "external_type": m.external_system,
                "external_url": m.external_url,
                "external_key": (m.mapping_metadata or {}).get("external_key"),
                "mapping_metadata": m.mapping_metadata or {},
                "status": m.status,
                "sync_enabled": m.auto_sync,
                "last_synced_at": m.last_sync_at.isoformat() if m.last_sync_at else None,
                "field_mappings": (m.mapping_metadata or {}).get("field_mappings"),
                "created_at": m.created_at.isoformat() if m.created_at else None,
            }
            for m in mappings
        ],
        "total": len(mappings),
    }


@app.post("/api/v1/integrations/mappings")
async def create_mapping(
    request: Request,
    data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create a new integration mapping."""
    from tracertm.repositories.integration_repository import (
        IntegrationCredentialRepository,
        IntegrationMappingRepository,
    )
    from tracertm.services.encryption_service import EncryptionService

    enforce_rate_limit(request, claims)

    credential_id = data.get("credential_id")
    if not credential_id:
        raise HTTPException(status_code=400, detail="credential_id required")
    assert isinstance(credential_id, str)
    local_item_id = data.get("local_item_id")
    local_item_type = data.get("local_item_type")
    project_id = data.get("project_id")
    external_id = data.get("external_id")
    external_type = data.get("external_type")
    direction = data.get("direction", "bidirectional")
    mapping_metadata = data.get("mapping_metadata") or {}

    if not project_id and local_item_type == "project":
        project_id = local_item_id

    if not project_id:
        raise HTTPException(status_code=400, detail="project_id required")

    # Validate credential
    encryption_key = os.environ.get("ENCRYPTION_KEY", "")
    encryption_service = EncryptionService(encryption_key) if encryption_key else None
    cred_repo = IntegrationCredentialRepository(db, encryption_service)

    credential = await cred_repo.get_by_id(credential_id)
    if not credential:
        raise HTTPException(status_code=404, detail="Credential not found")

    ensure_credential_access(credential, claims)

    ensure_project_access(project_id, claims)

    tracertm_item_id = local_item_id
    tracertm_item_type = local_item_type
    if local_item_type == "project":
        from sqlalchemy import select

        from tracertm.models.item import Item
        from tracertm.repositories.item_repository import ItemRepository
        from tracertm.repositories.project_repository import ProjectRepository

        result = await db.execute(
            select(Item).where(
                Item.project_id == project_id,
                Item.item_type == "project_root",
                Item.title == "Integration Root",
            ),
        )
        root_item = result.scalar_one_or_none()
        if not root_item:
            project_repo = ProjectRepository(db)
            project = await project_repo.get_by_id(project_id)
            title = f"{project.name} Integration Root" if project else "Integration Root"
            item_repo = ItemRepository(db)
            root_item = await item_repo.create(
                project_id=project_id,
                title=title,
                view="configuration",
                item_type="project_root",
                description="Synthetic root item for project-level integrations.",
                status="active",
                priority="low",
            )
        tracertm_item_id = root_item.id
        tracertm_item_type = "project_root"

    # Create mapping
    repo = IntegrationMappingRepository(db)
    mapping = await repo.create(
        project_id=str(project_id),
        credential_id=str(credential_id),
        tracertm_item_id=str(tracertm_item_id),
        tracertm_item_type=str(tracertm_item_type),
        external_system=str(external_type),
        external_id=str(external_id) if external_id is not None else "",
        external_url=data.get("external_url") or "",
        direction=direction,
        auto_sync=data.get("sync_enabled", True),
        mapping_metadata={
            **mapping_metadata,
            "external_key": data.get("external_key"),
            "field_mappings": data.get("field_mappings"),
        },
    )

    return {
        "id": mapping.id,
        "credential_id": mapping.integration_credential_id,
        "provider": mapping.external_system,
        "direction": mapping.direction,
        "local_item_id": local_item_id,
        "local_item_type": local_item_type,
        "external_id": mapping.external_id,
        "external_url": mapping.external_url,
        "status": mapping.status,
        "mapping_metadata": mapping.mapping_metadata or {},
        "created_at": mapping.created_at.isoformat() if mapping.created_at else None,
    }


@app.put("/api/v1/integrations/mappings/{mapping_id}")
async def update_mapping(
    request: Request,
    mapping_id: str,
    data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Update an integration mapping."""
    from tracertm.repositories.integration_repository import (
        IntegrationCredentialRepository,
        IntegrationMappingRepository,
    )
    from tracertm.services.encryption_service import EncryptionService

    enforce_rate_limit(request, claims)

    repo = IntegrationMappingRepository(db)
    mapping = await repo.get_by_id(mapping_id)
    if not mapping:
        raise HTTPException(status_code=404, detail="Mapping not found")

    # Validate access via credential
    encryption_key = os.environ.get("ENCRYPTION_KEY", "")
    encryption_service = EncryptionService(encryption_key) if encryption_key else None
    cred_repo = IntegrationCredentialRepository(db, encryption_service)

    credential = await cred_repo.get_by_id(mapping.integration_credential_id)
    if credential:
        ensure_credential_access(credential, claims)

    # Update mapping (store field mappings inside mapping_metadata)
    updates: dict[str, Any] = {
        "direction": data.get("direction"),
        "status": data.get("status"),
    }
    if "sync_enabled" in data:
        updates["auto_sync"] = data.get("sync_enabled")
    if "field_mappings" in data:
        mapping_metadata = mapping.mapping_metadata or {}
        mapping_metadata["field_mappings"] = data.get("field_mappings")
        updates["mapping_metadata"] = mapping_metadata
    if "mapping_metadata" in data:
        mapping_metadata = mapping.mapping_metadata or {}
        mapping_metadata.update(data.get("mapping_metadata") or {})
        updates["mapping_metadata"] = mapping_metadata

    await repo.update(mapping_id, **updates)
    updated = await repo.get_by_id(mapping_id)

    if not updated:
        raise HTTPException(status_code=404, detail="Updated mapping not found")

    return {
        "id": updated.id,
        "direction": updated.direction,
        "sync_enabled": updated.auto_sync,
        "status": updated.status,
        "updated_at": updated.updated_at.isoformat() if updated.updated_at else None,
    }


@app.delete("/api/v1/integrations/mappings/{mapping_id}")
async def delete_mapping(
    request: Request,
    mapping_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Delete an integration mapping."""
    from tracertm.repositories.integration_repository import (
        IntegrationCredentialRepository,
        IntegrationMappingRepository,
    )
    from tracertm.services.encryption_service import EncryptionService

    enforce_rate_limit(request, claims)

    repo = IntegrationMappingRepository(db)
    mapping = await repo.get_by_id(mapping_id)
    if not mapping:
        raise HTTPException(status_code=404, detail="Mapping not found")

    # Validate access via credential
    encryption_key = os.environ.get("ENCRYPTION_KEY", "")
    encryption_service = EncryptionService(encryption_key) if encryption_key else None
    cred_repo = IntegrationCredentialRepository(db, encryption_service)

    credential = await cred_repo.get_by_id(mapping.integration_credential_id)
    if credential:
        ensure_credential_access(credential, claims)

    await repo.delete(mapping_id)

    return {"success": True, "deleted_id": mapping_id}


# ==================== SYNC MANAGEMENT ====================


@app.get("/api/v1/integrations/sync/status")
async def get_integration_sync_status(
    request: Request,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get sync status summary for a project."""
    from sqlalchemy import func, select

    from tracertm.models.integration import (
        IntegrationMapping,
        IntegrationSyncLog,
        IntegrationSyncQueue,
    )
    from tracertm.repositories.integration_repository import (
        IntegrationCredentialRepository,
    )
    from tracertm.services.encryption_service import EncryptionService

    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    # Get credentials for this project
    encryption_key = os.environ.get("ENCRYPTION_KEY", "")
    encryption_service = EncryptionService(encryption_key) if encryption_key else None
    cred_repo = IntegrationCredentialRepository(db, encryption_service)
    credentials = await cred_repo.get_by_project(project_id, include_global_user_id=claims.get("sub"))

    # Get queue stats
    queue_result = await db.execute(
        select(IntegrationSyncQueue.status, func.count().label("count"))
        .join(IntegrationMapping)
        .where(IntegrationMapping.project_id == project_id)
        .group_by(IntegrationSyncQueue.status),
    )

    queue_stats = {"pending": 0, "processing": 0, "failed": 0, "completed": 0}
    for row in queue_result.all():
        status_val = row[0]
        count_val = row[1]
        queue_stats[str(status_val)] = int(cast("int | str", count_val))

    # Get recent sync logs
    log_result = await db.execute(
        select(IntegrationSyncLog)
        .join(IntegrationMapping)
        .where(IntegrationMapping.project_id == project_id)
        .order_by(IntegrationSyncLog.created_at.desc())
        .limit(10),
    )

    recent_syncs = [
        {
            "id": log.id,
            "provider": log.source_system,
            "event_type": log.operation,
            "direction": log.direction,
            "status": "completed" if log.success else "failed",
            "items_processed": 1 if log.success else 0,
            "items_failed": 0 if log.success else 1,
            "error_message": log.error_message,
            "started_at": log.created_at.isoformat() if log.created_at else None,
            "completed_at": log.created_at.isoformat() if log.created_at else None,
        }
        for log in log_result.scalars().all()
    ]

    # Provider summary
    providers = [
        {
            "provider": c.provider,
            "status": c.status,
            "last_validated": c.last_validated_at.isoformat() if c.last_validated_at else None,
        }
        for c in credentials
    ]

    return {
        "project_id": project_id,
        "queue": queue_stats,
        "recent_syncs": recent_syncs,
        "providers": providers,
    }


@app.post("/api/v1/integrations/sync/trigger")
async def trigger_sync(
    request: Request,
    data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Trigger a manual sync for a mapping or credential."""
    from tracertm.repositories.integration_repository import (
        IntegrationCredentialRepository,
        IntegrationMappingRepository,
        IntegrationSyncQueueRepository,
    )
    from tracertm.services.encryption_service import EncryptionService

    enforce_rate_limit(request, claims)

    mapping_id = data.get("mapping_id")
    credential_id = data.get("credential_id")
    if not credential_id:
        raise HTTPException(status_code=400, detail="credential_id required")
    assert isinstance(credential_id, str)
    direction = data.get("direction", "pull")

    if not mapping_id:
        raise HTTPException(status_code=400, detail="mapping_id required")

    encryption_key = os.environ.get("ENCRYPTION_KEY", "")
    encryption_service = EncryptionService(encryption_key) if encryption_key else None
    cred_repo = IntegrationCredentialRepository(db, encryption_service)

    mapping_repo = IntegrationMappingRepository(db)
    mapping = await mapping_repo.get_by_id(mapping_id)
    if not mapping:
        raise HTTPException(status_code=404, detail="Mapping not found")
    credential_id = mapping.integration_credential_id

    credential = await cred_repo.get_by_id(credential_id)
    if not credential:
        raise HTTPException(status_code=404, detail="Credential not found")

    ensure_credential_access(credential, claims)

    # Create sync queue item
    queue_repo = IntegrationSyncQueueRepository(db)
    queue_item = await queue_repo.enqueue(
        credential_id=credential_id,
        mapping_id=mapping_id,
        event_type="manual_sync",
        direction=direction,
        payload=data.get("payload", {}),
        priority="high",
    )

    return {
        "queued": True,
        "queue_id": queue_item.id,
        "provider": credential.provider,
        "direction": direction,
    }


@app.get("/api/v1/integrations/sync/queue")
async def get_sync_queue(
    request: Request,
    project_id: str,
    status: str | None = None,
    limit: int = 50,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """Get sync queue items for a project."""
    from tracertm.repositories.integration_repository import (
        IntegrationSyncQueueRepository,
    )

    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    queue_repo = IntegrationSyncQueueRepository(db)
    items, _total = await queue_repo.list_by_project(project_id, status=status, limit=limit)

    priority_map = {"low": 0, "normal": 1, "high": 2, "critical": 3}
    mapping_lookup = {}
    if items:
        from sqlalchemy import select

        from tracertm.models.integration import IntegrationMapping

        mapping_ids = list({item.mapping_id for item in items})
        result = await db.execute(select(IntegrationMapping).where(IntegrationMapping.id.in_(mapping_ids)))
        mapping_lookup = {m.id: m for m in result.scalars().all()}

    return {
        "items": [
            {
                "id": item.id,
                "provider": getattr(mapping_lookup.get(item.mapping_id), "external_system", "unknown"),
                "event_type": item.event_type,
                "direction": item.direction,
                "status": item.status,
                "priority": priority_map.get(item.priority, 1),
                "retry_count": item.attempts,
                "error_message": item.error_message,
                "scheduled_at": item.next_retry_at.isoformat() if item.next_retry_at else None,
                "created_at": item.created_at.isoformat() if item.created_at else None,
            }
            for item in items
        ],
        "total": len(items),
    }


# ==================== CONFLICT RESOLUTION ====================


@app.get("/api/v1/integrations/conflicts")
async def list_conflicts(
    request: Request,
    project_id: str,
    status: str = "pending",
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List sync conflicts for a project."""
    from tracertm.repositories.integration_repository import (
        IntegrationConflictRepository,
        IntegrationMappingRepository,
    )

    enforce_rate_limit(request, claims)
    ensure_project_access(project_id, claims)

    conflict_repo = IntegrationConflictRepository(db)
    if status != "pending":
        return {"conflicts": [], "total": 0}
    conflicts, _total = await conflict_repo.list_pending_by_project(project_id)

    mapping_repo = IntegrationMappingRepository(db)
    mappings = {}
    if conflicts:
        mapping_ids = list({c.mapping_id for c in conflicts})
        for mapping_id in mapping_ids:
            mapping = await mapping_repo.get_by_id(mapping_id)
            if mapping:
                mappings[mapping_id] = mapping

    return {
        "conflicts": [
            {
                "id": c.id,
                "mapping_id": c.mapping_id,
                "provider": getattr(mappings.get(c.mapping_id), "external_system", "unknown"),
                "conflict_type": "field_mismatch",
                "field_name": c.field,
                "local_value": c.tracertm_value,
                "external_value": c.external_value,
                "status": c.resolution_status,
                "resolved_value": c.resolved_value,
                "resolved_at": c.resolved_at.isoformat() if c.resolved_at else None,
                "created_at": c.detected_at.isoformat() if c.detected_at else None,
            }
            for c in conflicts
        ],
        "total": len(conflicts),
    }


@app.post("/api/v1/integrations/conflicts/{conflict_id}/resolve")
async def resolve_conflict(
    request: Request,
    conflict_id: str,
    data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Resolve a sync conflict."""
    from tracertm.repositories.integration_repository import (
        IntegrationConflictRepository,
        IntegrationCredentialRepository,
        IntegrationMappingRepository,
    )
    from tracertm.services.encryption_service import EncryptionService

    enforce_rate_limit(request, claims)

    resolution = data.get("resolution")  # "local", "external", "merge", "skip"
    merged_value = data.get("merged_value")

    if resolution not in {"local", "external", "merge", "skip"}:
        raise HTTPException(status_code=400, detail="Invalid resolution strategy")

    if resolution == "merge" and not merged_value:
        raise HTTPException(status_code=400, detail="Merged value required for merge resolution")

    conflict_repo = IntegrationConflictRepository(db)
    conflict = await conflict_repo.get_by_id(conflict_id)
    if not conflict:
        raise HTTPException(status_code=404, detail="Conflict not found")

    # Validate access via mapping
    mapping_repo = IntegrationMappingRepository(db)
    mapping = await mapping_repo.get_by_id(conflict.mapping_id)
    if mapping:
        encryption_key = os.environ.get("ENCRYPTION_KEY", "")
        encryption_service = EncryptionService(encryption_key) if encryption_key else None
        cred_repo = IntegrationCredentialRepository(db, encryption_service)
        credential = await cred_repo.get_by_id(mapping.integration_credential_id)
        if credential:
            ensure_credential_access(credential, claims)

    # Resolve conflict
    if resolution == "skip":
        await conflict_repo.ignore(conflict_id)
    else:
        resolved_value = (
            merged_value
            if resolution == "merge"
            else (conflict.tracertm_value if resolution == "local" else conflict.external_value)
        )
        await conflict_repo.resolve(
            conflict_id,
            resolved_value=str(resolved_value) if resolved_value is not None else "",
            strategy_used=resolution,
        )

    resolved = await conflict_repo.get_by_id(conflict_id)

    return {
        "resolved": True,
        "conflict_id": conflict_id,
        "resolution": resolution,
        "resolved_at": resolved.resolved_at.isoformat() if resolved and resolved.resolved_at else None,
    }


# ==================== EXTERNAL ITEM DISCOVERY ====================


@app.get("/api/v1/integrations/github/repos")
async def list_github_repos(
    request: Request,
    account_id: str | None = None,
    installation_id: str | None = None,
    credential_id: str | None = None,
    search: str | None = None,
    per_page: int = 30,
    page: int = 1,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List GitHub repositories accessible via GitHub App installation or OAuth credential."""
    from tracertm.clients.github_client import GitHubClient
    from tracertm.config.github_app import get_github_app_config
    from tracertm.repositories.account_repository import AccountRepository
    from tracertm.repositories.github_app_repository import GitHubAppInstallationRepository
    from tracertm.repositories.integration_repository import IntegrationCredentialRepository
    from tracertm.services.encryption_service import EncryptionService

    enforce_rate_limit(request, claims)

    user_id = claims.get("sub") if isinstance(claims, dict) else None
    if account_id and user_id:
        # Verify user has access to account
        account_repo = AccountRepository(db)
        role = await account_repo.get_user_role(account_id, user_id)
        if not role:
            raise HTTPException(status_code=403, detail="Access denied to this account")

    client: GitHubClient | None = None
    repos: list[dict] = []

    try:
        # Prefer GitHub App installation if provided
        if installation_id:
            installation_repo = GitHubAppInstallationRepository(db)
            installation = await installation_repo.get_by_id(installation_id)

            if not installation:
                raise HTTPException(status_code=404, detail="Installation not found")

            if account_id and installation.account_id != account_id:
                raise HTTPException(status_code=403, detail="Installation does not belong to this account")

            config = get_github_app_config()
            if not config.is_configured():
                raise HTTPException(status_code=500, detail="GitHub App is not configured")

            # Create client from app installation
            client = await GitHubClient.from_app_installation(
                app_id=config.app_id,
                private_key=config.private_key,
                installation_id=installation.installation_id,
            )

            if search:
                result = await client.search_repos(
                    query=search,
                    per_page=per_page,
                    page=page,
                )
                repos = cast("list[dict[Any, Any]]", result.get("items", []))
            else:
                repos_result = await client.list_installation_repos(
                    _installation_id=installation.installation_id,
                    per_page=per_page,
                    page=page,
                )
                # Handle both formats: { repositories: [...] } and list
                if isinstance(repos_result, dict):
                    repos = cast("list[dict[Any, Any]]", repos_result.get("repositories", []))
                else:
                    repos = repos_result if isinstance(repos_result, list) else []

        # Fallback to OAuth credential
        elif credential_id:
            encryption_key = os.environ.get("ENCRYPTION_KEY", "")
            if not encryption_key:
                raise HTTPException(status_code=500, detail="Encryption key not configured")

            encryption_service = EncryptionService(encryption_key)
            cred_repo = IntegrationCredentialRepository(db, encryption_service)

            credential = await cred_repo.get_by_id(credential_id)
            if not credential:
                raise HTTPException(status_code=404, detail="Credential not found")

            ensure_credential_access(credential, claims)

            if credential.provider != "github":
                raise HTTPException(status_code=400, detail="Credential is not for GitHub")

            token = cred_repo.decrypt_token(credential)
            client = GitHubClient(token)

            if search:
                result = await client.search_repos(
                    query=search,
                    per_page=per_page,
                    page=page,
                )
                repos = cast("list[dict[Any, Any]]", result.get("items", []))
            else:
                repos = cast("list[dict[Any, Any]]", await client.list_user_repos(
                    per_page=per_page,
                    page=page,
                ))
        else:
            raise HTTPException(status_code=400, detail="Either installation_id or credential_id is required")
    finally:
        if client:
            await client.close()

    return {
        "repos": [
            {
                "id": r["id"],
                "name": r["name"],
                "full_name": r["full_name"],
                "description": r.get("description"),
                "html_url": r["html_url"],
                "private": r["private"],
                "owner": {
                    "login": r["owner"]["login"],
                    "avatar_url": r["owner"]["avatar_url"],
                },
                "default_branch": r.get("default_branch", "main"),
                "updated_at": r.get("updated_at"),
            }
            for r in repos
        ],
        "page": page,
        "per_page": per_page,
    }


@app.post("/api/v1/integrations/github/repos")
async def create_github_repo(
    request: Request,
    data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create a new GitHub repository."""
    from tracertm.clients.github_client import GitHubClient
    from tracertm.config.github_app import get_github_app_config
    from tracertm.repositories.account_repository import AccountRepository
    from tracertm.repositories.github_app_repository import GitHubAppInstallationRepository

    enforce_rate_limit(request, claims)

    user_id = claims.get("sub") if isinstance(claims, dict) else None
    if not user_id:
        raise HTTPException(status_code=401, detail="Authentication required")

    installation_id = data.get("installation_id")
    account_id = data.get("account_id")
    name = data.get("name")
    description = data.get("description")
    private = data.get("private", False)
    org = data.get("org")  # Optional organization name

    if not installation_id or not name:
        raise HTTPException(status_code=400, detail="installation_id and name are required")

    # Verify user has access to account
    if account_id:
        account_repo = AccountRepository(db)
        role = await account_repo.get_user_role(account_id, user_id)
        if not role:
            raise HTTPException(status_code=403, detail="Access denied to this account")

    installation_repo = GitHubAppInstallationRepository(db)
    installation = await installation_repo.get_by_id(installation_id)

    if not installation:
        raise HTTPException(status_code=404, detail="Installation not found")

    if account_id and installation.account_id != account_id:
        raise HTTPException(status_code=403, detail="Installation does not belong to this account")

    config = get_github_app_config()
    if not config.is_configured():
        raise HTTPException(status_code=500, detail="GitHub App is not configured")

    # Create client from app installation
    client = await GitHubClient.from_app_installation(
        app_id=config.app_id,
        private_key=config.private_key,
        installation_id=installation.installation_id,
    )

    try:
        created_repo = cast("dict[str, Any]", await client.create_repo(
            name=name,
            description=description,
            private=private,
            org=org or installation.account_login if installation.target_type == "Organization" else None,
        ))
    finally:
        await client.close()

    owner_info = cast("dict[str, Any]", created_repo.get("owner", {}))
    return {
        "id": created_repo["id"],
        "name": created_repo["name"],
        "full_name": created_repo["full_name"],
        "description": created_repo.get("description"),
        "html_url": created_repo["html_url"],
        "private": created_repo["private"],
        "owner": {
            "login": owner_info["login"],
            "avatar_url": owner_info["avatar_url"],
        },
        "default_branch": created_repo.get("default_branch", "main"),
        "created_at": created_repo.get("created_at"),
    }


@app.get("/api/v1/integrations/github/repos/{owner}/{repo}/issues")
async def list_github_issues(
    request: Request,
    owner: str,
    repo: str,
    credential_id: str,
    state: str = "open",
    per_page: int = 30,
    page: int = 1,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List GitHub issues for a repository."""
    from tracertm.clients.github_client import GitHubClient, IssueListParams
    from tracertm.repositories.integration_repository import IntegrationCredentialRepository
    from tracertm.services.encryption_service import EncryptionService

    enforce_rate_limit(request, claims)

    encryption_key = os.environ.get("ENCRYPTION_KEY", "")
    if not encryption_key:
        raise HTTPException(status_code=500, detail="Encryption key not configured")

    encryption_service = EncryptionService(encryption_key)
    cred_repo = IntegrationCredentialRepository(db, encryption_service)

    credential = await cred_repo.get_by_id(credential_id)
    if not credential:
        raise HTTPException(status_code=404, detail="Credential not found")

    ensure_credential_access(credential, claims)

    token = cred_repo.decrypt_token(credential)
    client = GitHubClient(token)

    try:
        issues = cast("list[dict[str, Any]]", await client.list_issues(
            owner=owner,
            repo=repo,
            params=IssueListParams(state=state, per_page=per_page, page=page),
        ))
    finally:
        await client.close()

    return {
        "issues": [
            {
                "id": i["id"],
                "number": i["number"],
                "title": i["title"],
                "state": i["state"],
                "html_url": i["html_url"],
                "body": i.get("body", "")[:500] if i.get("body") else None,
                "user": {
                    "login": i["user"]["login"],
                    "avatar_url": i["user"]["avatar_url"],
                },
                "labels": [l["name"] for l in i.get("labels", [])],
                "assignees": [a["login"] for a in i.get("assignees", [])],
                "created_at": i["created_at"],
                "updated_at": i["updated_at"],
            }
            for i in issues
            if "pull_request" not in i  # Filter out PRs
        ],
        "page": page,
        "per_page": per_page,
    }


# ==================== GITHUB APP INSTALLATION ====================


@app.get("/api/v1/integrations/github/app/install-url")
async def get_github_app_install_url(
    account_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get GitHub App installation URL for an account."""
    from tracertm.config.github_app import get_github_app_config
    from tracertm.repositories.account_repository import AccountRepository

    user_id = claims.get("sub") if isinstance(claims, dict) else None
    if not user_id:
        raise HTTPException(status_code=401, detail="Authentication required")

    # Verify user has access to account
    account_repo = AccountRepository(db)
    role = await account_repo.get_user_role(account_id, user_id)
    if not role:
        raise HTTPException(status_code=403, detail="Access denied to this account")

    config = get_github_app_config()
    if not config.is_configured():
        raise HTTPException(status_code=500, detail="GitHub App is not configured")

    # Generate state token with account_id
    import secrets

    state = secrets.token_urlsafe(32)
    state_data = f"{account_id}:{state}"

    install_url = config.get_installation_url(state=state_data)

    return {
        "install_url": install_url,
        "state": state_data,
    }


@app.post("/api/v1/integrations/github/app/webhook")
async def github_app_webhook_endpoint(
    request: Request,
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Handle GitHub App webhook events."""
    from tracertm.api.handlers.webhooks import github_app_webhook

    return await github_app_webhook(request, db)


@app.get("/api/v1/integrations/github/app/installations")
async def list_github_app_installations(
    account_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """List GitHub App installations for an account."""
    from tracertm.repositories.account_repository import AccountRepository
    from tracertm.repositories.github_app_repository import GitHubAppInstallationRepository

    user_id = claims.get("sub") if isinstance(claims, dict) else None
    if not user_id:
        raise HTTPException(status_code=401, detail="Authentication required")

    # Verify user has access to account
    account_repo = AccountRepository(db)
    role = await account_repo.get_user_role(account_id, user_id)
    if not role:
        raise HTTPException(status_code=403, detail="Access denied to this account")

    installation_repo = GitHubAppInstallationRepository(db)
    installations = await installation_repo.list_by_account(account_id)

    return {
        "installations": [
            {
                "id": inst.id,
                "installation_id": inst.installation_id,
                "account_login": inst.account_login,
                "target_type": inst.target_type,
                "permissions": inst.permissions,
                "repository_selection": inst.repository_selection,
                "suspended_at": inst.suspended_at.isoformat() if inst.suspended_at else None,
                "created_at": inst.created_at.isoformat(),
            }
            for inst in installations
        ],
        "total": len(installations),
    }


@app.post("/api/v1/integrations/github/app/installations/{installation_id}/link")
async def link_github_app_installation(
    installation_id: str,
    data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Link a GitHub App installation to an account.

Functional Requirements:
- FR-DISC-001

User Stories:
- US-INT-001

Epics:
- EPIC-001
    """
    from tracertm.repositories.account_repository import AccountRepository
    from tracertm.repositories.github_app_repository import GitHubAppInstallationRepository

    user_id = claims.get("sub") if isinstance(claims, dict) else None
    account_id = data.get("account_id")

    if not user_id or not account_id:
        raise HTTPException(status_code=400, detail="account_id is required")

    # Verify user has access to account
    account_repo = AccountRepository(db)
    role = await account_repo.get_user_role(account_id, user_id)
    if not role:
        raise HTTPException(status_code=403, detail="Access denied to this account")

    installation_repo = GitHubAppInstallationRepository(db)
    installation = await installation_repo.get_by_id(installation_id)

    if not installation:
        raise HTTPException(status_code=404, detail="Installation not found")

    if installation.account_id and installation.account_id != account_id:
        raise HTTPException(status_code=400, detail="Installation already linked to another account")

    installation.account_id = account_id
    await db.commit()

    return {
        "status": "linked",
        "installation_id": installation.id,
        "account_id": account_id,
    }


@app.delete("/api/v1/integrations/github/app/installations/{installation_id}")
async def delete_github_app_installation(
    installation_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Delete a GitHub App installation."""
    from tracertm.repositories.account_repository import AccountRepository
    from tracertm.repositories.github_app_repository import GitHubAppInstallationRepository

    user_id = claims.get("sub") if isinstance(claims, dict) else None
    if not user_id:
        raise HTTPException(status_code=401, detail="Authentication required")

    installation_repo = GitHubAppInstallationRepository(db)
    installation = await installation_repo.get_by_id(installation_id)

    if not installation:
        raise HTTPException(status_code=404, detail="Installation not found")

    # Verify user has access to account
    if installation.account_id:
        account_repo = AccountRepository(db)
        role = await account_repo.get_user_role(installation.account_id, user_id)
        if not role:
            raise HTTPException(status_code=403, detail="Access denied")

    await installation_repo.delete(installation_id)
    await db.commit()

    return {"status": "deleted"}


@app.get("/api/v1/integrations/github/projects")
async def list_github_projects(
    request: Request,
    owner: str,
    installation_id: str | None = None,
    credential_id: str | None = None,
    is_org: bool = True,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List GitHub Projects v2 for an owner."""
    from tracertm.clients.github_client import GitHubClient
    from tracertm.config.github_app import get_github_app_config
    from tracertm.repositories.github_app_repository import GitHubAppInstallationRepository
    from tracertm.repositories.integration_repository import IntegrationCredentialRepository
    from tracertm.services.encryption_service import EncryptionService

    enforce_rate_limit(request, claims)

    client: GitHubClient | None = None

    try:
        # Prefer GitHub App installation
        if installation_id:
            installation_repo = GitHubAppInstallationRepository(db)
            installation = await installation_repo.get_by_id(installation_id)

            if not installation:
                raise HTTPException(status_code=404, detail="Installation not found")

            config = get_github_app_config()
            if not config.is_configured():
                raise HTTPException(status_code=500, detail="GitHub App is not configured")

            client = await GitHubClient.from_app_installation(
                app_id=config.app_id,
                private_key=config.private_key,
                installation_id=installation.installation_id,
            )

        # Fallback to OAuth credential
        elif credential_id:
            encryption_key = os.environ.get("ENCRYPTION_KEY", "")
            if not encryption_key:
                raise HTTPException(status_code=500, detail="Encryption key not configured")

            encryption_service = EncryptionService(encryption_key)
            cred_repo = IntegrationCredentialRepository(db, encryption_service)

            credential = await cred_repo.get_by_id(credential_id)
            if not credential:
                raise HTTPException(status_code=404, detail="Credential not found")

            ensure_credential_access(credential, claims)

            token = cred_repo.decrypt_token(credential)
            client = GitHubClient(token)
        else:
            raise HTTPException(status_code=400, detail="Either installation_id or credential_id is required")

        projects = await client.list_projects_graphql(owner=owner, is_org=is_org)
    finally:
        if client:
            await client.close()

    return {
        "projects": [
            {
                "id": p["id"],
                "title": p["title"],
                "description": p.get("shortDescription"),
                "url": p["url"],
                "closed": p.get("closed", False),
                "public": p.get("public", False),
                "created_at": p.get("createdAt"),
                "updated_at": p.get("updatedAt"),
            }
            for p in projects
        ],
    }


@app.post("/api/v1/integrations/github/projects/auto-link")
async def auto_link_github_projects(
    request: Request,
    data: dict[str, Any],
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Auto-link GitHub Projects for a repository."""
    from tracertm.clients.github_client import GitHubClient
    from tracertm.config.github_app import get_github_app_config
    from tracertm.repositories.github_app_repository import GitHubAppInstallationRepository
    from tracertm.services.github_project_service import GitHubProjectService

    enforce_rate_limit(request, claims)

    project_id = data.get("project_id")
    github_repo_owner = data.get("github_repo_owner")
    github_repo_name = data.get("github_repo_name")
    github_repo_id = data.get("github_repo_id")
    installation_id = data.get("installation_id")

    if not all([project_id, github_repo_owner, github_repo_name, github_repo_id, installation_id]):
        raise HTTPException(status_code=400, detail="Missing required fields")

    # Type casting for mypy
    from typing import cast

    p_id = cast("str", project_id)
    i_id = cast("str", installation_id)
    repo_owner = cast("str", github_repo_owner)
    repo_name = cast("str", github_repo_name)
    repo_id = cast("int", github_repo_id)

    ensure_project_access(p_id, claims)

    installation_repo = GitHubAppInstallationRepository(db)
    installation = await installation_repo.get_by_id(i_id)

    if not installation:
        raise HTTPException(status_code=404, detail="Installation not found")

    config = get_github_app_config()
    if not config.is_configured():
        raise HTTPException(status_code=500, detail="GitHub App is not configured")

    # Create client from app installation
    client = await GitHubClient.from_app_installation(
        app_id=config.app_id,
        private_key=config.private_key,
        installation_id=installation.installation_id,
    )

    try:
        service = GitHubProjectService(db)
        linked_projects = await service.auto_link_projects_for_repo(
            project_id=p_id,
            github_repo_owner=repo_owner,
            github_repo_name=repo_name,
            github_repo_id=repo_id,
            client=client,
        )
    finally:
        await client.close()

    return {
        "linked_projects": linked_projects,
        "total": len(linked_projects),
    }


@app.get("/api/v1/integrations/github/projects/linked")
async def list_linked_github_projects(
    request: Request,
    project_id: str | None = None,
    github_repo_id: int | None = None,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List linked GitHub Projects."""
    from tracertm.repositories.github_project_repository import GitHubProjectRepository

    enforce_rate_limit(request, claims)

    repo = GitHubProjectRepository(db)

    if project_id:
        ensure_project_access(project_id, claims)
        projects = await repo.get_by_project_id(project_id)
    elif github_repo_id:
        projects = await repo.get_by_repo(github_repo_id)
    else:
        raise HTTPException(status_code=400, detail="Either project_id or github_repo_id is required")

    return {
        "projects": [
            {
                "id": p.id,
                "project_id": p.project_id,
                "github_repo_id": p.github_repo_id,
                "github_repo_owner": p.github_repo_owner,
                "github_repo_name": p.github_repo_name,
                "github_project_id": p.github_project_id,
                "github_project_number": p.github_project_number,
                "auto_sync": p.auto_sync,
                "sync_config": p.sync_config,
            }
            for p in projects
        ],
        "total": len(projects),
    }


@app.delete("/api/v1/integrations/github/projects/{github_project_id}/unlink")
async def unlink_github_project(
    request: Request,
    github_project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Unlink a GitHub Project from a TraceRTM project."""
    from tracertm.repositories.github_project_repository import GitHubProjectRepository

    enforce_rate_limit(request, claims)

    repo = GitHubProjectRepository(db)
    github_project = await repo.get_by_id(github_project_id)

    if not github_project:
        raise HTTPException(status_code=404, detail="GitHub Project link not found")

    ensure_project_access(github_project.project_id, claims)

    await repo.delete(github_project_id)
    await db.commit()

    return {"status": "unlinked"}


@app.get("/api/v1/integrations/linear/teams")
async def list_linear_teams(
    request: Request,
    credential_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """List Linear teams accessible with the credential."""
    from tracertm.clients.linear_client import LinearClient
    from tracertm.repositories.integration_repository import IntegrationCredentialRepository
    from tracertm.services.encryption_service import EncryptionService

    enforce_rate_limit(request, claims)

    encryption_key = os.environ.get("ENCRYPTION_KEY", "")
    if not encryption_key:
        raise HTTPException(status_code=500, detail="Encryption key not configured")

    encryption_service = EncryptionService(encryption_key)
    cred_repo = IntegrationCredentialRepository(db, encryption_service)

    credential = await cred_repo.get_by_id(credential_id)
    if not credential:
        raise HTTPException(status_code=404, detail="Credential not found")

    ensure_credential_access(credential, claims)

    if credential.provider != "linear":
        raise HTTPException(status_code=400, detail="Credential is not for Linear")

    token = cred_repo.decrypt_token(credential)
    client = LinearClient(token)

    try:
        teams = await client.list_teams()
    finally:
        await client.close()

    return {
        "teams": [
            {
                "id": t["id"],
                "name": t["name"],
                "key": t["key"],
                "description": t.get("description"),
                "icon": t.get("icon"),
                "color": t.get("color"),
            }
            for t in teams
        ],
    }


@app.get("/api/v1/integrations/linear/teams/{team_id}/issues")
async def list_linear_issues(
    request: Request,
    team_id: str,
    credential_id: str,
    first: int = 50,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List Linear issues for a team."""
    from tracertm.clients.linear_client import LinearClient
    from tracertm.repositories.integration_repository import IntegrationCredentialRepository
    from tracertm.services.encryption_service import EncryptionService

    enforce_rate_limit(request, claims)

    encryption_key = os.environ.get("ENCRYPTION_KEY", "")
    if not encryption_key:
        raise HTTPException(status_code=500, detail="Encryption key not configured")

    encryption_service = EncryptionService(encryption_key)
    cred_repo = IntegrationCredentialRepository(db, encryption_service)

    credential = await cred_repo.get_by_id(credential_id)
    if not credential:
        raise HTTPException(status_code=404, detail="Credential not found")

    ensure_credential_access(credential, claims)

    token = cred_repo.decrypt_token(credential)
    client = LinearClient(token)

    try:
        issues_result = await client.list_issues(team_id=team_id, first=first)
        issues = cast("list[dict[str, Any]]", issues_result.get("nodes", []))
    finally:
        await client.close()

    return {
        "issues": [
            {
                "id": i["id"],
                "identifier": i["identifier"],
                "title": i["title"],
                "description": i.get("description", "")[:500] if i.get("description") else None,
                "state": i.get("state", {}).get("name"),
                "priority": i.get("priority"),
                "url": i["url"],
                "assignee": i.get("assignee", {}).get("name") if i.get("assignee") else None,
                "labels": [l["name"] for l in i.get("labels", {}).get("nodes", [])],
                "created_at": i.get("createdAt"),
                "updated_at": i.get("updatedAt"),
            }
            for i in issues
        ],
    }


@app.get("/api/v1/integrations/linear/projects")
async def list_linear_projects(
    request: Request,
    credential_id: str,
    _first: int = 50,
    claims: dict[str, Any] = Depends(auth_guard),
    db: AsyncSession = Depends(get_db),
):
    """List Linear projects."""
    from tracertm.clients.linear_client import LinearClient
    from tracertm.repositories.integration_repository import IntegrationCredentialRepository
    from tracertm.services.encryption_service import EncryptionService

    enforce_rate_limit(request, claims)

    encryption_key = os.environ.get("ENCRYPTION_KEY", "")
    if not encryption_key:
        raise HTTPException(status_code=500, detail="Encryption key not configured")

    encryption_service = EncryptionService(encryption_key)
    cred_repo = IntegrationCredentialRepository(db, encryption_service)

    credential = await cred_repo.get_by_id(credential_id)
    if not credential:
        raise HTTPException(status_code=404, detail="Credential not found")

    ensure_credential_access(credential, claims)

    token = cred_repo.decrypt_token(credential)
    client = LinearClient(token)

    try:
        projects = await client.list_projects()
    finally:
        await client.close()

    return {
        "projects": [
            {
                "id": p["id"],
                "name": p["name"],
                "description": p.get("description"),
                "state": p.get("state"),
                "progress": p.get("progress"),
                "url": p["url"],
                "start_date": p.get("startDate"),
                "target_date": p.get("targetDate"),
            }
            for p in projects
        ],
    }


# ==================== EXTERNAL WEBHOOK RECEIVERS ====================


@app.post("/api/v1/webhooks/github/{webhook_id}")
async def receive_github_webhook(
    request: Request,
    webhook_id: str,
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Receive GitHub webhook events."""
    import hashlib
    import hmac

    from tracertm.models.webhook_integration import WebhookStatus
    from tracertm.repositories.integration_repository import IntegrationSyncQueueRepository
    from tracertm.repositories.webhook_repository import WebhookRepository

    # Get webhook config
    webhook_repo = WebhookRepository(db)
    webhook = await webhook_repo.get_by_id(webhook_id)
    if not webhook:
        raise HTTPException(status_code=404, detail="Webhook not found")

    if webhook.status != WebhookStatus.ACTIVE:
        raise HTTPException(status_code=400, detail="Webhook is inactive")

    # Verify signature
    body = await request.body()
    signature_header = request.headers.get("X-Hub-Signature-256", "")

    if webhook.webhook_secret:
        expected_signature = "sha256=" + hmac.new(webhook.webhook_secret.encode(), body, hashlib.sha256).hexdigest()

        if not hmac.compare_digest(signature_header, expected_signature):
            raise HTTPException(status_code=401, detail="Invalid signature")

    # Parse event
    event_type = request.headers.get("X-GitHub-Event", "unknown")
    payload = await request.json()

    # Queue for processing
    # Note: Using enqueue with mapping_id=None might fail if model requires it.
    # For now, we use create_log and assume sync queue handles it via other means or fix mapping later.
    credential_id = getattr(webhook, "credential_id", None)
    if not credential_id:
        raise HTTPException(status_code=400, detail="Webhook missing credential_id")

    queue_repo = IntegrationSyncQueueRepository(db)
    await queue_repo.enqueue(
        credential_id=str(credential_id),
        mapping_id=str(getattr(webhook, "mapping_id", None) or webhook_id),
        event_type=f"webhook:{event_type}",
        direction="pull",
        payload={
            "webhook_id": webhook_id,
            "event_type": event_type,
            "delivery_id": request.headers.get("X-GitHub-Delivery"),
            "data": payload,
        },
    )

    # Log delivery
    await webhook_repo.create_log(
        webhook_id=webhook_id,
        event_type=event_type,
        payload_size_bytes=len(body),
        success=True,
        status_code=200,
    )

    return {"received": True, "event": event_type}


@app.post("/api/v1/webhooks/linear/{webhook_id}")
async def receive_linear_webhook(
    request: Request,
    webhook_id: str,
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Receive Linear webhook events."""
    import hashlib
    import hmac

    from tracertm.models.webhook_integration import WebhookStatus
    from tracertm.repositories.integration_repository import IntegrationSyncQueueRepository
    from tracertm.repositories.webhook_repository import WebhookRepository

    # Get webhook config
    webhook_repo = WebhookRepository(db)
    webhook = await webhook_repo.get_by_id(webhook_id)
    if not webhook:
        raise HTTPException(status_code=404, detail="Webhook not found")

    if webhook.status != WebhookStatus.ACTIVE:
        raise HTTPException(status_code=400, detail="Webhook is inactive")

    # Verify signature
    body = await request.body()
    signature_header = request.headers.get("Linear-Signature", "")

    if webhook.webhook_secret:
        expected_signature = hmac.new(webhook.webhook_secret.encode(), body, hashlib.sha256).hexdigest()

        if not hmac.compare_digest(signature_header, expected_signature):
            raise HTTPException(status_code=401, detail="Invalid signature")

    # Parse event
    payload = await request.json()
    event_type = payload.get("type", "unknown")
    action = payload.get("action", "")

    credential_id = getattr(webhook, "credential_id", None)
    if not credential_id:
        raise HTTPException(status_code=400, detail="Webhook missing credential_id")

    queue_repo = IntegrationSyncQueueRepository(db)
    await queue_repo.enqueue(
        credential_id=str(credential_id),
        mapping_id=str(getattr(webhook, "mapping_id", None) or webhook_id),
        event_type=f"webhook:{event_type}:{action}",
        direction="pull",
        payload={
            "webhook_id": webhook_id,
            "event_type": event_type,
            "action": action,
            "data": payload.get("data", {}),
        },
    )

    # Log delivery
    await webhook_repo.create_log(
        webhook_id=webhook_id,
        event_type=f"{event_type}:{action}",
        payload_size_bytes=len(body),
        success=True,
        status_code=200,
    )

    return {"received": True, "event": event_type, "action": action}


# ==================== INTEGRATION STATS ====================


@app.get("/api/v1/integrations/stats")
async def get_integration_stats(
    request: Request,
    project_id: str,
    claims: Annotated[dict[str, Any], Depends(auth_guard)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get integration statistics for a project."""
    from tracertm.api.handlers.integrations import get_integration_stats_handler

    return await get_integration_stats_handler(request, project_id, claims, db)


# ==================== AI CHAT ====================

from tracertm.api.handlers.chat import simple_chat as _simple_chat_impl
from tracertm.api.handlers.chat import stream_chat as _stream_chat_impl

# Register chat endpoints
app.post("/api/v1/chat/stream")(_stream_chat_impl)
app.post("/api/v1/chat")(_simple_chat_impl)


if __name__ == "__main__":
    import uvicorn

    # Dev-only: bind to all interfaces for local development
    uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)  # nosec B104 -- dev-only __main__ guard
