Widget Embed Verification Service

Response:
```python
"""
Sunaiva Talking Widget - Installation Verifier Service

Production-ready service for verifying widget installations on customer websites.
Performs async HTTP fetching, HTML parsing, CSP analysis, and version validation.
"""

import asyncio
import logging
import re
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional, Set, Tuple
from urllib.parse import urlparse

import httpx
from bs4 import BeautifulSoup
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, status
from pydantic import BaseModel, Field, HttpUrl, validator
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import declarative_base, Mapped, mapped_column
from packaging import version as pkg_version

# Configure structured logging
logger = logging.getLogger(__name__)

# -----------------------------------------------------------------------------
# Configuration Constants
# -----------------------------------------------------------------------------

WIDGET_SCRIPT_PATTERNS = [
    r"cdn\.sunaiva\.com/talking-widget",
    r"sunaiva-widget\.js",
    r"talking-widget@v\d+",
]
WIDGET_DOMAINS = ["cdn.sunaiva.com", "api.sunaiva.com", "ws.sunaiva.com"]
CURRENT_WIDGET_VERSION = "2.1.0"
REQUEST_TIMEOUT = 30.0
MAX_REDIRECTS = 5
USER_AGENT = "Sunaiva-Widget-Verifier/1.0"

# -----------------------------------------------------------------------------
# Database Models (SQLAlchemy Async)
# -----------------------------------------------------------------------------

Base = declarative_base()

class WidgetInstallation(Base):
    """Database model for widget installations."""

    __tablename__ = "widget_installations"

    id: Mapped[str] = mapped_column(primary_key=True)
    customer_id: Mapped[str] = mapped_column(index=True)
    website_url: Mapped[str] = mapped_column(nullable=False)
    expected_version: Mapped[Optional[str]] = mapped_column(nullable=True)
    last_verified_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
    verification_status: Mapped[str] = mapped_column(default="pending")
    installation_meta: Mapped[Optional[Dict[str, Any]]] = mapped_column(default=dict)
    created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
    updated_at: Mapped[datetime] = mapped_column(
        default=datetime.utcnow,
        onupdate=datetime.utcnow
    )

# -----------------------------------------------------------------------------
# Pydantic Models
# -----------------------------------------------------------------------------

class CSPReport(BaseModel):
    """Content Security Policy analysis report."""

    has_csp: bool = Field(description="Whether CSP headers are present")
    report_only: bool = Field(description="Whether CSP is in report-only mode")
    script_src_policy: Optional[str] = Field(None, description="Script source policy")
    connect_src_policy: Optional[str] = Field(None, description="Connect source policy")
    frame_src_policy: Optional[str] = Field(None, description="Frame source policy")
    violations: List[str] = Field(default_factory=list, description="Detected violations")
    warnings: List[str] = Field(default_factory=list, description="Potential issues")

    class Config:
        json_schema_extra = {
            "example": {
                "has_csp": True,
                "report_only": False,
                "script_src_policy": "'self' 'unsafe-inline' cdn.sunaiva.com",
                "violations": [],
                "warnings": ["Missing https: in script-src may block CDN"]
            }
        }

class ScriptInfo(BaseModel):
    """Detected widget script information."""

    found: bool = Field(description="Whether widget script was detected")
    src: Optional[str] = Field(None, description="Script source URL")
    version: Optional[str] = Field(None, description="Detected version")
    is_async: bool = Field(False, description="Whether script has async attribute")
    is_defer: bool = Field(False, description="Whether script has defer attribute")
    position: Optional[str] = Field(None, description="Position in DOM (head/body)")
    attributes: Dict[str, str] = Field(default_factory=dict, description="Additional attributes")

    class Config:
        json_schema_extra = {
            "example": {
                "found": True,
                "src": "https://cdn.sunaiva.com/talking-widget@v2.1.0/widget.min.js",
                "version": "2.1.0",
                "is_async": True,
                "position": "head"
            }
        }

class VerificationResult(BaseModel):
    """Complete verification result payload."""

    widget_id: str
    url: HttpUrl
    timestamp: datetime = Field(default_factory=datetime.utcnow)
    accessible: bool = Field(description="Whether website is accessible")
    http_status: Optional[int] = Field(None, description="HTTP status code")
    script_info: ScriptInfo = Field(default_factory=lambda: ScriptInfo(found=False))
    csp_report: CSPReport = Field(default_factory=lambda: CSPReport(has_csp=False))
    version_check: Dict[str, Any] = Field(default_factory=dict)
    installation_valid: bool = Field(False, description="Overall installation validity")
    errors: List[str] = Field(default_factory=list, description="Critical errors")
    recommendations: List[str] = Field(default_factory=list, description="Improvement suggestions")

    class Config:
        json_schema_extra = {
            "example": {
                "widget_id": "wid_12345",
                "url": "https://example.com",
                "accessible": True,
                "http_status": 200,
                "installation_valid": True,
                "script_info": {
                    "found": True,
                    "version": "2.1.0"
                }
            }
        }

class VerificationRequest(BaseModel):
    """Request body for manual verification triggers."""

    expected_version: Optional[str] = Field(
        None,
        description="Override expected version for this check"
    )
    check_csp: bool = Field(
        True,
        description="Whether to analyze CSP headers"
    )
    follow_redirects: bool = Field(
        True,
        description="Whether to follow HTTP redirects"
    )

# -----------------------------------------------------------------------------
# Custom Exceptions
# -----------------------------------------------------------------------------

class VerificationError(Exception):
    """Base exception for verification failures."""
    pass

class FetchError(VerificationError):
    """Failed to fetch customer website."""
    pass

class ParseError(VerificationError):
    """Failed to parse website content."""
    pass

class CSPParseError(VerificationError):
    """Failed to parse CSP headers."""
    pass

# -----------------------------------------------------------------------------
# Service Implementation
# -----------------------------------------------------------------------------

class InstallVerifierService:
    """
    Service for verifying widget installations on external websites.

    Handles async HTTP fetching, HTML parsing, CSP analysis, and version validation.
    """

    def __init__(
        self,
        db_session: AsyncSession,
        http_client: Optional[httpx.AsyncClient] = None
    ):
        self.db = db_session
        self.client = http_client or httpx.AsyncClient(
            timeout=httpx.Timeout(REQUEST_TIMEOUT),
            follow_redirects=True,
            headers={"User-Agent": USER_AGENT},
            limits=httpx.Limits(max_keepalive_connections=10, max_connections=20)
        )
        self.logger = logger.getChild(self.__class__.__name__)

    async def __aenter__(self):
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        await self.client.aclose()

    async def verify_installation(
        self,
        widget_id: str,
        expected_version: Optional[str] = None,
        check_csp: bool = True
    ) -> VerificationResult:
        """
        Perform comprehensive installation verification.

        Args:
            widget_id: Unique identifier for the widget installation
            expected_version: Expected widget version (defaults to CURRENT_WIDGET_VERSION)
            check_csp: Whether to perform CSP conflict analysis

        Returns:
            VerificationResult with complete analysis

        Raises:
            HTTPException: If widget not found or verification fails critically
        """
        expected_version = expected_version or CURRENT_WIDGET_VERSION
        start_time = datetime.utcnow()

        # Fetch widget configuration from database
        widget = await self._get_widget(widget_id)
        if not widget:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail=f"Widget installation {widget_id} not found"
            )

        target_url = widget.website_url
        self.logger.info(
            "Starting verification",
            extra={
                "widget_id": widget_id,
                "url": target_url,
                "expected_version": expected_version
            }
        )

        result = VerificationResult(
            widget_id=widget_id,
            url=target_url,
            expected_version=expected_version
        )

        try:
            # Step 1: Fetch website
            html_content, headers, http_status = await self._fetch_website(target_url)
            result.accessible = True
            result.http_status = http_status

            # Step 2: Parse and verify script
            result.script_info = await self._analyze_scripts(html_content)

            # Step 3: Version validation
            if result.script_info.found and result.script_info.version:
                result.version_check = self._validate_version(
                    result.script_info.version,
                    expected_version
                )

            # Step 4: CSP Analysis
            if check_csp:
                result.csp_report = await self._analyze_csp(headers, html_content)

            # Step 5: Determine overall validity
            result.installation_valid = self._determine_validity(result)

            # Generate recommendations
            result.recommendations = self._generate_recommendations(result)

        except FetchError as e:
            self.logger.error("Fetch failed", extra={"error": str(e), "widget_id": widget_id})
            result.accessible = False
            result.errors.append(f"Website inaccessible: {str(e)}")

        except Exception as e:
            self.logger.exception("Verification failed unexpectedly")
            result.errors.append(f"Internal verification error: {str(e)}")
            raise HTTPException(
                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                detail="Verification process failed"
            ) from e

        finally:
            # Update database with results (non-blocking if used with background tasks)
            await self._persist_results(widget_id, result)

            duration = (datetime.utcnow() - start_time).total_seconds()
            self.logger.info(
                "Verification completed",
                extra={
                    "widget_id": widget_id,
                    "duration_seconds": duration,
                    "valid": result.installation_valid
                }
            )

        return result

    async def _get_widget(self, widget_id: str) -> Optional[WidgetInstallation]:
        """Retrieve widget configuration from database."""
        query = select(WidgetInstallation).where(WidgetInstallation.id == widget_id)
        result = await self.db.execute(query)
        return result.scalar_one_or_none()

    async def _fetch_website(self, url: str) -> Tuple[str, Dict[str, str], int]:
        """
        Fetch website content with robust error handling.

        Returns:
            Tuple of (html_content, headers_dict, status_code)

        Raises:
            FetchError: If request fails
        """
        try:
            response = await self.client.get(
                url,
                headers={
                    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
                    "Accept-Language": "en-US,en;q=0.5",
                    "Accept-Encoding": "gzip, deflate, br",
                    "DNT": "1",
                    "Connection": "keep-alive",
                }
            )
            response.raise_for_status()

            # Ensure we got HTML
            content_type = response.headers.get("content-type", "").lower()
            if "text/html" not in content_type:
                raise FetchError(f"Expected HTML, got {content_type}")

            return response.text, dict(response.headers), response.status_code

        except httpx.TimeoutException as e:
            raise FetchError(f"Request timeout after {REQUEST_TIMEOUT}s") from e
        except httpx.HTTPStatusError as e:
            raise FetchError(f"HTTP {e.response.status_code}: {e.response.reason_phrase}") from e
        except httpx.RequestError as e:
            raise FetchError(f"Connection failed: {str(e)}") from e

    async def _analyze_scripts(self, html_content: str) -> ScriptInfo:
        """
        Parse HTML and detect widget scripts.

        Args:
            html_content: Raw HTML string

        Returns:
            ScriptInfo with detection details
        """
        try:
            soup = BeautifulSoup(html_content, 'html.parser')

            # Look for script tags matching our patterns
            scripts = soup.find_all('script', src=True)

            for script in scripts:
                src = script.get('src', '')
                src_lower = src.lower()

                # Check if this is our widget script
                if any(re.search(pattern, src_lower) for pattern in WIDGET_SCRIPT_PATTERNS):
                    position = "head" if script.find_parent("head") else "body"

                    return ScriptInfo(
                        found=True,
                        src=src,
                        version=self._extract_version(src, script),
                        is_async=script.get('async') is not None,
                        is_defer=script.get('defer') is not None,
                        position=position,
                        attributes={
                            k: v for k, v in script.attrs.items()
                            if k not in ['src', 'async', 'defer']
                        }
                    )

            # Check for inline initialization (fallback detection)
            inline_scripts = soup.find_all('script', string=re.compile(r'Sunaiva|TalkingWidget'))
            if inline_scripts:
                return ScriptInfo(
                    found=True,
                    version=None,  # Cannot determine version from inline
                    position="inline",
                    attributes={"inline": "true"}
                )

            return ScriptInfo(found=False)

        except Exception as e:
            self.logger.error("HTML parsing failed", exc_info=True)
            raise ParseError(f"Failed to parse HTML: {str(e)}") from e

    def _extract_version(self, src: str, script_tag: Any) -> Optional[str]:
        """
        Extract version from script URL or attributes.

        Supports:
        - URL patterns: /v2.1.0/ or @2.1.0 or -2.1.0.min.js
        - data-version attribute
        """
        # Try URL patterns
        version_patterns = [
            r'[/@]v?(\d+\.\d+\.\d+)',  # /v2.1.0/ or @2.1.0
            r'-(\d+\.\d+\.\d+)\.min\.js$',  # -2.1.0.min.js
            r'widget@(\d+\.\d+\.\d+)'  # widget@2.1.0
        ]

        for pattern in version_patterns:
            match = re.search(pattern, src)
            if match:
                return match.group(1)

        # Try data attribute
        version = script_tag.get('data-version')
        if version:
            return version

        return None

    def _validate_version(
        self,
        detected: str,
        expected: str
    ) -> Dict[str, Any]:
        """
        Compare detected version with expected version.

        Returns:
            Dict with comparison results and metadata
        """
        try:
            detected_v = pkg_version.parse(detected)
            expected_v = pkg_version.parse(expected)

            return {
                "detected": detected,
                "expected": expected,
                "is_current": detected_v >= expected_v,
                "is_supported": detected_v.major == expected_v.major,  # Major version match
                "needs_update": detected_v < expected_v,
                "severity": "critical" if detected_v.major < expected_v.major else
                           "recommended" if detected_v < expected_v else "none"
            }
        except Exception as e:
            self.logger.warning(f"Version parsing failed: {detected} vs {expected}")
            return {
                "detected": detected,
                "expected": expected,
                "is_current": detected == expected,
                "parse_error": str(e)
            }

    async def _analyze_csp(
        self,
        headers: Dict[str, str],
        html_content: str
    ) -> CSPReport:
        """
        Analyze Content Security Policy headers for conflicts.

        Checks:
        - script-src restrictions
        - connect-src for API calls
        - frame-src for iframe embeds
        """
        report = CSPReport(has_csp=False)

        # Check CSP headers
        csp_header = headers.get('content-security-policy')
        csp_report_only = headers.get('content-security-policy-report-only')

        if not csp_header and not csp_report_only:
            # Check meta tags in HTML
            soup = BeautifulSoup(html_content, 'html.parser')
            meta_csp = soup.find('meta', {'http-equiv': 'Content-Security-Policy'})
            if meta_csp:
                csp_header = meta_csp.get('content', '')

        if not csp_header and not csp_report_only:
            return report

        report.has_csp = True
        report.report_only = csp_report_only is not None and csp_header is None
        policy = csp_header or csp_report_only

        try:
            directives = self._parse_csp_policy(policy)

            # Analyze script-src
            script_src = directives.get('script-src', directives.get('default-src', []))
            report.script_src_policy = ' '.join(script_src) if script_src else None

            if script_src:
                violations = self._check_script_policy(script_src)
                report.violations.extend(violations)

            # Analyze connect-src (for API/WebSocket connections)
            connect_src = directives.get('connect-src', directives.get('default-src', []))
            report.connect_src_policy = ' '.join(connect_src) if connect_src else None

            if connect_src:
                ws_violations = self._check_connect_policy(connect_src)
                report.violations.extend(ws_violations)

            # Check for unsafe-inline without nonce/hash (security warning)
            if "'unsafe-inline'" in script_src and "'nonce-" not in policy:
                report.warnings.append(
                    "Using 'unsafe-inline' without nonces reduces CSP effectiveness"
                )

        except Exception as e:
            self.logger.error("CSP parsing failed", exc_info=True)
            raise CSPParseError(f"Failed to parse CSP: {str(e)}") from e

        return report

    def _parse_csp_policy(self, policy: str) -> Dict[str, List[str]]:
        """Parse CSP policy string into directives dictionary."""
        directives = {}
        # Handle both semicolon and comma separation (though semicolon is standard)
        parts = policy.split(';')

        for part in parts:
            part = part.strip()
            if not part:
                continue
            tokens = part.split()
            if tokens:
                directive_name = tokens[0]
                directive_values = tokens[1:]
                directives[directive_name] = directive_values

        return directives

    def _check_script_policy(self, script_src: List[str]) -> List[str]:
        """Check if script-src directive blocks widget scripts."""
        violations = []
        policy_str = ' '.join(script_src)

        # Check for 'none'
        if "'none'" in script_src and len(script_src) == 1:
            violations.append("script-src 'none' blocks all JavaScript execution")
            return violations

        # Check if our CDN domains are allowed
        cdn_allowed = any(
            domain in policy_str or
            "*" in script_src or  # Wildcard allows everything
            ("https:" in script_src and domain.startswith("https://"))
            for domain in WIDGET_DOMAINS
        )

        if not cdn_allowed and "'self'" in script_src:
            # Check if inline is allowed (for inline initialization)
            if "'unsafe-inline'" not in script_src and "'nonce-" not in policy_str:
                violations.append(
                    f"script-src 'self' blocks CDN domains: {', '.join(WIDGET_DOMAINS)}"
                )

        return violations

    def _check_connect_policy(self, connect_src: List[str]) -> List[str]:
        """Check if connect-src blocks API/WebSocket connections."""
        violations = []
        policy_str = ' '.join(connect_src)

        # Check WebSocket connections
        ws_domains = [d.replace("https://", "wss://").replace("http://", "ws://")
                     for d in WIDGET_DOMAINS]

        api_allowed = any(
            domain in policy_str or "*" in connect_src
            for domain in WIDGET_DOMAINS + ws_domains
        )

        if not api_allowed and "'self'" in connect_src:
            violations.append(
                "connect-src 'self' blocks widget API connections"
            )

        return violations

    def _determine_validity(self, result: VerificationResult) -> bool:
        """Determine if installation is valid based on all checks."""
        if not result.accessible:
            return False

        if not result.script_info.found:
            return False

        if result.csp_report.violations and not result.csp_report.report_only:
            return False

        if result.version_check.get("is_current") is False:
            # Outdated version is a warning, not necessarily invalid
            pass

        return True

    def _generate_recommendations(self, result: VerificationResult) -> List[str]:
        """Generate actionable recommendations based on findings."""
        recommendations = []

        if not result.script_info.found:
            recommendations.append(
                "Widget script not detected. Ensure the embed code is placed before </body> or in <head>"
            )
            return recommendations

        if not result.script_info.is_async and not result.script_info.is_defer:
            recommendations.append(
                "Consider adding 'async' or 'defer' attributes to prevent render-blocking"
            )

        if result.script_info.position == "body":
            recommendations.append(
                "Move script to <head> with 'defer' attribute for better performance"
            )

        if result.csp_report.violations:
            recommendations.append(
                f"Update CSP headers to allow: {', '.join(WIDGET_DOMAINS)}"
            )

        if result.version_check.get("needs_update"):
            recommendations.append(
                f"Update recommended: v{result.version_check['detected']} -> v{result.version_check['expected']}"
            )

        if result.csp_report.warnings:
            recommendations.extend(result.csp_report.warnings)

        return recommendations

    async def _persist_results(
        self,
        widget_id: str,
        result: VerificationResult
    ) -> None:
        """Persist verification results to database."""
        try:
            query = (
                update(WidgetInstallation)
                .where(WidgetInstallation.id == widget_id)
                .values(
                    last_verified_at=datetime.utcnow(),
                    verification_status="valid" if result.installation_valid else "invalid",
                    installation_meta={
                        "last_check": result.dict(),
                        "error_count": len(result.errors),
                        "script_version": result.script_info.version
                    }
                )
            )
            await self.db.execute(query)
            await self.db.commit()
        except Exception as e:
            self.logger.error("Failed to persist results", exc_info=True)
            await self.db.rollback()
            # Don't raise - verification already completed

# -----------------------------------------------------------------------------
# API Router
# -----------------------------------------------------------------------------

router = APIRouter(prefix="/api/widgets", tags=["widget-verification"])

async def get_db() -> AsyncSession:
    """Dependency to get async database session."""
    # This would typically come from your database setup module
    # For example: from .database import async_session
    raise NotImplementedError("Database session dependency not implemented")

@router.post(
    "/{widget_id}/verify-install",
    response_model=VerificationResult,
    status_code=status.HTTP_200_OK,
    summary="Verify widget installation",
    description="""
    Performs comprehensive verification of widget installation on customer website:

    - Fetches website and checks accessibility
    - Detects widget script presence and configuration
    - Validates script version against expected/current
    - Analyzes CSP headers for blocking conflicts
    - Generates actionable recommendations

    Results are stored in database for audit purposes.
    """
)
async def verify_widget_installation(
    widget_id: str = Path(..., description="Unique widget installation ID"),
    request: VerificationRequest = VerificationRequest(),
    background_tasks: BackgroundTasks = BackgroundTasks(),
    db: AsyncSession = Depends(get_db)
) -> VerificationResult:
    """
    Endpoint to trigger widget installation verification.

    Args:
        widget_id: UUID or unique identifier for the widget installation
        request: Verification parameters and overrides
        background_tasks: FastAPI background task handler
        db: Async database session

    Returns:
        VerificationResult with complete analysis details
    """
    async with InstallVerifierService(db) as verifier:
        result = await verifier.verify_installation(
            widget_id=widget_id,
            expected_version=request.expected_version,
            check_csp=request.check_csp
        )

    return result

# -----------------------------------------------------------------------------
# Background Task Support (Optional)
# -----------------------------------------------------------------------------

async def scheduled_verification_task(
    db: AsyncSession,
    widget_ids: Optional[List[str]] = None
) -> None:
    """
    Background task for batch verification (e.g., nightly checks).

    Args:
        db: Database session
        widget_ids: Optional list to check specific widgets, or all if None
    """
    if widget_ids is None:
        # Fetch all active widgets
        query = select(WidgetInstallation).where(
            WidgetInstallation.verification_status != "archived"
        )
        result = await db.execute(query)
        widgets = result.scalars().all()
        widget_ids = [w.id for w in widgets]

    async with InstallVerifierService(db) as verifier:
        for widget_id in widget_ids:
            try:
                await verifier.verify_installation(widget_id)
                await asyncio.sleep(0.5)  # Rate limiting between requests
            except Exception as e:
                logger.error(
                    "Scheduled verification failed",
                    extra={"widget_id": widget_id, "error": str(e)}
                )

# -----------------------------------------------------------------------------
# Module Export
# -----------------------------------------------------------------------------

__all__ = [
    "InstallVerifierService",
    "VerificationResult",
    "router",
    "WidgetInstallation",
    "scheduled_verification_task"
]
```


