"""Token manager scaffold — generates encrypted token storage + rotation code.

Supports Python (Fernet/AES-256-GCM + SQLAlchemy + FastAPI) and
TypeScript (Node crypto AES-256-GCM + Prisma + Express).
"""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from . import Language

DIR = "stablestack_token_manager"


def get_files(language: Language) -> dict[str, str]:
    """Return {relative_path: content} for the token-manager scaffold."""
    if language == "python":
        return _python_files()
    return _typescript_files()


def get_slash_command() -> str:
    """Return the .claude/commands/token-manager.md content."""
    return _SLASH_COMMAND


# ---------------------------------------------------------------------------
# Python files
# ---------------------------------------------------------------------------

def _python_files() -> dict[str, str]:
    return {
        f"{DIR}/__init__.py": _PY_INIT,
        f"{DIR}/encryption.py": _PY_ENCRYPTION,
        f"{DIR}/models.py": _PY_MODELS,
        f"{DIR}/rotation.py": _PY_ROTATION,
        f"{DIR}/api.py": _PY_API,
        f"scripts/rotate_tokens.py": _PY_CRON,
        f"{DIR}/ui/TokenDashboard.tsx": _REACT_UI,
    }


_PY_INIT = '''\
"""StableStack Token Manager — encrypted token storage and automatic rotation.

Installed by: stablestack add-token-manager
"""

from .encryption import TokenEncryptor, encrypt_token, decrypt_token
from .rotation import TokenRotationJob, OAuthProvider, GenericOAuthProvider

__all__ = [
    "TokenEncryptor",
    "encrypt_token",
    "decrypt_token",
    "TokenRotationJob",
    "OAuthProvider",
    "GenericOAuthProvider",
]
'''

_PY_ENCRYPTION = '''\
"""
Encryption utilities for secure token storage.

Uses Fernet (AES-128-CBC with HMAC) for simplicity, with an AES-256-GCM
alternative for stricter requirements. Tokens are encrypted before database
storage and decrypted on read.

Installed by: stablestack add-token-manager
"""

import base64
import os
from typing import Optional

from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives.ciphers.aead import AESGCM


class TokenEncryptor:
    """Encrypt and decrypt tokens using Fernet (symmetric encryption).

    Usage:
        encryptor = TokenEncryptor()  # reads ENCRYPTION_KEY env var
        encrypted = encryptor.encrypt("oauth_access_token_here")
        token = encryptor.decrypt(encrypted)
    """

    def __init__(self, key: Optional[str] = None):
        """Initialize with a Fernet key.

        Args:
            key: Base64-encoded Fernet key. Falls back to ENCRYPTION_KEY env var.
                 Generate with:
                 python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
        """
        key = key or os.environ.get("ENCRYPTION_KEY")
        if not key:
            raise ValueError(
                "Encryption key required. Set ENCRYPTION_KEY environment variable "
                "or pass key to constructor."
            )
        self._fernet = Fernet(key.encode() if isinstance(key, str) else key)

    def encrypt(self, plaintext: str) -> str:
        """Encrypt a plaintext string. Returns base64-encoded ciphertext."""
        if not plaintext:
            return ""
        return self._fernet.encrypt(plaintext.encode()).decode()

    def decrypt(self, ciphertext: str) -> str:
        """Decrypt a ciphertext string.

        Raises:
            InvalidToken: If the key is wrong or data was tampered with.
        """
        if not ciphertext:
            return ""
        return self._fernet.decrypt(ciphertext.encode()).decode()

    def rotate_key(self, old_ciphertext: str, new_encryptor: "TokenEncryptor") -> str:
        """Re-encrypt data with a new key."""
        return new_encryptor.encrypt(self.decrypt(old_ciphertext))


class TokenEncryptorAESGCM:
    """Encrypt and decrypt tokens using AES-256-GCM (authenticated encryption).

    Usage:
        encryptor = TokenEncryptorAESGCM()
        encrypted = encryptor.encrypt("oauth_access_token_here")
        token = encryptor.decrypt(encrypted)
    """

    NONCE_SIZE = 12  # 96 bits, recommended for GCM

    def __init__(self, key: Optional[str] = None):
        """Initialize with a base64-encoded 32-byte key.

        Generate with:
            python -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"
        """
        key = key or os.environ.get("ENCRYPTION_KEY")
        if not key:
            raise ValueError(
                "Encryption key required. Set ENCRYPTION_KEY environment variable."
            )
        self._key = base64.b64decode(key)
        if len(self._key) != 32:
            raise ValueError("Key must be 32 bytes (256 bits) for AES-256")
        self._aesgcm = AESGCM(self._key)

    def encrypt(self, plaintext: str) -> str:
        """Encrypt plaintext using AES-256-GCM."""
        if not plaintext:
            return ""
        nonce = os.urandom(self.NONCE_SIZE)
        ciphertext = self._aesgcm.encrypt(nonce, plaintext.encode(), None)
        return base64.b64encode(nonce + ciphertext).decode()

    def decrypt(self, ciphertext: str) -> str:
        """Decrypt ciphertext using AES-256-GCM."""
        if not ciphertext:
            return ""
        combined = base64.b64decode(ciphertext)
        nonce = combined[: self.NONCE_SIZE]
        encrypted = combined[self.NONCE_SIZE :]
        return self._aesgcm.decrypt(nonce, encrypted, None).decode()


# -- Convenience singleton ---------------------------------------------------

_encryptor: Optional[TokenEncryptor] = None


def get_encryptor() -> TokenEncryptor:
    """Get or create the global TokenEncryptor instance."""
    global _encryptor
    if _encryptor is None:
        _encryptor = TokenEncryptor()
    return _encryptor


def encrypt_token(token: str) -> str:
    """Encrypt a token using the global encryptor."""
    return get_encryptor().encrypt(token)


def decrypt_token(encrypted: str) -> str:
    """Decrypt a token using the global encryptor."""
    return get_encryptor().decrypt(encrypted)
'''