CRM Integration - HubSpot Connector

Response:
 ```python
"""
HubSpot CRM Connector for Voice Widget Platform.

Production-ready async implementation for bidirectional sync between
voice call data and HubSpot CRM. Handles OAuth2, contact management,
call logging, deal creation, and real-time webhooks.

File: Sunaiva/talking-widget/backend/crm_hubspot.py
"""

import asyncio
import base64
import hashlib
import hmac
import json
import logging
import secrets
import time
from datetime import datetime, timedelta
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple, Union
from urllib.parse import urlencode, parse_qs, urlparse

import asyncpg
import httpx
from fastapi import (
    APIRouter,
    BackgroundTasks,
    Depends,
    HTTPException,
    Request,
    Security,
    status,
)
from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel, EmailStr, Field, HttpUrl, validator, UUID4
from tenacity import (
    retry,
    retry_if_exception_type,
    stop_after_attempt,
    wait_exponential,
    before_sleep_log,
)

# Configure module logger
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

# ============================================================================
# CONSTANTS & CONFIGURATION
# ============================================================================

HUBSPOT_API_BASE = "https://api.hubapi.com"
HUBSPOT_OAUTH_AUTHORIZE_URL = "https://app.hubspot.com/oauth/authorize"
HUBSPOT_OAUTH_TOKEN_URL = f"{HUBSPOT_API_BASE}/oauth/v1/token"
HUBSPOT_API_VERSION = "v3"

# Token refresh buffer (5 minutes before expiry)
TOKEN_REFRESH_BUFFER_SECONDS = 300

# Retry configuration for HubSpot API calls
MAX_RETRIES = 3
MIN_WAIT_SECONDS = 1
MAX_WAIT_SECONDS = 10

# HubSpot rate limits (100 requests per 10 seconds for professional)
RATE_LIMIT_REQUESTS = 100
RATE_LIMIT_WINDOW = 10

# Custom property names in HubSpot
HS_PROPERTY_CALL_DURATION = "call_duration_seconds"
HS_PROPERTY_TRANSCRIPT_SUMMARY = "transcript_summary"
HS_PROPERTY_LEAD_SCORE = "voice_lead_score"
HS_PROPERTY_CALL_RECORDING_URL = "call_recording_url"
HS_PROPERTY_LAST_CALL_DATE = "last_call_date"

# ============================================================================
# DATABASE SCHEMA (PostgreSQL)
# ============================================================================
"""
-- Required tables:

CREATE TABLE hubspot_integrations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    portal_id BIGINT NOT NULL UNIQUE,
    access_token_encrypted TEXT NOT NULL,
    refresh_token_encrypted TEXT NOT NULL,
    expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
    token_type VARCHAR(50) DEFAULT 'Bearer',
    scope TEXT[],
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    is_active BOOLEAN DEFAULT TRUE,
    settings JSONB DEFAULT '{}'
);

CREATE TABLE hubspot_sync_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    integration_id UUID NOT NULL REFERENCES hubspot_integrations(id),
    sync_type VARCHAR(50) NOT NULL, -- 'contact', 'deal', 'call', 'webhook'
    external_id VARCHAR(255), -- HubSpot object ID
    status VARCHAR(50) NOT NULL, -- 'success', 'error', 'pending'
    payload JSONB,
    error_message TEXT,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE TABLE hubspot_webhook_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id VARCHAR(255) UNIQUE NOT NULL, -- HubSpot event ID for idempotency
    subscription_type VARCHAR(100) NOT NULL,
    portal_id BIGINT NOT NULL,
    object_id BIGINT NOT NULL,
    change_source VARCHAR(100),
    occurred_at TIMESTAMP WITH TIME ZONE,
    processed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    status VARCHAR(50) DEFAULT 'pending'
);

CREATE INDEX idx_hubspot_sync_logs_integration ON hubspot_sync_logs(integration_id, created_at);
CREATE INDEX idx_hubspot_webhook_events_id ON hubspot_webhook_events(event_id);
"""

# ============================================================================
# EXCEPTIONS
# ============================================================================

class HubSpotError(Exception):
    """Base exception for HubSpot connector errors."""

    def __init__(
        self,
        message: str,
        status_code: Optional[int] = None,
        response_data: Optional[Dict] = None
    ):
        super().__init__(message)
        self.status_code = status_code
        self.response_data = response_data or {}

class HubSpotAuthError(HubSpotError):
    """Authentication or authorization errors."""
    pass

class HubSpotRateLimitError(HubSpotError):
    """Rate limit exceeded."""

    def __init__(self, message: str, retry_after: Optional[int] = None):
        super().__init__(message, status_code=429)
        self.retry_after = retry_after

class HubSpotValidationError(HubSpotError):
    """Data validation errors."""
    pass

# ============================================================================
# PYDANTIC MODELS
# ============================================================================

class CallData(BaseModel):
    """Voice call data model."""

    call_id: str = Field(..., description="Unique call identifier")
    phone_number: Optional[str] = Field(None, description="Contact phone number")
    email: Optional[EmailStr] = Field(None, description="Contact email")
    contact_name: Optional[str] = Field(None, description="Contact name")
    duration_seconds: int = Field(..., ge=0, description="Call duration in seconds")
    transcript_summary: Optional[str] = Field(None, max_length=65535)
    recording_url: Optional[HttpUrl] = None
    lead_score: Optional[float] = Field(None, ge=0, le=100)
    call_timestamp: datetime = Field(default_factory=datetime.utcnow)
    direction: str = Field(default="inbound", regex="^(inbound|outbound)$")
    outcome: Optional[str] = Field(None, description="Call outcome (e.g., 'qualified', 'no_answer')")
    notes: Optional[str] = None

    class Config:
        json_encoders = {datetime: lambda v: v.isoformat()}

class DealData(BaseModel):
    """Deal creation data model."""

    deal_name: str = Field(..., min_length=1, max_length=255)
    amount: Optional[float] = Field(None, ge=0)
    close_date: Optional[datetime] = None
    pipeline: Optional[str] = Field(None, description="Pipeline ID or name")
    stage: Optional[str] = Field(None, description="Stage ID")
    associated_contact_email: Optional[EmailStr] = None
    associated_company_domain: Optional[str] = None
    custom_properties: Dict[str, Any] = Field(default_factory=dict)

class HubSpotWebhookEvent(BaseModel):
    """HubSpot webhook payload model."""

    event_id: int = Field(..., alias="eventId")
    subscription_id: int = Field(..., alias="subscriptionId")
    portal_id: int = Field(..., alias="portalId")
    app_id: int = Field(..., alias="appId")
    occurred_at: int = Field(..., alias="occurredAt")
    subscription_type: str = Field(..., alias="subscriptionType")
    attempt_number: int = Field(..., alias="attemptNumber")
    object_id: int = Field(..., alias="objectId")
    change_source: Optional[str] = Field(None, alias="changeSource")
    property_name: Optional[str] = Field(None, alias="propertyName")
    property_value: Optional[str] = Field(None, alias="propertyValue")

    class Config:
        allow_population_by_field_name = True

class OAuthCallbackRequest(BaseModel):
    """OAuth callback request model."""

    code: str
    state: str

# ============================================================================
# DATABASE DEPENDENCIES
# ============================================================================

async def get_db_pool() -> asyncpg.Pool:
    """
    Get PostgreSQL connection pool.

    In production, this should be provided via FastAPI dependency injection
    from the main application context.
    """
    # This is a placeholder - actual implementation should retrieve
    # from app.state or dependency injection
    raise NotImplementedError("Database pool must be configured in main app")

# ============================================================================
# ENCRYPTION UTILITIES
# ============================================================================

class TokenEncryption:
    """Simple encryption for tokens (use proper KMS in production)."""

    @staticmethod
    def encrypt_token(token: str, encryption_key: str) -> str:
        """Encrypt token using Fernet-like approach (simplified)."""
        # In production, use cryptography.fernet or AWS KMS
        # This is a placeholder implementation
        combined = f"{token}:{encryption_key}"
        return base64.b64encode(combined.encode()).decode()

    @staticmethod
    def decrypt_token(encrypted_token: str, encryption_key: str) -> str:
        """Decrypt token."""
        try:
            decoded = base64.b64decode(encrypted_token.encode()).decode()
            token, _ = decoded.rsplit(":", 1)
            return token
        except Exception as e:
            logger.error("Failed to decrypt token")
            raise HubSpotAuthError("Token decryption failed") from e

# ============================================================================
# HUBSPOT API CLIENT
# ============================================================================

class HubSpotAPIClient:
    """
    Async HTTP client for HubSpot API with automatic retries,
    rate limiting, and token refresh.
    """

    def __init__(
        self,
        access_token: str,
        refresh_token: Optional[str] = None,
        token_expires_at: Optional[datetime] = None,
        integration_id: Optional[str] = None,
        db_pool: Optional[asyncpg.Pool] = None,
        encryption_key: Optional[str] = None,
    ):
        self.access_token = access_token
        self.refresh_token = refresh_token
        self.token_expires_at = token_expires_at
        self.integration_id = integration_id
        self.db_pool = db_pool
        self.encryption_key = encryption_key
        self._client: Optional[httpx.AsyncClient] = None
        self._last_request_time: float = 0
        self._request_count: int = 0
        self._rate_limit_lock = asyncio.Lock()

    async def __aenter__(self) -> "HubSpotAPIClient":
        timeout = httpx.Timeout(30.0, connect=5.0)
        limits = httpx.Limits(max_keepalive_connections=5, max_connections=10)
        self._client = httpx.AsyncClient(timeout=timeout, limits=limits)
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
        if self._client:
            await self._client.aclose()

    async def _ensure_valid_token(self) -> None:
        """Refresh token if expired or about to expire."""
        if not self.token_expires_at or not self.refresh_token:
            return

        if datetime.utcnow() >= (self.token_expires_at - timedelta(seconds=TOKEN_REFRESH_BUFFER_SECONDS)):
            if not self.db_pool or not self.integration_id:
                raise HubSpotAuthError("Cannot refresh token: missing db_pool or integration_id")

            logger.info(f"Refreshing access token for integration {self.integration_id}")
            await self._refresh_access_token()

    async def _refresh_access_token(self) -> None:
        """Perform token refresh using refresh token."""
        if not self.refresh_token or not self.db_pool:
            raise HubSpotAuthError("Refresh token or database not available")

        try:
            async with httpx.AsyncClient() as client:
                response = await client.post(
                    HUBSPOT_OAUTH_TOKEN_URL,
                    data={
                        "grant_type": "refresh_token",
                        "client_id": settings.HUBSPOT_CLIENT_ID,
                        "client_secret": settings.HUBSPOT_CLIENT_SECRET,
                        "refresh_token": self.refresh_token,
                    },
                    headers={"Content-Type": "application/x-www-form-urlencoded"},
                )

                if response.status_code != 200:
                    raise HubSpotAuthError(f"Token refresh failed: {response.text}")

                data = response.json()
                self.access_token = data["access_token"]
                expires_in = data.get("expires_in", 21600)  # Default 6 hours
                self.token_expires_at = datetime.utcnow() + timedelta(seconds=expires_in)

                # Update database
                if self.integration_id:
                    async with self.db_pool.acquire() as conn:
                        await conn.execute(
                            """
                            UPDATE hubspot_integrations
                            SET access_token_encrypted = $1,
                                expires_at = $2,
                                updated_at = NOW()
                            WHERE id = $3
                            """,
                            TokenEncryption.encrypt_token(self.access_token, self.encryption_key),
                            self.token_expires_at,
                            self.integration_id,
                        )

        except httpx.HTTPError as e:
            logger.error(f"HTTP error during token refresh: {e}")
            raise HubSpotAuthError(f"Token refresh HTTP error: {e}") from e

    async def _rate_limit(self) -> None:
        """Simple rate limiting to avoid 429 errors."""
        async with self._rate_limit_lock:
            now = time.time()
            if now - self._last_request_time >= RATE_LIMIT_WINDOW:
                self._request_count = 0
                self._last_request_time = now

            if self._request_count >= RATE_LIMIT_REQUESTS:
                sleep_time = RATE_LIMIT_WINDOW - (now - self._last_request_time)
                if sleep_time > 0:
                    logger.warning(f"Rate limit approaching, sleeping for {sleep_time}s")
                    await asyncio.sleep(sleep_time)
                self._request_count = 0
                self._last_request_time = time.time()

            self._request_count += 1

    @retry(
        retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.NetworkError)),
        stop=stop_after_attempt(MAX_RETRIES),
        wait=wait_exponential(multiplier=1, min=MIN_WAIT_SECONDS, max=MAX_WAIT_SECONDS),
        before_sleep=before_sleep_log(logger, logging.WARNING),
    )
    async def request(
        self,
        method: str,
        endpoint: str,
        json_data: Optional[Dict] = None,
        params: Optional[Dict] = None,
        headers: Optional[Dict] = None,
    ) -> Dict[str, Any]:
        """
        Make authenticated request to HubSpot API.

        Args:
            method: HTTP method
            endpoint: API endpoint (without base URL)
            json_data: JSON payload
            params: Query parameters
            headers: Additional headers

        Returns:
            JSON response data

        Raises:
            HubSpotError: On API errors
            HubSpotRateLimitError: On rate limiting
        """
        if not self._client:
            raise RuntimeError("Client not initialized. Use async context manager.")

        await self._ensure_valid_token()
        await self._rate_limit()

        url = f"{HUBSPOT_API_BASE}/{endpoint}"
        request_headers = {
            "Authorization": f"Bearer {self.access_token}",
            "Content-Type": "application/json",
        }
        if headers:
            request_headers.update(headers)

        try:
            response = await self._client.request(
                method=method,
                url=url,
                json=json_data,
                params=params,
                headers=request_headers,
            )

            if response.status_code == 429:
                retry_after = int(response.headers.get("Retry-After", 10))
                raise HubSpotRateLimitError("Rate limit exceeded", retry_after=retry_after)

            if response.status_code == 401:
                raise HubSpotAuthError("Invalid or expired access token", status_code=401)

            response.raise_for_status()

            if response.status_code == 204:
                return {}

            return response.json()

        except httpx.HTTPStatusError as e:
            error_data = {}
            try:
                error_data = e.response.json()
            except:
                pass

            logger.error(f"HubSpot API error: {e.response.status_code} - {error_data}")

            if e.response.status_code == 400:
                raise HubSpotValidationError(
                    f"Validation error: {error_data}",
                    status_code=400,
                    response_data=error_data,
                )

            raise HubSpotError(
                f"HubSpot API error: {e}",
                status_code=e.response.status_code,
                response_data=error_data,
            ) from e

# ============================================================================
# HUBSPOT CONNECTOR SERVICE
# ============================================================================

class HubSpotConnector:
    """
    High-level service for HubSpot CRM operations.
    Handles contacts, deals, activities, and custom properties.
    """

    def __init__(
        self,
        client: HubSpotAPIClient,
        db_pool: asyncpg.Pool,
        integration_id: str,
    ):
        self.client = client
        self.db_pool = db_pool
        self.integration_id = integration_id

    async def _log_sync(
        self,
        sync_type: str,
        status: str,
        external_id: Optional[str] = None,
        payload: Optional[Dict] = None,
        error_message: Optional[str] = None,
    ) -> None:
        """Log sync operation to database."""
        try:
            async with self.db_pool.acquire() as conn:
                await conn.execute(
                    """
                    INSERT INTO hubspot_sync_logs
                    (integration_id, sync_type, external_id, status, payload, error_message)
                    VALUES ($1, $2, $3, $4, $5, $6)
                    """,
                    self.integration_id,
                    sync_type,
                    external_id,
                    status,
                    json.dumps(payload) if payload else None,
                    error_message,
                )
        except Exception as e:
            logger.error(f"Failed to log sync operation: {e}")

    async def ensure_custom_properties(self) -> None:
        """
        Ensure required custom properties exist in HubSpot.
        Should be called during initial setup.
        """
        properties_to_create = [
            {
                "name": HS_PROPERTY_CALL_DURATION,
                "label": "Call Duration (Seconds)",
                "type": "number",
                "fieldType": "number",
                "groupName": "contactinformation",
            },
            {
                "name": HS_PROPERTY_TRANSCRIPT_SUMMARY,
                "label": "Transcript Summary",
                "type": "string",
                "fieldType": "textarea",
                "groupName": "contactinformation",
            },
            {
                "name": HS_PROPERTY_LEAD_SCORE,
                "label": "Voice Lead Score",
                "type": "number",
                "fieldType": "number",
                "groupName": "contactinformation",
            },
            {
                "name": HS_PROPERTY_CALL_RECORDING_URL,
                "label": "Call Recording URL",
                "type": "string",
                "fieldType": "text",
                "groupName": "contactinformation",
            },
            {
                "name": HS_PROPERTY_LAST_CALL_DATE,
                "label": "Last Call Date",
                "type": "datetime",
                "fieldType": "date",
                "groupName": "contactinformation",
            },
        ]

        for prop in properties_to_create:
            try:
                await self.client.request(
                    "POST",
                    f"crm/{HUBSPOT_API_VERSION}/properties/contacts",
                    json_data=prop,
                )
                logger.info(f"Created property {prop['name']}")
            except HubSpotError as e:
                if e.status_code == 409:  # Already exists
                    logger.debug(f"Property {prop['name']} already exists")
                else:
                    raise

    async def find_contact_by_email(self, email: str) -> Optional[Dict[str, Any]]:
        """
        Search for contact by email address.

        Args:
            email: Contact email

        Returns:
            Contact data if found, None otherwise
        """
        try:
            result = await self.client.request(
                "POST",
                f"crm/{HUBSPOT_API_VERSION}/objects/contacts/search",
                json_data={
                    "filterGroups": [{
                        "filters": [{
                            "propertyName": "email",
                            "operator": "EQ",
                            "value": email,
                        }]
                    }],
                    "properties": ["email", "firstname", "lastname", "phone"],
                    "limit": 1,
                },
            )

            if result.get("total", 0) > 0:
                return result["results"][0]
            return None

        except HubSpotError as e:
            logger.error(f"Error searching for contact: {e}")
            raise

    async def create_or_update_contact(
        self,
        call_data: CallData,
    ) -> Dict[str, Any]:
        """
        Create new contact or update existing one with call data.

        Args:
            call_data: Voice call information

        Returns:
            Created/updated contact data
        """
        properties = {
            "email": call_data.email,
            "phone": call_data.phone_number,
        }

        if call_data.contact_name:
            parts = call_data.contact_name.split(" ", 1)
            properties["firstname"] = parts[0]
            if len(parts) > 1:
                properties["lastname"] = parts[1]

        # Add custom properties
        properties[HS_PROPERTY_CALL_DURATION] = call_data.duration_seconds
        properties[HS_PROPERTY_LAST_CALL_DATE] = call_data.call_timestamp.isoformat()

        if call_data.transcript_summary:
            properties[HS_PROPERTY_TRANSCRIPT_SUMMARY] = call_data.transcript_summary[:65535]

        if call_data.lead_score is not None:
            properties[HS_PROPERTY_LEAD_SCORE] = call_data.lead_score

        if call_data.recording_url:
            properties[HS_PROPERTY_CALL_RECORDING_URL] = str(call_data.recording_url)

        try:
            existing = None
            if call_data.email:
                existing = await self.find_contact_by_email(call_data.email)

            if existing:
                # Update existing
                contact_id = existing["id"]
                result = await self.client.request(
                    "PATCH",
                    f"crm/{HUBSPOT_API_VERSION}/objects/contacts/{contact_id}",
                    json_data={"properties": properties},
                )
                await self._log_sync(
                    "contact", "success", contact_id,
                    {"action": "update", "call_id": call_data.call_id}
                )
                logger.info(f"Updated contact {contact_id}")
            else:
                # Create new
                result = await self.client.request(
                    "POST",
                    f"crm/{HUBSPOT_API_VERSION}/objects/contacts",
                    json_data={"properties": properties},
                )
                contact_id = result["id"]
                await self._log_sync(
                    "contact", "success", contact_id,
                    {"action": "create", "call_id": call_data.call_id}
                )
                logger.info(f"Created contact {contact_id}")

            return result

        except HubSpotError as e:
            await self._log_sync(
                "contact", "error", None,
                {"call_id": call_data.call_id, "email": call_data.email},
                str(e),
            )
            raise

    async def log_call_activity(
        self,
        call_data: CallData,
        contact_id: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Log call as engagement/activity in HubSpot.

        Args:
            call_data: Call information
            contact_id: HubSpot contact ID (if known)

        Returns:
            Created engagement data
        """
        if not contact_id and call_data.email:
            contact = await self.find_contact_by_email(call_data.email)
            if contact:
                contact_id = contact["id"]

        if not contact_id:
            raise HubSpotValidationError("Contact ID required to log call activity")

        # Create call engagement using CRM Objects (newer API)
        engagement_data = {
            "properties": {
                "hs_timestamp": call_data.call_timestamp.isoformat(),
                "hs_call_body": call_data.notes or call_data.transcript_summary or "Voice call",
                "hs_call_duration": call_data.duration_seconds * 1000,  # milliseconds
                "hs_call_from_number": call_data.phone_number if call_data.direction == "inbound" else None,
                "hs_call_to_number": call_data.phone_number if call_data.direction == "outbound" else None,
                "hs_call_status": "COMPLETED",
                "hs_call_title": f"Voice Call - {call_data.direction}",
                "hs_call_recording_url": str(call_data.recording_url) if call_data.recording_url else None,
            },
            "associations": [
                {
                    "to": {"id": contact_id},
                    "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 194}],  # Contact to Call
                }
            ],
        }

        try:
            result = await self.client.request(
                "POST",
                f"crm/{HUBSPOT_API_VERSION}/objects/calls",
                json_data=engagement_data,
            )

            await self._log_sync(
                "call", "success", result["id"],
                {"contact_id": contact_id, "call_id": call_data.call_id}
            )

            logger.info(f"Logged call activity {result['id']} for contact {contact_id}")
            return result

        except HubSpotError as e:
            await self._log_sync(
                "call", "error", None,
                {"contact_id": contact_id, "call_id": call_data.call_id},
                str(e),
            )
            raise

    async def create_deal_from_lead(
        self,
        deal_data: DealData,
        call_data: Optional[CallData] = None,
    ) -> Dict[str, Any]:
        """
        Create deal and associate with contact/company.

        Args:
            deal_data: Deal information
            call_data: Optional call data for context

        Returns:
            Created deal data
        """
        properties = {
            "dealname": deal_data.deal_name,
            "amount": deal_data.amount,
            "pipeline": deal_data.pipeline or "default",
        }

        if deal_data.close_date:
            properties["closedate"] = deal_data.close_date.isoformat()

        if deal_data.stage:
            properties["dealstage"] = deal_data.stage

        # Add custom properties
        properties.update(deal_data.custom_properties)

        associations = []

        # Find and associate contact
        if deal_data.associated_contact_email:
            contact = await self.find_contact_by_email(deal_data.associated_contact_email)
            if contact:
                associations.append({
                    "to": {"id": contact["id"]},
                    "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 3}],  # Contact to Deal
                })

        payload = {"properties": properties}
        if associations:
            payload["associations"] = associations

        try:
            result = await self.client.request(
                "POST",
                f"crm/{HUBSPOT_API_VERSION}/objects/deals",
                json_data=payload,
            )

            await self._log_sync(
                "deal", "success", result["id"],
                {"deal_name": deal_data.deal_name, "amount": deal_data.amount}
            )

            logger.info(f"Created deal {result['id']}")
            return result

        except HubSpotError as e:
            await self._log_sync(
                "deal", "error", None,
                {"deal_name": deal_data.deal_name},
                str(e),
            )
            raise

# ============================================================================
# WEBHOOK HANDLER
# ============================================================================

class HubSpotWebhookHandler:
    """
    Handles incoming HubSpot webhooks for real-time sync.
    Verifies signatures and processes events idempotently.
    """

    def __init__(
        self,
        client_secret: str,
        db_pool: asyncpg.Pool,
    ):
        self.client_secret = client_secret
        self.db_pool = db_pool

    def verify_signature(
        self,
        request_body: bytes,
        signature: str,
    ) -> bool:
        """
        Verify HubSpot webhook signature.

        Args:
            request_body: Raw request body
            signature: X-HubSpot-Signature header value

        Returns:
            True if signature valid
        """
        # HubSpot uses SHA-256 hash of client secret + request body
        expected = hashlib.sha256(
            self.client_secret.encode() + request_body
        ).hexdigest()

        return hmac.compare_digest(expected, signature)

    async def process_event(
        self,
        event: HubSpotWebhookEvent,
        background_tasks: BackgroundTasks,
    ) -> None:
        """
        Process webhook event idempotently.

        Args:
            event: Parsed webhook event
            background_tasks: FastAPI background tasks
        """
        # Check for duplicate
        async with self.db_pool.acquire() as conn:
            existing = await conn.fetchval(
                "SELECT id FROM hubspot_webhook_events WHERE event_id = $1",
                str(event.event_id),
            )

            if existing:
                logger.debug(f"Skipping duplicate event {event.event_id}")
                return

            # Record event
            await conn.execute(
                """
                INSERT INTO hubspot_webhook_events
                (event_id, subscription_type, portal_id, object_id, change_source, occurred_at)
                VALUES ($1, $2, $3, $4, $5, to_timestamp($6 / 1000.0))
                """,
                str(event.event_id),
                event.subscription_type,
                event.portal_id,
                event.object_id,
                event.change_source,
                event.occurred_at,
            )

        # Process based on event type
        if "contact" in event.subscription_type:
            background_tasks.add_task(
                self._handle_contact_change,
                event,
            )
        elif "deal" in event.subscription_type:
            background_tasks.add_task(
                self._handle_deal_change,
                event,
            )

    async def _handle_contact_change(self, event: HubSpotWebhookEvent) -> None:
        """Handle contact property changes."""
        logger.info(f"Processing contact change: {event.object_id}")
        # Implement bidirectional sync logic here
        # e.g., update internal database if HubSpot is source of truth

    async def _handle_deal_change(self, event: HubSpotWebhookEvent) -> None:
        """Handle deal stage changes."""
        logger.info(f"Processing deal change: {event.object_id}")
        # Implement deal sync logic here

# ============================================================================
# AUTHENTICATION SERVICE
# ============================================================================

class HubSpotAuthService:
    """
    Manages OAuth2 flow for HubSpot integrations.
    """

    def __init__(
        self,
        client_id: str,
        client_secret: str,
        redirect_uri: str,
        db_pool: asyncpg.Pool,
        encryption_key: str,
    ):
        self.client_id = client_id
        self.client_secret = client_secret
        self.redirect_uri = redirect_uri
        self.db_pool = db_pool
        self.encryption_key = encryption_key
        self._state


CRM Integration - GoHighLevel Connector

Response:
```python
"""
GoHighLevel (GHL) CRM Connector for Voice Widget Platform.

This module provides async integration with GoHighLevel CRM for contact management,
call logging, workflow automation, and custom field synchronization.

File: Sunaiva/talking-widget/backend/crm_ghl.py
"""

import asyncio
import logging
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional, Union
from functools import wraps

import httpx
from pydantic import BaseModel, Field, validator
from sqlalchemy import Column, String, DateTime, JSON, Boolean, select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import declarative_base
from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
    retry_if_exception_type,
    before_sleep_log,
)

# Configure module-level logging
logger = logging.getLogger(__name__)

# ==============================================================================
# Configuration Constants
# ==============================================================================

GHL_API_V1_BASE = "https://api.gohighlevel.com/v1"
GHL_API_V2_BASE = "https://api.gohighlevel.com/v2"
DEFAULT_TIMEOUT = 30.0
MAX_RETRIES = 3
CIRCUIT_BREAKER_THRESHOLD = 5
CIRCUIT_BREAKER_TIMEOUT = 60

# ==============================================================================
# Custom Exceptions
# ==============================================================================

class GHLException(Exception):
    """Base exception for GHL CRM operations."""
    pass

class GHLAuthenticationError(GHLException):
    """Raised when API key is invalid or expired."""
    pass

class GHLRateLimitError(GHLException):
    """Raised when GHL API rate limit (1000/min) is exceeded."""
    pass

class GHLValidationError(GHLException):
    """Raised when request data fails GHL validation."""
    pass

class GHLNotFoundError(GHLException):
    """Raised when requested resource does not exist."""
    pass