_PY_MODELS = '''\
"""
SQLAlchemy models for encrypted credential storage.

Provides a Credential model with hybrid properties that automatically
encrypt/decrypt token fields on read and write.

Installed by: stablestack add-token-manager
"""

import enum
from datetime import datetime, timedelta, timezone
from typing import Optional

from sqlalchemy import (
    Boolean,
    Column,
    DateTime,
    Enum,
    Integer,
    String,
    Text,
)
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import DeclarativeBase

from .encryption import get_encryptor


class Base(DeclarativeBase):
    """Base class for models. Replace with your project\'s Base if you have one."""
    pass


class TokenStatus(str, enum.Enum):
    """Status of a stored credential\'s token."""
    ACTIVE = "active"
    EXPIRING_SOON = "expiring_soon"  # < WARN_THRESHOLD_DAYS
    EXPIRED = "expired"
    INVALID = "invalid"
    REVOKED = "revoked"


class Credential(Base):
    """Encrypted OAuth credential.

    Access token and refresh token are stored encrypted. Use the
    hybrid properties (.access_token, .refresh_token) which
    transparently encrypt/decrypt.
    """

    __tablename__ = "credentials"

    id = Column(Integer, primary_key=True, autoincrement=True)
    provider = Column(String(64), nullable=False, index=True)
    label = Column(String(255), nullable=False, default="")
    # Encrypted fields — never read _encrypted_* directly
    _encrypted_access_token = Column("encrypted_access_token", Text, nullable=False)
    _encrypted_refresh_token = Column("encrypted_refresh_token", Text, nullable=True)
    _encrypted_client_id = Column("encrypted_client_id", Text, nullable=True)
    _encrypted_client_secret = Column("encrypted_client_secret", Text, nullable=True)

    token_expires_at = Column(DateTime(timezone=True), nullable=True)
    refresh_token_expires_at = Column(DateTime(timezone=True), nullable=True)
    token_status = Column(
        Enum(TokenStatus), nullable=False, default=TokenStatus.ACTIVE
    )
    is_active = Column(Boolean, nullable=False, default=True)
    created_at = Column(
        DateTime(timezone=True),
        nullable=False,
        default=lambda: datetime.now(timezone.utc),
    )
    updated_at = Column(
        DateTime(timezone=True),
        nullable=False,
        default=lambda: datetime.now(timezone.utc),
        onupdate=lambda: datetime.now(timezone.utc),
    )

    # -- Hybrid properties for transparent encrypt/decrypt --

    @hybrid_property
    def access_token(self) -> str:
        return get_encryptor().decrypt(self._encrypted_access_token or "")

    @access_token.setter  # type: ignore[no-redef]
    def access_token(self, value: str) -> None:
        self._encrypted_access_token = get_encryptor().encrypt(value)

    @hybrid_property
    def refresh_token(self) -> Optional[str]:
        if not self._encrypted_refresh_token:
            return None
        return get_encryptor().decrypt(self._encrypted_refresh_token)

    @refresh_token.setter  # type: ignore[no-redef]
    def refresh_token(self, value: Optional[str]) -> None:
        self._encrypted_refresh_token = (
            get_encryptor().encrypt(value) if value else None
        )

    @hybrid_property
    def client_id(self) -> Optional[str]:
        if not self._encrypted_client_id:
            return None
        return get_encryptor().decrypt(self._encrypted_client_id)

    @client_id.setter  # type: ignore[no-redef]
    def client_id(self, value: Optional[str]) -> None:
        self._encrypted_client_id = (
            get_encryptor().encrypt(value) if value else None
        )

    @hybrid_property
    def client_secret(self) -> Optional[str]:
        if not self._encrypted_client_secret:
            return None
        return get_encryptor().decrypt(self._encrypted_client_secret)

    @client_secret.setter  # type: ignore[no-redef]
    def client_secret(self, value: Optional[str]) -> None:
        self._encrypted_client_secret = (
            get_encryptor().encrypt(value) if value else None
        )

    def to_safe_dict(self) -> dict:
        """Serialize without exposing raw tokens (masked)."""
        return {
            "id": self.id,
            "provider": self.provider,
            "label": self.label,
            "token_status": self.token_status.value if self.token_status else None,
            "token_expires_at": (
                self.token_expires_at.isoformat() if self.token_expires_at else None
            ),
            "refresh_token_expires_at": (
                self.refresh_token_expires_at.isoformat()
                if self.refresh_token_expires_at else None
            ),
            "has_refresh_token": bool(self._encrypted_refresh_token),
            "is_active": self.is_active,
            "created_at": self.created_at.isoformat() if self.created_at else None,
            "updated_at": self.updated_at.isoformat() if self.updated_at else None,
        }


# Provider-specific refresh token lifetimes (days from last use).
# Providers not listed here have indefinite refresh tokens.
PROVIDER_REFRESH_DAYS: dict[str, int] = {
    # Google: 180 days of inactivity
    "google": 180, "google_calendar": 180, "gmail": 180,
    # Microsoft: 90-day rolling window, resets on each use
    "microsoft": 90, "outlook": 90, "outlook_calendar": 90, "teams": 90,
    # HubSpot: 180-day rotating tokens
    "hubspot": 180,
    # Zoom, Salesforce, GitHub: indefinite (not listed)
}


def get_refresh_token_expires_at(provider: str) -> Optional[datetime]:
    """Compute the refresh token expiry date for a provider.

    Returns None for providers with indefinite refresh tokens.
    """
    days = PROVIDER_REFRESH_DAYS.get(provider.lower())
    if days is None:
        return None
    return datetime.now(timezone.utc) + timedelta(days=days)
'''

_PY_ROTATION = '''\
"""
OAuth token rotation — generic provider interface + rotation job.

Distilled from production token rotation systems. Implement OAuthProvider
for each provider you use, or use GenericOAuthProvider for standard
OAuth2 refresh_token flows.

Installed by: stablestack add-token-manager
"""

import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Optional

import requests

from .models import Credential, TokenStatus, get_refresh_token_expires_at

logger = logging.getLogger(__name__)

# Configurable thresholds
REFRESH_THRESHOLD_DAYS = 30  # Refresh when access token < 30 days remaining
WARN_THRESHOLD_DAYS = 7      # Mark as "expiring_soon" when < 7 days
REFRESH_TOKEN_THRESHOLD_DAYS = 30  # Proactively refresh when refresh token < 30 days


@dataclass
class RefreshResult:
    """Result of a single token refresh attempt."""
    success: bool
    message: str
    new_access_token: Optional[str] = None
    new_refresh_token: Optional[str] = None
    expires_in_seconds: Optional[int] = None


class OAuthProvider(ABC):
    """Base class for OAuth providers. Implement per provider."""

    name: str = "base"

    @abstractmethod
    def check_token_validity(self, access_token: str) -> TokenStatus:
        """Check whether a token is still valid."""
        ...

    @abstractmethod
    def refresh_token(
        self,
        refresh_token: str,
        client_id: str,
        client_secret: str,
    ) -> RefreshResult:
        """Exchange a refresh token for a new access token."""
        ...


class GenericOAuthProvider(OAuthProvider):
    """Standard OAuth2 token refresh endpoint.

    Works with providers that follow the OAuth2 spec (Google, GitHub,
    Slack, etc.). For providers with non-standard flows, subclass
    OAuthProvider directly.

    Usage:
        provider = GenericOAuthProvider("google", "https://oauth2.googleapis.com/token")
        result = provider.refresh_token(refresh_tok, client_id, client_secret)
    """

    def __init__(self, name: str, token_url: str, timeout: int = 30):
        self.name = name
        self.token_url = token_url
        self.timeout = timeout

    def check_token_validity(self, access_token: str) -> TokenStatus:
        """Basic validity check — override for provider-specific introspection."""
        if not access_token:
            return TokenStatus.INVALID
        return TokenStatus.ACTIVE

    def refresh_token(
        self,
        refresh_token: str,
        client_id: str,
        client_secret: str,
    ) -> RefreshResult:
        """Exchange refresh token using the standard OAuth2 token endpoint."""
        try:
            resp = requests.post(
                self.token_url,
                data={
                    "grant_type": "refresh_token",
                    "refresh_token": refresh_token,
                    "client_id": client_id,
                    "client_secret": client_secret,
                },
                timeout=self.timeout,
            )

            if resp.status_code != 200:
                error = resp.json().get("error_description", resp.text[:200])
                return RefreshResult(success=False, message=f"Refresh failed: {error}")

            data = resp.json()
            return RefreshResult(
                success=True,
                message="Token refreshed successfully",
                new_access_token=data.get("access_token"),
                new_refresh_token=data.get("refresh_token"),
                expires_in_seconds=data.get("expires_in"),
            )
        except Exception as e:
            return RefreshResult(success=False, message=str(e))


@dataclass
class RotationSummary:
    """Summary of a full rotation run."""
    total: int = 0
    refreshed: int = 0
    skipped: int = 0
    failed: int = 0
    errors: list[str] = field(default_factory=list)


class TokenRotationJob:
    """Rotate all expiring tokens for registered providers.

    Usage:
        from sqlalchemy.orm import Session

        job = TokenRotationJob(db_session)
        job.register_provider(GenericOAuthProvider(
            "google", "https://oauth2.googleapis.com/token"
        ))
        summary = job.run()
        print(f"Refreshed {summary.refreshed}/{summary.total} tokens")
    """

    def __init__(self, db_session):
        self.db = db_session
        self._providers: dict[str, OAuthProvider] = {}

    def register_provider(self, provider: OAuthProvider) -> None:
        """Register an OAuth provider by name."""
        self._providers[provider.name] = provider

    def run(self, dry_run: bool = False) -> RotationSummary:
        """Check all active credentials and rotate expiring tokens."""
        summary = RotationSummary()
        now = datetime.now(timezone.utc)
        access_cutoff = now + timedelta(days=REFRESH_THRESHOLD_DAYS)
        refresh_cutoff = now + timedelta(days=REFRESH_TOKEN_THRESHOLD_DAYS)

        from sqlalchemy import or_

        credentials = (
            self.db.query(Credential)
            .filter(
                Credential.is_active.is_(True),
                or_(
                    Credential.token_expires_at < access_cutoff,
                    Credential.refresh_token_expires_at < refresh_cutoff,
                    Credential.token_expires_at.is_(None),
                ),
            )
            .all()
        )
        summary.total = len(credentials)

        for cred in credentials:
            try:
                provider = self._providers.get(cred.provider)
                if not provider:
                    logger.debug("No provider registered for %s — skipping", cred.provider)
                    summary.skipped += 1
                    continue

                # Check if refresh token has already expired (dead)
                if cred.refresh_token_expires_at:
                    refresh_days_left = (cred.refresh_token_expires_at - now).days
                    if refresh_days_left < 0:
                        cred.token_status = TokenStatus.EXPIRED
                        self.db.commit()
                        summary.failed += 1
                        summary.errors.append(f"Credential {cred.id}: refresh token expired")
                        continue

                # Check access token expiry
                needs_refresh_renewal = (
                    cred.refresh_token_expires_at
                    and cred.refresh_token_expires_at < refresh_cutoff
                )
                if cred.token_expires_at:
                    days_left = (cred.token_expires_at - now).days

                    if days_left > REFRESH_THRESHOLD_DAYS and not needs_refresh_renewal:
                        summary.skipped += 1
                        continue

                    # OAuth access tokens expire every ~1 hour — that\'s normal.
                    # Only mark as "expired" if >7 days past expiry (refresh token
                    # is likely dead too). Recently expired + refresh token = auto-refreshes.
                    if days_left <= -7:
                        cred.token_status = TokenStatus.EXPIRED
                    elif days_left <= 0:
                        # Recently expired with a refresh token — normal, keep current status
                        pass
                    elif days_left <= WARN_THRESHOLD_DAYS:
                        cred.token_status = TokenStatus.EXPIRING_SOON

                # Refresh token required
                if not cred.refresh_token:
                    logger.warning("Credential %s has no refresh token — skipping", cred.id)
                    summary.skipped += 1
                    continue

                if dry_run:
                    reason = "refresh token expiring" if needs_refresh_renewal else "access token expiring"
                    logger.info("[DRY RUN] Would refresh credential %s (%s) — %s", cred.id, cred.provider, reason)
                    summary.refreshed += 1
                    continue

                result = provider.refresh_token(
                    cred.refresh_token,
                    cred.client_id or "",
                    cred.client_secret or "",
                )

                if not result.success:
                    logger.error("Failed to refresh credential %s: %s", cred.id, result.message)
                    summary.failed += 1
                    summary.errors.append(f"Credential {cred.id}: {result.message}")
                    continue

                # Update credential
                if result.new_access_token:
                    cred.access_token = result.new_access_token
                if result.new_refresh_token:
                    cred.refresh_token = result.new_refresh_token
                if result.expires_in_seconds:
                    cred.token_expires_at = now + timedelta(seconds=result.expires_in_seconds)
                cred.token_status = TokenStatus.ACTIVE
                cred.updated_at = now
                # Reset refresh token expiry — using the token resets the inactivity window
                cred.refresh_token_expires_at = get_refresh_token_expires_at(cred.provider)

                self.db.commit()
                summary.refreshed += 1
                logger.info("Refreshed credential %s (%s)", cred.id, cred.provider)

            except Exception as e:
                logger.error("Error processing credential %s: %s", cred.id, e)
                summary.failed += 1
                summary.errors.append(f"Credential {cred.id}: {e}")
                self.db.rollback()

        return summary
'''