class GHLCircuitBreakerOpen(GHLException):
    """Raised when circuit breaker is open due to repeated failures."""
    pass

# ==============================================================================
# Database Models (PostgreSQL)
# ==============================================================================

Base = declarative_base()

class GHLConfiguration(Base):
    """Stores GHL API configuration and mappings per location."""

    __tablename__ = "ghl_configurations"

    location_id = Column(String(255), primary_key=True, index=True)
    api_key = Column(String(512), nullable=False)  # Encrypt at application level
    api_version = Column(String(10), default="v2")  # v1 or v2
    base_url = Column(String(512), default=GHL_API_V2_BASE)
    is_active = Column(Boolean, default=True, nullable=False)

    # JSON configuration stores
    workflow_triggers = Column(JSON, default=dict)  # {event_type: workflow_id}
    tag_mappings = Column(JSON, default=dict)  # {outcome: [tag_ids]}
    custom_field_mappings = Column(JSON, default=dict)  # {internal_key: ghl_field_id}

    created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

class GHLContactMapping(Base):
    """Maps internal widget contacts to GHL contact IDs."""

    __tablename__ = "ghl_contact_mappings"

    id = Column(String(255), primary_key=True)  # Composite: {location_id}_{internal_id}
    internal_contact_id = Column(String(255), index=True, nullable=False)
    ghl_contact_id = Column(String(255), index=True, nullable=False)
    location_id = Column(String(255), index=True, nullable=False)

    # Sync metadata
    last_sync_at = Column(DateTime, nullable=True)
    sync_status = Column(String(50), default="active")  # active, error, archived
    error_count = Column(String(10), default="0")  # Stored as string for JSON compatibility

    # Cached data
    cached_tags = Column(JSON, default=list)
    custom_field_values = Column(JSON, default=dict)

    created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

# ==============================================================================
# Pydantic Schemas
# ==============================================================================

class CallOutcome(str, Enum):
    """Standardized call outcomes for tag mapping."""
    INTERESTED = "interested"
    NOT_INTERESTED = "not_interested"
    VOICEMAIL = "voicemail"
    NO_ANSWER = "no_answer"
    APPOINTMENT_SCHEDULED = "appointment_scheduled"
    CALLBACK_REQUESTED = "callback_requested"
    DISCONNECTED = "disconnected"

class ContactData(BaseModel):
    """Schema for contact creation/update data."""

    email: Optional[str] = None
    phone: str = Field(..., min_length=10)
    first_name: Optional[str] = None
    last_name: Optional[str] = None
    name: Optional[str] = None
    address: Optional[str] = None
    city: Optional[str] = None
    state: Optional[str] = None
    postal_code: Optional[str] = None
    timezone: Optional[str] = None
    source: Optional[str] = "voice_widget"

    # Custom fields as {internal_field_name: value}
    custom_fields: Dict[str, Any] = Field(default_factory=dict)

    @validator('phone')
    def validate_phone(cls, v: str) -> str:
        """Normalize phone number to E.164 format (basic)."""
        digits = ''.join(c for c in v if c.isdigit())
        if len(digits) < 10:
            raise ValueError("Phone number must have at least 10 digits")
        return digits

    @validator('name', always=True)
    def set_name(cls, v: Optional[str], values: Dict[str, Any]) -> str:
        """Auto-generate name if not provided."""
        if v:
            return v
        first = values.get('first_name', '')
        last = values.get('last_name', '')
        return f"{first} {last}".strip() or "Unknown"