_PY_API = '''\
"""
FastAPI router for credential management — CRUD, validation, rotation.

Mount this router in your FastAPI app:
    from stablestack_token_manager.api import router
    app.include_router(router, prefix="/api")

Installed by: stablestack add-token-manager
"""

from datetime import datetime, timezone
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session

from .models import Credential, TokenStatus, get_refresh_token_expires_at
from .rotation import (
    GenericOAuthProvider,
    TokenRotationJob,
)

router = APIRouter(prefix="/credentials", tags=["credentials"])


# -- Pydantic schemas --------------------------------------------------------

class CredentialCreate(BaseModel):
    provider: str
    label: str = ""
    access_token: str
    refresh_token: Optional[str] = None
    client_id: Optional[str] = None
    client_secret: Optional[str] = None
    token_expires_at: Optional[datetime] = None


class CredentialUpdate(BaseModel):
    label: Optional[str] = None
    access_token: Optional[str] = None
    refresh_token: Optional[str] = None
    client_id: Optional[str] = None
    client_secret: Optional[str] = None
    token_expires_at: Optional[datetime] = None


# -- Dependency (replace with your own session factory) -----------------------

def get_db():
    """Yields a SQLAlchemy session. Replace with your project\'s dependency."""
    raise NotImplementedError(
        "Replace get_db() in stablestack_token_manager/api.py with your "
        "project\'s database session dependency."
    )


# -- CRUD endpoints -----------------------------------------------------------

@router.get("")
def list_credentials(
    db: Session = Depends(get_db),
    provider: Optional[str] = None,
):
    """List all credentials (tokens are masked)."""
    query = db.query(Credential).filter(Credential.is_active.is_(True))
    if provider:
        query = query.filter(Credential.provider == provider)
    return [c.to_safe_dict() for c in query.all()]


@router.post("", status_code=201)
def create_credential(body: CredentialCreate, db: Session = Depends(get_db)):
    """Create a new encrypted credential."""
    cred = Credential(
        provider=body.provider,
        label=body.label,
        token_expires_at=body.token_expires_at,
        refresh_token_expires_at=get_refresh_token_expires_at(body.provider),
    )
    cred.access_token = body.access_token
    cred.refresh_token = body.refresh_token
    cred.client_id = body.client_id
    cred.client_secret = body.client_secret

    db.add(cred)
    db.commit()
    db.refresh(cred)
    return cred.to_safe_dict()


@router.put("/{credential_id}")
def update_credential(
    credential_id: int,
    body: CredentialUpdate,
    db: Session = Depends(get_db),
):
    """Update an existing credential."""
    cred = db.query(Credential).get(credential_id)
    if not cred or not cred.is_active:
        raise HTTPException(404, "Credential not found")

    if body.label is not None:
        cred.label = body.label
    if body.access_token is not None:
        cred.access_token = body.access_token
    if body.refresh_token is not None:
        cred.refresh_token = body.refresh_token
    if body.client_id is not None:
        cred.client_id = body.client_id
    if body.client_secret is not None:
        cred.client_secret = body.client_secret
    if body.token_expires_at is not None:
        cred.token_expires_at = body.token_expires_at

    cred.updated_at = datetime.now(timezone.utc)
    db.commit()
    return cred.to_safe_dict()


@router.delete("/{credential_id}")
def delete_credential(credential_id: int, db: Session = Depends(get_db)):
    """Soft-delete a credential."""
    cred = db.query(Credential).get(credential_id)
    if not cred or not cred.is_active:
        raise HTTPException(404, "Credential not found")
    cred.is_active = False
    cred.updated_at = datetime.now(timezone.utc)
    db.commit()
    return {"success": True, "id": credential_id}


# -- Validation & rotation ---------------------------------------------------

@router.post("/{credential_id}/validate")
def validate_credential(credential_id: int, db: Session = Depends(get_db)):
    """Check if a credential\'s token is still valid via its provider."""
    cred = db.query(Credential).get(credential_id)
    if not cred or not cred.is_active:
        raise HTTPException(404, "Credential not found")

    now = datetime.now(timezone.utc)
    if cred.token_expires_at and cred.token_expires_at < now:
        days_since_expiry = (now - cred.token_expires_at).days
        # OAuth access tokens expire every ~1 hour — that's normal.
        # Only mark as "expired" if >7 days past expiry (refresh token
        # is likely dead too). Recently expired + refresh token = auto-refreshes.
        if days_since_expiry > 7:
            cred.token_status = TokenStatus.EXPIRED
            db.commit()
            return {"id": cred.id, "status": "expired"}
        return {"id": cred.id, "status": cred.token_status.value}

    return {"id": cred.id, "status": cred.token_status.value}


@router.post("/{credential_id}/refresh")
def refresh_credential(credential_id: int, db: Session = Depends(get_db)):
    """Force-refresh a single credential\'s token."""
    cred = db.query(Credential).get(credential_id)
    if not cred or not cred.is_active:
        raise HTTPException(404, "Credential not found")

    if not cred.refresh_token:
        raise HTTPException(400, "Credential has no refresh token")

    # Use a generic provider — customize per provider as needed
    provider = GenericOAuthProvider(
        cred.provider,
        token_url=f"https://{cred.provider}.example.com/oauth/token",
    )
    result = provider.refresh_token(
        cred.refresh_token, cred.client_id or "", cred.client_secret or ""
    )

    if not result.success:
        raise HTTPException(502, f"Refresh failed: {result.message}")

    if result.new_access_token:
        cred.access_token = result.new_access_token
    if result.new_refresh_token:
        cred.refresh_token = result.new_refresh_token
    if result.expires_in_seconds:
        from datetime import timedelta
        cred.token_expires_at = datetime.now(timezone.utc) + timedelta(
            seconds=result.expires_in_seconds
        )
    cred.token_status = TokenStatus.ACTIVE
    # Reset refresh token expiry — using the token resets the inactivity window
    cred.refresh_token_expires_at = get_refresh_token_expires_at(cred.provider)
    db.commit()
    return cred.to_safe_dict()


@router.get("/status")
def credential_status(db: Session = Depends(get_db)):
    """Get all credentials with their current expiry status."""
    creds = (
        db.query(Credential)
        .filter(Credential.is_active.is_(True))
        .all()
    )
    now = datetime.now(timezone.utc)
    results = []
    for c in creds:
        days_left = None
        if c.token_expires_at:
            days_left = (c.token_expires_at - now).days
        refresh_days_left = None
        if c.refresh_token_expires_at:
            refresh_days_left = (c.refresh_token_expires_at - now).days
        results.append({
            **c.to_safe_dict(),
            "days_until_expiry": days_left,
            "days_until_refresh_expiry": refresh_days_left,
        })
    return results


@router.post("/rotate")
def rotate_all(
    dry_run: bool = Query(default=False),
    db: Session = Depends(get_db),
):
    """Rotate all credentials whose tokens are expiring soon.

    Register providers in your app startup to enable automatic rotation.
    """
    job = TokenRotationJob(db)
    # TODO: Register your providers here, e.g.:
    # job.register_provider(GenericOAuthProvider("google", "https://oauth2.googleapis.com/token"))
    summary = job.run(dry_run=dry_run)
    return {
        "total": summary.total,
        "refreshed": summary.refreshed,
        "skipped": summary.skipped,
        "failed": summary.failed,
        "errors": summary.errors,
    }
'''