class CallLogData(BaseModel):
    """Schema for logging calls as conversations/notes."""

    internal_contact_id: str
    call_duration: int = Field(..., ge=0, description="Duration in seconds")
    call_recording_url: Optional[str] = None
    call_direction: str = Field(..., regex="^(inbound|outbound)$")
    outcome: CallOutcome
    notes: Optional[str] = None
    transcript: Optional[str] = None
    agent_name: Optional[str] = None
    agent_email: Optional[str] = None
    timestamp: datetime = Field(default_factory=datetime.utcnow)
    disposition_code: Optional[str] = None

class WorkflowTriggerRequest(BaseModel):
    """Schema for triggering GHL workflows."""

    workflow_id: str
    contact_id: Optional[str] = None  # GHL contact ID (if known)
    internal_contact_id: Optional[str] = None  # Will lookup if ghl_contact_id not provided
    custom_data: Dict[str, Any] = Field(default_factory=dict)

# ==============================================================================
# HTTP Client with Resilience
# ==============================================================================

class GHLHTTPClient:
    """Async HTTP client for GHL API with retry logic and circuit breaker."""

    def __init__(self, api_key: str, base_url: str, api_version: str = "v2"):
        self.api_key = api_key
        self.base_url = base_url.rstrip('/')
        self.api_version = api_version
        self.headers = {
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "Version": "2021-07-28"
        }
        self._client: Optional[httpx.AsyncClient] = None
        self._failure_count = 0
        self._circuit_open = False
        self._last_failure_time: Optional[datetime] = None

    async def __aenter__(self) -> "