# ---------------------------------------------------------------------------
# TypeScript files
# ---------------------------------------------------------------------------

def _typescript_files() -> dict[str, str]:
    return {
        f"{DIR}/index.ts": _TS_INDEX,
        f"{DIR}/encryption.ts": _TS_ENCRYPTION,
        f"{DIR}/models.ts": _TS_MODELS,
        f"{DIR}/rotation.ts": _TS_ROTATION,
        f"{DIR}/api.ts": _TS_API,
        f"scripts/rotate_tokens.ts": _TS_CRON,
        f"{DIR}/ui/TokenDashboard.tsx": _REACT_UI,
    }


_TS_INDEX = '''\
/**
 * StableStack Token Manager — encrypted token storage and automatic rotation.
 *
 * Installed by: stablestack add-token-manager
 */

export { TokenEncryptor, encryptToken, decryptToken } from "./encryption";
export { TokenRotationJob, GenericOAuthProvider } from "./rotation";
export type { OAuthProvider, RefreshResult, RotationSummary } from "./rotation";
export { CredentialService } from "./models";
export type { Credential, TokenStatus } from "./models";
'''

_TS_ENCRYPTION = '''\
/**
 * Encryption utilities for secure token storage.
 * Uses AES-256-GCM for authenticated encryption.
 *
 * Installed by: stablestack add-token-manager
 */

import crypto from "crypto";

const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12; // 96 bits for GCM
const AUTH_TAG_LENGTH = 16; // 128 bits

export class TokenEncryptor {
  private key: Buffer;

  /**
   * @param key Base64-encoded 32-byte key. Falls back to ENCRYPTION_KEY env var.
   *            Generate with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))"
   */
  constructor(key?: string) {
    const keyString = key || process.env.ENCRYPTION_KEY;
    if (!keyString) {
      throw new Error(
        "Encryption key required. Set ENCRYPTION_KEY environment variable " +
          "or pass key to constructor."
      );
    }
    this.key = Buffer.from(keyString, "base64");
    if (this.key.length !== 32) {
      throw new Error("Key must be 32 bytes (256 bits) for AES-256");
    }
  }

  encrypt(plaintext: string): string {
    if (!plaintext) return "";
    const iv = crypto.randomBytes(IV_LENGTH);
    const cipher = crypto.createCipheriv(ALGORITHM, this.key, iv, {
      authTagLength: AUTH_TAG_LENGTH,
    });
    const encrypted = Buffer.concat([
      cipher.update(plaintext, "utf8"),
      cipher.final(),
    ]);
    const authTag = cipher.getAuthTag();
    return Buffer.concat([iv, authTag, encrypted]).toString("base64");
  }

  decrypt(ciphertext: string): string {
    if (!ciphertext) return "";
    const combined = Buffer.from(ciphertext, "base64");
    const iv = combined.subarray(0, IV_LENGTH);
    const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
    const encrypted = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
    const decipher = crypto.createDecipheriv(ALGORITHM, this.key, iv, {
      authTagLength: AUTH_TAG_LENGTH,
    });
    decipher.setAuthTag(authTag);
    return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
  }

  rotateKey(oldCiphertext: string, newEncryptor: TokenEncryptor): string {
    return newEncryptor.encrypt(this.decrypt(oldCiphertext));
  }
}

// Singleton
let _encryptor: TokenEncryptor | null = null;

export function getEncryptor(): TokenEncryptor {
  if (!_encryptor) _encryptor = new TokenEncryptor();
  return _encryptor;
}

export function encryptToken(token: string): string {
  return getEncryptor().encrypt(token);
}

export function decryptToken(encrypted: string): string {
  return getEncryptor().decrypt(encrypted);
}
'''

_TS_MODELS = '''\
/**
 * Credential models and service for encrypted token storage.
 *
 * Uses a service class with encrypt/decrypt wrappers. If using Prisma,
 * add this to your schema:
 *
 * model Credential {
 *   id                     Int       @id @default(autoincrement())
 *   provider               String
 *   label                  String    @default("")
 *   encrypted_access_token String
 *   encrypted_refresh_token String?
 *   encrypted_client_id    String?
 *   encrypted_client_secret String?
 *   token_expires_at       DateTime?
 *   refresh_token_expires_at DateTime?
 *   token_status           String    @default("active")
 *   is_active              Boolean   @default(true)
 *   created_at             DateTime  @default(now())
 *   updated_at             DateTime  @updatedAt
 * }
 *
 * Installed by: stablestack add-token-manager
 */

import { getEncryptor } from "./encryption";

export type TokenStatus =
  | "active"
  | "expiring_soon"
  | "expired"
  | "invalid"
  | "revoked";

export interface Credential {
  id: number;
  provider: string;
  label: string;
  encrypted_access_token: string;
  encrypted_refresh_token: string | null;
  encrypted_client_id: string | null;
  encrypted_client_secret: string | null;
  token_expires_at: Date | null;
  refresh_token_expires_at: Date | null;
  token_status: TokenStatus;
  is_active: boolean;
  created_at: Date;
  updated_at: Date;
}

export interface CredentialSafe {
  id: number;
  provider: string;
  label: string;
  token_status: TokenStatus;
  token_expires_at: string | null;
  refresh_token_expires_at: string | null;
  has_refresh_token: boolean;
  is_active: boolean;
  created_at: string;
  updated_at: string;
}

export class CredentialService {
  /**
   * Encrypt fields before saving to database.
   */
  static encryptFields(data: {
    access_token: string;
    refresh_token?: string | null;
    client_id?: string | null;
    client_secret?: string | null;
  }) {
    const enc = getEncryptor();
    return {
      encrypted_access_token: enc.encrypt(data.access_token),
      encrypted_refresh_token: data.refresh_token
        ? enc.encrypt(data.refresh_token)
        : null,
      encrypted_client_id: data.client_id
        ? enc.encrypt(data.client_id)
        : null,
      encrypted_client_secret: data.client_secret
        ? enc.encrypt(data.client_secret)
        : null,
    };
  }

  /**
   * Decrypt token fields from a credential record.
   */
  static decryptFields(cred: Credential) {
    const enc = getEncryptor();
    return {
      access_token: enc.decrypt(cred.encrypted_access_token),
      refresh_token: cred.encrypted_refresh_token
        ? enc.decrypt(cred.encrypted_refresh_token)
        : null,
      client_id: cred.encrypted_client_id
        ? enc.decrypt(cred.encrypted_client_id)
        : null,
      client_secret: cred.encrypted_client_secret
        ? enc.decrypt(cred.encrypted_client_secret)
        : null,
    };
  }

  /**
   * Create a safe representation (tokens masked) for API responses.
   */
  static toSafe(cred: Credential): CredentialSafe {
    return {
      id: cred.id,
      provider: cred.provider,
      label: cred.label,
      token_status: cred.token_status,
      token_expires_at: cred.token_expires_at?.toISOString() ?? null,
      refresh_token_expires_at: cred.refresh_token_expires_at?.toISOString() ?? null,
      has_refresh_token: !!cred.encrypted_refresh_token,
      is_active: cred.is_active,
      created_at: cred.created_at.toISOString(),
      updated_at: cred.updated_at.toISOString(),
    };
  }
}

/**
 * Provider-specific refresh token lifetimes (days from last use).
 * Providers not listed here have indefinite refresh tokens.
 */
const PROVIDER_REFRESH_DAYS: Record<string, number> = {
  // Google: 180 days of inactivity
  google: 180, google_calendar: 180, gmail: 180,
  // Microsoft: 90-day rolling window, resets on each use
  microsoft: 90, outlook: 90, outlook_calendar: 90, teams: 90,
  // HubSpot: 180-day rotating tokens
  hubspot: 180,
  // Zoom, Salesforce, GitHub: indefinite (not listed)
};

/**
 * Compute the refresh token expiry date for a provider.
 * Returns null for providers with indefinite refresh tokens.
 */
export function getRefreshTokenExpiresAt(provider: string): Date | null {
  const key = provider.toLowerCase();
  const days = PROVIDER_REFRESH_DAYS[key];
  return days ? new Date(Date.now() + days * 86_400_000) : null;
}
'''