Google Calendar Integration

Response:
 ```python
# Sunaiva/talking-widget/backend/calendar_google.py

"""
Google Calendar Integration Module for Voice Appointment Booking.

This module provides a complete async-first integration with Google Calendar API
for appointment scheduling from voice calls. Features include OAuth2 authentication,
availability checking with Australian timezone support, double-booking prevention,
and automated calendar invite distribution.

Database: PostgreSQL (asyncpg)
Framework: FastAPI
"""

import asyncio
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any, AsyncGenerator, Tuple
from enum import Enum
from zoneinfo import ZoneInfo, available_timezones
import uuid
import json

from fastapi import FastAPI, Depends, HTTPException, status, Query, APIRouter
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, Field, EmailStr, validator, ConfigDict
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from google.auth.transport.requests import Request
from sqlalchemy.ext.asyncio import (
    AsyncSession,
    create_async_engine,
    async_sessionmaker,
    AsyncAttrs
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy import (
    String,
    DateTime,
    select,
    insert,
    update,
    func,
    UniqueConstraint,
    Index,
    ForeignKey,
    text
)
from sqlalchemy.dialects.postgresql import UUID, ExcludeConstraint
from cryptography.fernet import Fernet
from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
    retry_if_exception_type,
    before_sleep_log
)

# -----------------------------------------------------------------------------
# Configuration & Logging
# -----------------------------------------------------------------------------

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

class Settings(BaseModel):
    """Application configuration loaded from environment."""
    model_config = ConfigDict(env_file=".env", env_file_encoding="utf-8")

    GOOGLE_CLIENT_ID: str
    GOOGLE_CLIENT_SECRET: str
    GOOGLE_REDIRECT_URI: str = "http://localhost:8000/calendar/callback"
    DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost/calendar_db"
    ENCRYPTION_KEY: str  # Fernet key (32 bytes base64-encoded)
    DEFAULT_TIMEZONE: str = "Australia/Sydney"
    APPOINTMENT_SLOT_MINUTES: int = 30
    WORKING_HOURS_START: int = 9  # 9 AM
    WORKING_HOURS_END: int = 17   # 5 PM

settings = Settings()

# Initialize encryption
fernet = Fernet(settings.ENCRYPTION_KEY.encode())

# -----------------------------------------------------------------------------
# Database Models (SQLAlchemy 2.0 Async)
# -----------------------------------------------------------------------------

class Base(AsyncAttrs, DeclarativeBase):
    """Base class for async SQLAlchemy models."""
    pass

class CalendarConnection(Base):
    """
    Stores OAuth2 credentials for Google Calendar connections.

    Tokens are encrypted at rest using Fernet symmetric encryption.
    """
    __tablename__ = "calendar_connections"

    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        primary_key=True,
        default=uuid.uuid4
    )
    user_id: Mapped[str] = mapped_column(
        String(255),
        index=True,
        nullable=False,
        comment="Internal SaaS user identifier"
    )
    encrypted_refresh_token: Mapped[str] = mapped_column(
        String(500),
        nullable=False,
        comment="Encrypted OAuth2 refresh token"
    )
    access_token: Mapped[Optional[str]] = mapped_column(
        String(500),
        nullable=True,
        comment="Short-lived access token (plaintext, rotated frequently)"
    )
    token_expiry: Mapped[Optional[datetime]] = mapped_column(
        DateTime(timezone=True),
        nullable=True
    )
    calendar_id: Mapped[str] = mapped_column(
        String(255),
        default="primary",
        comment="Google Calendar ID (primary or specific calendar)"
    )
    timezone: Mapped[str] = mapped_column(
        String(50),
        default=settings.DEFAULT_TIMEZONE,
        comment="Default timezone for this calendar (IANA format)"
    )
    is_active: Mapped[bool] = mapped_column(default=True)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        server_default=func.now()
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        server_default=func.now(),
        onupdate=func.now()
    )

    # Relationships
    appointments: Mapped[List["Appointment"]] = relationship(
        "Appointment",
        back_populates="