_TS_ROTATION = '''\
/**
 * OAuth token rotation — generic provider interface + rotation job.
 *
 * Installed by: stablestack add-token-manager
 */

import { CredentialService, getRefreshTokenExpiresAt } from "./models";
import type { Credential, TokenStatus } from "./models";

export interface RefreshResult {
  success: boolean;
  message: string;
  new_access_token?: string;
  new_refresh_token?: string;
  expires_in_seconds?: number;
}

export interface OAuthProvider {
  name: string;
  checkTokenValidity(accessToken: string): TokenStatus;
  refreshToken(
    refreshToken: string,
    clientId: string,
    clientSecret: string
  ): Promise<RefreshResult>;
}

export interface RotationSummary {
  total: number;
  refreshed: number;
  skipped: number;
  failed: number;
  errors: string[];
}

/** Configurable thresholds */
export const REFRESH_THRESHOLD_DAYS = 30;
export const WARN_THRESHOLD_DAYS = 7;
export const REFRESH_TOKEN_THRESHOLD_DAYS = 30;

/**
 * Standard OAuth2 token refresh endpoint.
 * Works with Google, GitHub, Slack, and similar providers.
 */
export class GenericOAuthProvider implements OAuthProvider {
  constructor(
    public name: string,
    public tokenUrl: string,
    public timeout = 30_000
  ) {}

  checkTokenValidity(accessToken: string): TokenStatus {
    return accessToken ? "active" : "invalid";
  }

  async refreshToken(
    refreshToken: string,
    clientId: string,
    clientSecret: string
  ): Promise<RefreshResult> {
    try {
      const resp = await fetch(this.tokenUrl, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({
          grant_type: "refresh_token",
          refresh_token: refreshToken,
          client_id: clientId,
          client_secret: clientSecret,
        }),
        signal: AbortSignal.timeout(this.timeout),
      });

      if (!resp.ok) {
        const err = await resp.json().catch(() => ({}));
        return {
          success: false,
          message: `Refresh failed: ${err.error_description || resp.statusText}`,
        };
      }

      const data = await resp.json();
      return {
        success: true,
        message: "Token refreshed successfully",
        new_access_token: data.access_token,
        new_refresh_token: data.refresh_token,
        expires_in_seconds: data.expires_in,
      };
    } catch (e: any) {
      return { success: false, message: e.message };
    }
  }
}

/**
 * Rotate all expiring tokens for registered providers.
 *
 * Usage (with Prisma):
 *   const job = new TokenRotationJob(prisma);
 *   job.registerProvider(new GenericOAuthProvider("google", "https://oauth2.googleapis.com/token"));
 *   const summary = await job.run();
 */
export class TokenRotationJob {
  private providers = new Map<string, OAuthProvider>();

  // Pass your Prisma client or any DB abstraction
  constructor(private db: any) {}

  registerProvider(provider: OAuthProvider): void {
    this.providers.set(provider.name, provider);
  }

  async run(dryRun = false): Promise<RotationSummary> {
    const summary: RotationSummary = {
      total: 0,
      refreshed: 0,
      skipped: 0,
      failed: 0,
      errors: [],
    };

    const now = new Date();
    const accessCutoff = new Date(now.getTime() + REFRESH_THRESHOLD_DAYS * 86_400_000);
    const refreshCutoff = new Date(now.getTime() + REFRESH_TOKEN_THRESHOLD_DAYS * 86_400_000);

    const credentials: Credential[] = await this.db.credential.findMany({
      where: {
        is_active: true,
        OR: [
          { token_expires_at: { lt: accessCutoff } },
          { refresh_token_expires_at: { lt: refreshCutoff } },
          { token_expires_at: null },
        ],
      },
    });
    summary.total = credentials.length;

    for (const cred of credentials) {
      try {
        const provider = this.providers.get(cred.provider);
        if (!provider) {
          summary.skipped++;
          continue;
        }

        // Check if refresh token itself has expired (dead — can't refresh)
        if (cred.refresh_token_expires_at) {
          const refreshDaysLeft = Math.floor(
            (cred.refresh_token_expires_at.getTime() - now.getTime()) / 86_400_000
          );
          if (refreshDaysLeft < 0) {
            await this.db.credential.update({
              where: { id: cred.id },
              data: { token_status: "expired" },
            });
            summary.failed++;
            summary.errors.push(`Credential ${cred.id}: refresh token expired`);
            continue;
          }
        }

        if (cred.token_expires_at) {
          const daysLeft = Math.floor(
            (cred.token_expires_at.getTime() - now.getTime()) / 86_400_000
          );

          if (daysLeft > REFRESH_THRESHOLD_DAYS && !this.needsRefreshTokenRenewal(cred, refreshCutoff)) {
            summary.skipped++;
            continue;
          }

          // Update status
          // OAuth access tokens expire every ~1 hour — that's normal.
          // Only mark as "expired" if >7 days past expiry (refresh token
          // is likely dead too). Recently expired + refresh token = auto-refreshes.
          const newStatus: TokenStatus =
            daysLeft <= -7 ? "expired" : daysLeft <= 0 ? cred.token_status : daysLeft <= WARN_THRESHOLD_DAYS ? "expiring_soon" : cred.token_status;

          if (newStatus !== cred.token_status) {
            await this.db.credential.update({
              where: { id: cred.id },
              data: { token_status: newStatus },
            });
          }
        }

        const decrypted = CredentialService.decryptFields(cred);
        if (!decrypted.refresh_token) {
          summary.skipped++;
          continue;
        }

        if (dryRun) {
          summary.refreshed++;
          continue;
        }

        const result = await provider.refreshToken(
          decrypted.refresh_token,
          decrypted.client_id || "",
          decrypted.client_secret || ""
        );

        if (!result.success) {
          summary.failed++;
          summary.errors.push(`Credential ${cred.id}: ${result.message}`);
          continue;
        }

        const updateData: any = {
          token_status: "active",
          updated_at: now,
        };

        if (result.new_access_token) {
          const enc = CredentialService.encryptFields({
            access_token: result.new_access_token,
            refresh_token: result.new_refresh_token || decrypted.refresh_token,
            client_id: decrypted.client_id,
            client_secret: decrypted.client_secret,
          });
          Object.assign(updateData, enc);
        }

        if (result.expires_in_seconds) {
          updateData.token_expires_at = new Date(
            now.getTime() + result.expires_in_seconds * 1000
          );
        }

        // Reset refresh token expiry — using the token resets the inactivity window
        updateData.refresh_token_expires_at = getRefreshTokenExpiresAt(cred.provider);

        await this.db.credential.update({
          where: { id: cred.id },
          data: updateData,
        });

        summary.refreshed++;
      } catch (e: any) {
        summary.failed++;
        summary.errors.push(`Credential ${cred.id}: ${e.message}`);
      }
    }

    return summary;
  }

  /** Check if a credential's refresh token is approaching expiry. */
  private needsRefreshTokenRenewal(cred: Credential, cutoff: Date): boolean {
    return !!cred.refresh_token_expires_at && cred.refresh_token_expires_at < cutoff;
  }
}
'''

_TS_API = '''\
/**
 * Express router for credential management — CRUD, validation, rotation.
 *
 * Mount in your Express app:
 *   import { credentialRouter } from "./stablestack_token_manager/api";
 *   app.use("/api", credentialRouter);
 *
 * Installed by: stablestack add-token-manager
 */

import { Router, Request, Response } from "express";
import { CredentialService, getRefreshTokenExpiresAt } from "./models";
import type { Credential } from "./models";
import { GenericOAuthProvider, TokenRotationJob } from "./rotation";
import { getEncryptor } from "./encryption";

export const credentialRouter = Router();

/**
 * Replace this with your actual database access.
 * Examples use Prisma-style API.
 */
function getDb(): any {
  throw new Error(
    "Replace getDb() in stablestack_token_manager/api.ts with your " +
      "project\'s database client."
  );
}

// GET /api/credentials — list all (tokens masked)
credentialRouter.get("/credentials", async (req: Request, res: Response) => {
  const db = getDb();
  const where: any = { is_active: true };
  if (req.query.provider) where.provider = req.query.provider;

  const creds: Credential[] = await db.credential.findMany({ where });
  res.json(creds.map(CredentialService.toSafe));
});

// POST /api/credentials — create
credentialRouter.post("/credentials", async (req: Request, res: Response) => {
  const db = getDb();
  const { provider, label, access_token, refresh_token, client_id, client_secret, token_expires_at } = req.body;

  const encrypted = CredentialService.encryptFields({
    access_token,
    refresh_token,
    client_id,
    client_secret,
  });

  const cred = await db.credential.create({
    data: {
      provider,
      label: label || "",
      ...encrypted,
      token_expires_at: token_expires_at ? new Date(token_expires_at) : null,
      refresh_token_expires_at: getRefreshTokenExpiresAt(provider),
    },
  });

  res.status(201).json(CredentialService.toSafe(cred));
});

// PUT /api/credentials/:id — update
credentialRouter.put("/credentials/:id", async (req: Request, res: Response) => {
  const db = getDb();
  const id = parseInt(req.params.id, 10);
  const existing: Credential | null = await db.credential.findUnique({ where: { id } });

  if (!existing || !existing.is_active) {
    res.status(404).json({ error: "Credential not found" });
    return;
  }

  const updateData: any = { updated_at: new Date() };
  if (req.body.label !== undefined) updateData.label = req.body.label;
  if (req.body.token_expires_at !== undefined) {
    updateData.token_expires_at = new Date(req.body.token_expires_at);
  }

  if (req.body.access_token || req.body.refresh_token || req.body.client_id || req.body.client_secret) {
    const decrypted = CredentialService.decryptFields(existing);
    const encrypted = CredentialService.encryptFields({
      access_token: req.body.access_token || decrypted.access_token,
      refresh_token: req.body.refresh_token ?? decrypted.refresh_token,
      client_id: req.body.client_id ?? decrypted.client_id,
      client_secret: req.body.client_secret ?? decrypted.client_secret,
    });
    Object.assign(updateData, encrypted);
  }

  const updated = await db.credential.update({ where: { id }, data: updateData });
  res.json(CredentialService.toSafe(updated));
});

// DELETE /api/credentials/:id — soft-delete
credentialRouter.delete("/credentials/:id", async (req: Request, res: Response) => {
  const db = getDb();
  const id = parseInt(req.params.id, 10);
  const existing = await db.credential.findUnique({ where: { id } });

  if (!existing || !existing.is_active) {
    res.status(404).json({ error: "Credential not found" });
    return;
  }

  await db.credential.update({
    where: { id },
    data: { is_active: false, updated_at: new Date() },
  });
  res.json({ success: true, id });
});

// POST /api/credentials/:id/validate
credentialRouter.post("/credentials/:id/validate", async (req: Request, res: Response) => {
  const db = getDb();
  const id = parseInt(req.params.id, 10);
  const cred: Credential | null = await db.credential.findUnique({ where: { id } });

  if (!cred || !cred.is_active) {
    res.status(404).json({ error: "Credential not found" });
    return;
  }

  const now = new Date();
  if (cred.token_expires_at && cred.token_expires_at < now) {
    const daysSinceExpiry = Math.floor((now.getTime() - cred.token_expires_at.getTime()) / 86_400_000);
    // OAuth access tokens expire every ~1 hour — that's normal.
    // Only mark as "expired" if >7 days past expiry (refresh token
    // is likely dead too). Recently expired + refresh token = auto-refreshes.
    if (daysSinceExpiry > 7) {
      await db.credential.update({ where: { id }, data: { token_status: "expired" } });
      res.json({ id, status: "expired" });
      return;
    }
  }

  res.json({ id, status: cred.token_status });
});

// POST /api/credentials/:id/refresh
credentialRouter.post("/credentials/:id/refresh", async (req: Request, res: Response) => {
  const db = getDb();
  const id = parseInt(req.params.id, 10);
  const cred: Credential | null = await db.credential.findUnique({ where: { id } });

  if (!cred || !cred.is_active) {
    res.status(404).json({ error: "Credential not found" });
    return;
  }

  const decrypted = CredentialService.decryptFields(cred);
  if (!decrypted.refresh_token) {
    res.status(400).json({ error: "Credential has no refresh token" });
    return;
  }

  const provider = new GenericOAuthProvider(
    cred.provider,
    `https://${cred.provider}.example.com/oauth/token`
  );
  const result = await provider.refreshToken(
    decrypted.refresh_token,
    decrypted.client_id || "",
    decrypted.client_secret || ""
  );

  if (!result.success) {
    res.status(502).json({ error: `Refresh failed: ${result.message}` });
    return;
  }

  const encrypted = CredentialService.encryptFields({
    access_token: result.new_access_token || decrypted.access_token,
    refresh_token: result.new_refresh_token || decrypted.refresh_token,
    client_id: decrypted.client_id,
    client_secret: decrypted.client_secret,
  });

  const updateData: any = {
    ...encrypted,
    token_status: "active",
    updated_at: new Date(),
    refresh_token_expires_at: getRefreshTokenExpiresAt(cred.provider),
  };
  if (result.expires_in_seconds) {
    updateData.token_expires_at = new Date(Date.now() + result.expires_in_seconds * 1000);
  }

  const updated = await db.credential.update({ where: { id }, data: updateData });
  res.json(CredentialService.toSafe(updated));
});

// GET /api/credentials/status
credentialRouter.get("/credentials/status", async (req: Request, res: Response) => {
  const db = getDb();
  const creds: Credential[] = await db.credential.findMany({
    where: { is_active: true },
  });

  const now = new Date();
  const results = creds.map((c) => ({
    ...CredentialService.toSafe(c),
    days_until_expiry: c.token_expires_at
      ? Math.floor((c.token_expires_at.getTime() - now.getTime()) / 86_400_000)
      : null,
    days_until_refresh_expiry: c.refresh_token_expires_at
      ? Math.floor((c.refresh_token_expires_at.getTime() - now.getTime()) / 86_400_000)
      : null,
  }));

  res.json(results);
});

// POST /api/credentials/rotate
credentialRouter.post("/credentials/rotate", async (req: Request, res: Response) => {
  const db = getDb();
  const dryRun = req.query.dry_run === "true";
  const job = new TokenRotationJob(db);

  // TODO: Register your providers here, e.g.:
  // job.registerProvider(new GenericOAuthProvider("google", "https://oauth2.googleapis.com/token"));

  const summary = await job.run(dryRun);
  res.json(summary);
});
'''


# ---------------------------------------------------------------------------
# Cron scripts (both languages)
# ---------------------------------------------------------------------------

_PY_CRON = '''\
#!/usr/bin/env python3
"""
Token rotation cron job — refreshes all expiring OAuth tokens.

Run daily (e.g., 02:00 UTC) via cron, systemd timer, or scheduler:
    0 2 * * * /path/to/python scripts/rotate_tokens.py

Or invoke the /api/credentials/rotate endpoint via curl:
    curl -X POST http://localhost:8000/api/credentials/rotate

Installed by: stablestack add-token-manager
"""

import argparse
import logging
import os
import sys

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger("rotate_tokens")


def main() -> None:
    parser = argparse.ArgumentParser(description="Rotate expiring OAuth tokens")
    parser.add_argument(
        "--dry-run", action="store_true",
        help="Check tokens but don\'t actually refresh them",
    )
    parser.add_argument(
        "--database-url", default=os.environ.get("DATABASE_URL"),
        help="Database connection string (default: DATABASE_URL env var)",
    )
    args = parser.parse_args()

    if not args.database_url:
        logger.error("DATABASE_URL is required. Set it in the environment or pass --database-url.")
        sys.exit(1)

    # Import here so the script can show help without dependencies installed
    from sqlalchemy import create_engine
    from sqlalchemy.orm import sessionmaker

    # Add parent directory to path so stablestack_token_manager is importable
    sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

    from stablestack_token_manager.rotation import TokenRotationJob, GenericOAuthProvider

    engine = create_engine(args.database_url)
    Session = sessionmaker(bind=engine)
    session = Session()

    try:
        job = TokenRotationJob(session)

        # TODO: Register your providers here. Examples:
        # job.register_provider(GenericOAuthProvider("google", "https://oauth2.googleapis.com/token"))
        # job.register_provider(GenericOAuthProvider("github", "https://github.com/login/oauth/access_token"))
        # job.register_provider(GenericOAuthProvider("slack", "https://slack.com/api/oauth.v2.access"))

        logger.info("Starting token rotation job%s", " (dry run)" if args.dry_run else "")
        summary = job.run(dry_run=args.dry_run)

        logger.info(
            "Token rotation complete: %d total, %d refreshed, %d skipped, %d failed",
            summary.total, summary.refreshed, summary.skipped, summary.failed,
        )

        if summary.errors:
            for err in summary.errors:
                logger.error("  %s", err)

        if summary.failed > 0:
            sys.exit(1)

    finally:
        session.close()


if __name__ == "__main__":
    main()
'''

_TS_CRON = '''\
#!/usr/bin/env npx ts-node
/**
 * Token rotation cron job — refreshes all expiring OAuth tokens.
 *
 * Run daily (e.g., 02:00 UTC) via cron or scheduler:
 *   0 2 * * * npx ts-node scripts/rotate_tokens.ts
 *
 * Or invoke the /api/credentials/rotate endpoint via curl:
 *   curl -X POST http://localhost:3000/api/credentials/rotate
 *
 * Installed by: stablestack add-token-manager
 */

import { TokenRotationJob, GenericOAuthProvider } from "../stablestack_token_manager/rotation";

// TODO: Replace with your actual database client (e.g., PrismaClient)
async function getDb(): Promise<any> {
  // Example with Prisma:
  // const { PrismaClient } = await import("@prisma/client");
  // return new PrismaClient();
  throw new Error(
    "Replace getDb() in scripts/rotate_tokens.ts with your database client."
  );
}

async function main(): Promise<void> {
  const dryRun = process.argv.includes("--dry-run");
  const db = await getDb();

  const job = new TokenRotationJob(db);

  // TODO: Register your providers here. Examples:
  // job.registerProvider(new GenericOAuthProvider("google", "https://oauth2.googleapis.com/token"));
  // job.registerProvider(new GenericOAuthProvider("github", "https://github.com/login/oauth/access_token"));
  // job.registerProvider(new GenericOAuthProvider("slack", "https://slack.com/api/oauth.v2.access"));

  console.log(`Starting token rotation job${dryRun ? " (dry run)" : ""}...`);
  const summary = await job.run(dryRun);

  console.log(
    `Token rotation complete: ${summary.total} total, ${summary.refreshed} refreshed, ` +
      `${summary.skipped} skipped, ${summary.failed} failed`
  );

  if (summary.errors.length > 0) {
    for (const err of summary.errors) {
      console.error(`  ${err}`);
    }
  }

  // Disconnect database
  if (db.$disconnect) await db.$disconnect();

  if (summary.failed > 0) process.exit(1);
}

main().catch((err) => {
  console.error("Token rotation failed:", err);
  process.exit(1);
});
'''


# ---------------------------------------------------------------------------
# React UI (shared by both Python and TypeScript backends)
# ---------------------------------------------------------------------------

_REACT_UI = '''\
/**
 * Token Management Dashboard — view, validate, and rotate OAuth credentials.
 *
 * Framework-agnostic React component. Works with any API backend that
 * implements the stablestack_token_manager endpoints.
 *
 * Customize the API_BASE constant to match your backend URL.
 *
 * Installed by: stablestack add-token-manager
 */

import React, { useEffect, useState, useCallback } from "react";

const API_BASE = "/api/credentials";

// -- Types -------------------------------------------------------------------

interface CredentialSafe {
  id: number;
  provider: string;
  label: string;
  token_status: string;
  token_expires_at: string | null;
  refresh_token_expires_at: string | null;
  has_refresh_token: boolean;
  is_active: boolean;
  created_at: string;
  updated_at: string;
  days_until_expiry?: number | null;
  days_until_refresh_expiry?: number | null;
}

interface RotationSummary {
  total: number;
  refreshed: number;
  skipped: number;
  failed: number;
  errors: string[];
}

// -- Helpers -----------------------------------------------------------------

function statusColor(status: string, daysLeft?: number | null): string {
  if (status === "expired" || status === "invalid" || status === "revoked")
    return "#ef4444";
  // Only warn for tokens expiring soon (positive days <= 7), not recently
  // expired tokens which auto-refresh via their refresh token.
  if (status === "expiring_soon" || (daysLeft != null && daysLeft > 0 && daysLeft <= 7))
    return "#f59e0b";
  return "#22c55e";
}

function statusBg(status: string, daysLeft?: number | null): string {
  if (status === "expired" || status === "invalid" || status === "revoked")
    return "#fef2f2";
  if (status === "expiring_soon" || (daysLeft != null && daysLeft > 0 && daysLeft <= 7))
    return "#fffbeb";
  return "#f0fdf4";
}

function formatDate(iso: string | null): string {
  if (!iso) return "—";
  return new Date(iso).toLocaleDateString(undefined, {
    year: "numeric",
    month: "short",
    day: "numeric",
  });
}

function daysLabel(days: number | null | undefined): string {
  if (days == null) return "—";
  if (days <= -7) return "Expired";
  if (days < 0) return "Auto-refreshes";
  if (days === 0) return "Today";
  return `${days}d`;
}

function refreshExpiryColor(days: number | null | undefined): string {
  if (days == null) return "#9ca3af";  // gray for "Never" / unknown
  if (days <= 7 || days < 0) return "#ef4444";  // red
  if (days <= 30) return "#f59e0b";  // yellow
  return "#22c55e";  // green
}

function refreshExpiryLabel(iso: string | null, days: number | null | undefined): string {
  if (!iso) return "Never";
  if (days != null && days < 0) return "Expired";
  return formatDate(iso);
}

// -- API calls ---------------------------------------------------------------

async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
  const resp = await fetch(url, {
    headers: { "Content-Type": "application/json" },
    ...init,
  });
  if (!resp.ok) {
    const body = await resp.text().catch(() => "");
    throw new Error(`${resp.status}: ${body || resp.statusText}`);
  }
  return resp.json();
}

// -- Component ---------------------------------------------------------------

export default function TokenDashboard() {
  const [credentials, setCredentials] = useState<CredentialSafe[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [refreshing, setRefreshing] = useState<Set<number>>(new Set());
  const [rotating, setRotating] = useState(false);
  const [rotationResult, setRotationResult] = useState<RotationSummary | null>(
    null
  );
  const [messages, setMessages] = useState<
    Record<number, { ok: boolean; text: string }>
  >({});

  const load = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);
      const data = await fetchJson<CredentialSafe[]>(`${API_BASE}/status`);
      setCredentials(data);
    } catch (e: any) {
      setError(e.message);
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    load();
  }, [load]);

  const handleValidate = useCallback(
    async (id: number) => {
      setRefreshing((s) => new Set(s).add(id));
      try {
        const res = await fetchJson<{ id: number; status: string }>(
          `${API_BASE}/${id}/validate`,
          { method: "POST" }
        );
        setMessages((m) => ({
          ...m,
          [id]: { ok: true, text: `Status: ${res.status}` },
        }));
        await load();
      } catch (e: any) {
        setMessages((m) => ({ ...m, [id]: { ok: false, text: e.message } }));
      } finally {
        setRefreshing((s) => {
          const next = new Set(s);
          next.delete(id);
          return next;
        });
      }
    },
    [load]
  );

  const handleRefresh = useCallback(
    async (id: number) => {
      setRefreshing((s) => new Set(s).add(id));
      try {
        await fetchJson(`${API_BASE}/${id}/refresh`, { method: "POST" });
        setMessages((m) => ({
          ...m,
          [id]: { ok: true, text: "Token refreshed" },
        }));
        await load();
      } catch (e: any) {
        setMessages((m) => ({
          ...m,
          [id]: { ok: false, text: e.message },
        }));
      } finally {
        setRefreshing((s) => {
          const next = new Set(s);
          next.delete(id);
          return next;
        });
      }
    },
    [load]
  );

  const handleRotateAll = useCallback(
    async (dryRun: boolean) => {
      setRotating(true);
      setRotationResult(null);
      try {
        const res = await fetchJson<RotationSummary>(
          `${API_BASE}/rotate?dry_run=${dryRun}`,
          { method: "POST" }
        );
        setRotationResult(res);
        if (!dryRun) await load();
      } catch (e: any) {
        setError(e.message);
      } finally {
        setRotating(false);
      }
    },
    [load]
  );

  // -- Styles (inline to keep scaffold self-contained) -----------------------

  const card: React.CSSProperties = {
    border: "1px solid #e5e7eb",
    borderRadius: 8,
    padding: 20,
    marginBottom: 16,
    background: "#fff",
  };
  const badge = (
    bg: string,
    fg: string
  ): React.CSSProperties => ({
    display: "inline-block",
    padding: "2px 10px",
    borderRadius: 9999,
    fontSize: 12,
    fontWeight: 600,
    background: bg,
    color: fg,
  });
  const btn = (
    variant: "primary" | "secondary" | "danger"
  ): React.CSSProperties => ({
    padding: "6px 14px",
    borderRadius: 6,
    border: variant === "secondary" ? "1px solid #d1d5db" : "none",
    background:
      variant === "primary"
        ? "#2563eb"
        : variant === "danger"
        ? "#ef4444"
        : "#fff",
    color: variant === "secondary" ? "#374151" : "#fff",
    fontSize: 13,
    fontWeight: 500,
    cursor: "pointer",
  });

  // -- Render ----------------------------------------------------------------

  return (
    <div style={{ maxWidth: 960, margin: "0 auto", padding: 24, fontFamily: "system-ui, sans-serif" }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24 }}>
        <h1 style={{ fontSize: 24, fontWeight: 700, margin: 0 }}>Token Manager</h1>
        <div style={{ display: "flex", gap: 8 }}>
          <button style={btn("secondary")} onClick={() => handleRotateAll(true)} disabled={rotating}>
            {rotating ? "Running..." : "Dry Run"}
          </button>
          <button style={btn("primary")} onClick={() => handleRotateAll(false)} disabled={rotating}>
            {rotating ? "Running..." : "Rotate All"}
          </button>
          <button style={btn("secondary")} onClick={load} disabled={loading}>
            Refresh
          </button>
        </div>
      </div>

      {error && (
        <div style={{ ...card, borderColor: "#fca5a5", background: "#fef2f2", color: "#991b1b" }}>
          {error}
        </div>
      )}

      {rotationResult && (
        <div style={{ ...card, borderColor: rotationResult.failed > 0 ? "#fca5a5" : "#86efac" }}>
          <div style={{ display: "flex", gap: 12, flexWrap: "wrap", marginBottom: rotationResult.errors.length > 0 ? 12 : 0 }}>
            <span style={badge("#f0fdf4", "#166534")}>{rotationResult.refreshed} refreshed</span>
            <span style={badge("#f5f5f5", "#525252")}>{rotationResult.skipped} skipped</span>
            {rotationResult.failed > 0 && (
              <span style={badge("#fef2f2", "#991b1b")}>{rotationResult.failed} failed</span>
            )}
            <span style={badge("#f5f5f5", "#525252")}>{rotationResult.total} total</span>
          </div>
          {rotationResult.errors.map((e, i) => (
            <div key={i} style={{ fontSize: 13, color: "#991b1b", marginTop: 4 }}>{e}</div>
          ))}
        </div>
      )}

      {loading && credentials.length === 0 ? (
        <div style={{ textAlign: "center", padding: 48, color: "#9ca3af" }}>Loading credentials...</div>
      ) : credentials.length === 0 ? (
        <div style={{ textAlign: "center", padding: 48, color: "#9ca3af" }}>
          No credentials found. Create one via <code>POST {API_BASE}</code>.
        </div>
      ) : (
        <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
          <thead>
            <tr style={{ borderBottom: "2px solid #e5e7eb", textAlign: "left" }}>
              <th style={{ padding: "8px 12px" }}>Provider</th>
              <th style={{ padding: "8px 12px" }}>Label</th>
              <th style={{ padding: "8px 12px" }}>Status</th>
              <th style={{ padding: "8px 12px" }}>Expires</th>
              <th style={{ padding: "8px 12px" }}>Days Left</th>
              <th style={{ padding: "8px 12px" }}>Refresh Token</th>
              <th style={{ padding: "8px 12px" }}>Refresh Expires</th>
              <th style={{ padding: "8px 12px", textAlign: "center" }}>Actions</th>
            </tr>
          </thead>
          <tbody>
            {credentials.map((c) => {
              const msg = messages[c.id];
              return (
                <React.Fragment key={c.id}>
                  <tr style={{ borderBottom: "1px solid #f3f4f6" }}>
                    <td style={{ padding: "10px 12px", fontWeight: 500 }}>{c.provider}</td>
                    <td style={{ padding: "10px 12px" }}>{c.label || "—"}</td>
                    <td style={{ padding: "10px 12px" }}>
                      <span
                        style={badge(
                          statusBg(c.token_status, c.days_until_expiry),
                          statusColor(c.token_status, c.days_until_expiry)
                        )}
                      >
                        {c.token_status}
                      </span>
                    </td>
                    <td style={{ padding: "10px 12px" }}>{formatDate(c.token_expires_at)}</td>
                    <td
                      style={{
                        padding: "10px 12px",
                        color: statusColor(c.token_status, c.days_until_expiry),
                        fontWeight: 600,
                      }}
                    >
                      {daysLabel(c.days_until_expiry)}
                    </td>
                    <td style={{ padding: "10px 12px" }}>{c.has_refresh_token ? "Yes" : "No"}</td>
                    <td
                      style={{
                        padding: "10px 12px",
                        color: refreshExpiryColor(c.days_until_refresh_expiry),
                        fontWeight: 500,
                        fontSize: 13,
                      }}
                    >
                      {refreshExpiryLabel(c.refresh_token_expires_at, c.days_until_refresh_expiry)}
                    </td>
                    <td style={{ padding: "10px 12px", textAlign: "center" }}>
                      <div style={{ display: "flex", gap: 6, justifyContent: "center" }}>
                        <button
                          style={btn("secondary")}
                          onClick={() => handleValidate(c.id)}
                          disabled={refreshing.has(c.id)}
                        >
                          {refreshing.has(c.id) ? "..." : "Test"}
                        </button>
                        {c.has_refresh_token && (
                          <button
                            style={btn("primary")}
                            onClick={() => handleRefresh(c.id)}
                            disabled={refreshing.has(c.id)}
                          >
                            {refreshing.has(c.id) ? "..." : "Refresh"}
                          </button>
                        )}
                      </div>
                    </td>
                  </tr>
                  {msg && (
                    <tr>
                      <td
                        colSpan={8}
                        style={{
                          padding: "4px 12px 8px",
                          fontSize: 12,
                          color: msg.ok ? "#166534" : "#991b1b",
                          background: msg.ok ? "#f0fdf4" : "#fef2f2",
                        }}
                      >
                        {msg.text}
                      </td>
                    </tr>
                  )}
                </React.Fragment>
              );
            })}
          </tbody>
        </table>
      )}

      <div style={{ marginTop: 24, fontSize: 12, color: "#9ca3af", textAlign: "center" }}>
        Token Manager — installed by <code>stablestack add-token-manager</code>
      </div>
    </div>
  );
}
'''


# ---------------------------------------------------------------------------
# Slash command
# ---------------------------------------------------------------------------

_SLASH_COMMAND = '''\
---
description: "Migrate hardcoded tokens to encrypted storage with auto-rotation"
version: 1.0.0
---

# /token-manager - Secure Token Storage Migration

<objective>
Find hardcoded OAuth tokens and API keys, migrate them to the encrypted
TokenEncryptor + Credential model, and set up automatic rotation via
TokenRotationJob.
</objective>

<steps>
1. **Verify token manager is installed**
   - Check that `stablestack_token_manager/` directory exists
   - If not, run `stablestack add-token-manager` to install it

2. **Find hardcoded tokens**
   - Search for patterns: `token =`, `api_key =`, `secret =`, `Bearer `,
     `Authorization:`, environment variable reads for tokens
   - Look for `.env` files with token values
   - Check config files for embedded credentials

3. **Set up encryption key**
   - Ensure `ENCRYPTION_KEY` is in the environment or `.env`
   - If not present, generate one:
     Python: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
     Node: `node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))"`
   - Add to `.env.example` with a placeholder

4. **Create database migration**
   - Add the `credentials` table using the schema from `models.py` or `models.ts`
   - Python: Use Alembic — `alembic revision --autogenerate -m "Add credentials table"`
   - TypeScript: Use Prisma — `npx prisma migrate dev --name add-credentials`

5. **Wire up the API**
   - Mount the credential router in your app:
     Python: `app.include_router(router, prefix="/api")`
     TypeScript: `app.use("/api", credentialRouter)`
   - Replace `get_db()` / `getDb()` with your project\'s database session

6. **Migrate existing tokens**
   - For each hardcoded token found in step 2:
     a. Create a Credential record via the API or directly
     b. Replace the hardcoded value with a database lookup
     c. Remove the old hardcoded value

7. **Set up rotation (optional)**
   - Register OAuth providers in your app startup
   - Add a cron job or scheduled task to call `/api/credentials/rotate`
   - Recommended: daily at 02:00 UTC

8. **Verify**
   - Run `stablestack .` to confirm SEC001 (hardcoded secrets) findings are resolved
   - Test encrypt/decrypt round-trip
   - Test the credential CRUD API
</steps>

<rules>
- Never store plaintext tokens in the database — always use TokenEncryptor
- Store the ENCRYPTION_KEY in environment variables, never in code
- Use the Credential model\'s hybrid properties (Python) or CredentialService (TS) for transparent encryption
- Set up rotation for any OAuth tokens that expire
- Default refresh threshold: 30 days before expiry
</rules>
'''
