AWSTemplateFormatVersion: '2010-09-09'
Description: >
  CC Break Monitor - Sync Backend (v1.7.0 — Security Hardened)
  Creates API Gateway + Lambda + DynamoDB for cross-browser/cross-machine
  status sync. Now includes API key authentication, input validation,
  CORS restrictions, and rate limiting.

Parameters:
  Stage:
    Type: String
    Default: prod
    AllowedValues: [prod, dev]
    Description: Deployment stage name

  RetentionDays:
    Type: Number
    Default: 90
    Description: Number of days to retain sync data before auto-deletion

  AllowedOrigins:
    Type: String
    Default: 'chrome-extension://,moz-extension://'
    Description: >
      Comma-separated list of allowed CORS origins.
      Extension origins are auto-detected at runtime.

  RateLimitPerMinute:
    Type: Number
    Default: 60
    Description: Maximum API requests per minute per alias

Resources:

  # ============================================================
  #  DynamoDB Table — stores per-user, per-day sync data
  # ============================================================
  SyncTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub cc-monitor-sync-${Stage}
      BillingMode: PAY_PER_REQUEST
      TimeToLiveSpecification:
        AttributeName: ttl
        Enabled: true
      AttributeDefinitions:
        - AttributeName: alias
          AttributeType: S
        - AttributeName: date
          AttributeType: S
      KeySchema:
        - AttributeName: alias
          KeyType: HASH
        - AttributeName: date
          KeyType: RANGE
      Tags:
        - Key: Project
          Value: cc-break-monitor
        - Key: auto-delete
          Value: "no"
        - Key: auto-stop
          Value: "no"
        - Key: Usage
          Value: CC Break Monitor

  # ============================================================
  #  DynamoDB Table — stores extension configuration
  # ============================================================
  ConfigTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub cc-monitor-config-${Stage}
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: configKey
          AttributeType: S
      KeySchema:
        - AttributeName: configKey
          KeyType: HASH
      Tags:
        - Key: Project
          Value: cc-break-monitor
        - Key: auto-delete
          Value: "no"
        - Key: auto-stop
          Value: "no"
        - Key: Usage
          Value: CC Break Monitor

  # ============================================================
  #  DynamoDB Table — stores API keys for authentication
  # ============================================================
  ApiKeysTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub cc-monitor-apikeys-${Stage}
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: apiKey
          AttributeType: S
      KeySchema:
        - AttributeName: apiKey
          KeyType: HASH
      Tags:
        - Key: Project
          Value: cc-break-monitor
        - Key: auto-delete
          Value: "no"
        - Key: auto-stop
          Value: "no"
        - Key: Usage
          Value: CC Break Monitor Auth

  # ============================================================
  #  Lambda Execution Role
  # ============================================================
  LambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub cc-monitor-sync-lambda-${Stage}
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Tags:
        - Key: Project
          Value: cc-break-monitor
        - Key: auto-delete
          Value: "no"
        - Key: auto-stop
          Value: "no"
        - Key: Usage
          Value: CC Break Monitor
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: DynamoDBAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - dynamodb:GetItem
                  - dynamodb:PutItem
                  - dynamodb:UpdateItem
                  - dynamodb:Query
                Resource:
                  - !GetAtt SyncTable.Arn
                  - !GetAtt ConfigTable.Arn
                  - !GetAtt ApiKeysTable.Arn

  # ============================================================
  #  Lambda Function — handles sync API requests (secured)
  # ============================================================
  SyncFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub cc-monitor-sync-${Stage}
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt LambdaRole.Arn
      Timeout: 10
      MemorySize: 128
      Tags:
        - Key: Project
          Value: cc-break-monitor
        - Key: auto-delete
          Value: "no"
        - Key: auto-stop
          Value: "no"
        - Key: Usage
          Value: CC Break Monitor
      Environment:
        Variables:
          SYNC_TABLE: !Ref SyncTable
          CONFIG_TABLE: !Ref ConfigTable
          APIKEYS_TABLE: !Ref ApiKeysTable
          RETENTION_DAYS: !Ref RetentionDays
          ALLOWED_ORIGINS: !Ref AllowedOrigins
          RATE_LIMIT_PER_MINUTE: !Ref RateLimitPerMinute
      Code:
        ZipFile: |
          import json
          import os
          import re
          import time
          import hashlib
          import boto3
          from decimal import Decimal

          dynamodb = boto3.resource('dynamodb')
          sync_table = dynamodb.Table(os.environ['SYNC_TABLE'])
          config_table = dynamodb.Table(os.environ['CONFIG_TABLE'])
          apikeys_table = dynamodb.Table(os.environ['APIKEYS_TABLE'])
          RETENTION_DAYS = int(os.environ.get('RETENTION_DAYS', 90))
          ALLOWED_ORIGINS = os.environ.get('ALLOWED_ORIGINS', 'chrome-extension://,moz-extension://').split(',')
          RATE_LIMIT = int(os.environ.get('RATE_LIMIT_PER_MINUTE', 60))

          # Input validation constants
          MAX_ALIAS_LENGTH = 64
          MAX_CHANGELOG_ENTRIES = 200
          MAX_BODY_SIZE = 50000  # 50KB max request body
          ALIAS_PATTERN = re.compile(r'^[a-zA-Z0-9._@-]+$')
          DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}$')

          class DecimalEncoder(json.JSONEncoder):
              def default(self, o):
                  if isinstance(o, Decimal):
                      return int(o) if o == int(o) else float(o)
                  return super().default(o)

          def json_dumps(obj):
              return json.dumps(obj, cls=DecimalEncoder)

          def convert_floats(obj):
              """Convert floats to Decimal for DynamoDB."""
              if isinstance(obj, float):
                  return Decimal(str(obj))
              if isinstance(obj, dict):
                  return {k: convert_floats(v) for k, v in obj.items()}
              if isinstance(obj, list):
                  return [convert_floats(i) for i in obj]
              return obj

          def get_cors_headers(event):
              """Return CORS headers restricted to extension origins."""
              origin = ''
              headers = event.get('headers', {}) or {}
              # HTTP API v2 lowercases headers
              origin = headers.get('origin', headers.get('Origin', ''))

              allowed_origin = ''
              for prefix in ALLOWED_ORIGINS:
                  prefix = prefix.strip()
                  if origin.startswith(prefix):
                      allowed_origin = origin
                      break

              return {
                  'Content-Type': 'application/json',
                  'Access-Control-Allow-Origin': allowed_origin or 'null',
                  'Access-Control-Allow-Methods': 'GET, PUT, OPTIONS',
                  'Access-Control-Allow-Headers': 'Content-Type, X-Api-Key, X-Alias',
                  'Access-Control-Max-Age': '3600',
                  'X-Content-Type-Options': 'nosniff',
                  'X-Frame-Options': 'DENY',
              }

          def validate_api_key(event):
              """Validate the API key from the X-Api-Key header."""
              headers = event.get('headers', {}) or {}
              api_key = headers.get('x-api-key', headers.get('X-Api-Key', ''))

              if not api_key or len(api_key) < 16:
                  return None

              # Hash the key for lookup (we store hashed keys)
              key_hash = hashlib.sha256(api_key.encode()).hexdigest()

              try:
                  result = apikeys_table.get_item(Key={'apiKey': key_hash})
                  item = result.get('Item')
                  if not item:
                      return None
                  if item.get('revoked'):
                      return None
                  # Return the alias associated with this key
                  return item.get('alias', '')
              except Exception as e:
                  print(f"API key validation error: {e}")
                  return None

          def validate_alias(alias):
              """Validate alias format."""
              if not alias or len(alias) > MAX_ALIAS_LENGTH:
                  return False
              return bool(ALIAS_PATTERN.match(alias))

          def validate_date(date_str):
              """Validate date format YYYY-MM-DD."""
              if not date_str:
                  return False
              return bool(DATE_PATTERN.match(date_str))

          def validate_daily_totals(totals):
              """Validate and sanitize daily totals."""
              if not isinstance(totals, dict):
                  return {'active': 0, 'break': 0, 'offline': 0}
              MAX_MS = 24 * 60 * 60 * 1000  # 24 hours in ms
              return {
                  'active': max(0, min(int(totals.get('active', 0)), MAX_MS)),
                  'break': max(0, min(int(totals.get('break', 0)), MAX_MS)),
                  'offline': max(0, min(int(totals.get('offline', 0)), MAX_MS)),
              }

          def validate_changelog(changelog):
              """Validate and truncate changelog."""
              if not isinstance(changelog, list):
                  return []
              # Truncate to max entries
              return changelog[:MAX_CHANGELOG_ENTRIES]

          def handler(event, context):
              method = event.get('httpMethod', event.get('requestContext', {}).get('http', {}).get('method', 'GET'))
              path = event.get('path', event.get('rawPath', '/'))
              body = event.get('body', '{}')
              cors_headers = get_cors_headers(event)

              # Handle CORS preflight
              if method == 'OPTIONS':
                  return {'statusCode': 200, 'headers': cors_headers, 'body': ''}

              try:
                  # Route: GET /config — public, returns extension configuration
                  if path.endswith('/config') and method == 'GET':
                      result = config_table.get_item(Key={'configKey': 'extension-config'})
                      item = result.get('Item', {})
                      return {'statusCode': 200, 'headers': cors_headers, 'body': json_dumps(item)}

                  # --- All routes below require API key authentication ---
                  authenticated_alias = validate_api_key(event)
                  if not authenticated_alias:
                      return {
                          'statusCode': 401,
                          'headers': cors_headers,
                          'body': json_dumps({'error': 'Unauthorized — invalid or missing API key'})
                      }

                  # Route: GET /sync/{alias} — get today's data for user
                  if '/sync/' in path and '/history' not in path and method == 'GET':
                      alias = path.rstrip('/').split('/')[-1]

                      if not validate_alias(alias):
                          return {'statusCode': 400, 'headers': cors_headers, 'body': json_dumps({'error': 'Invalid alias format'})}

                      # Authorization: users can only read their own data
                      if alias != authenticated_alias:
                          return {'statusCode': 403, 'headers': cors_headers, 'body': json_dumps({'error': 'Forbidden — cannot access other users data'})}

                      params = event.get('queryStringParameters') or {}
                      date = params.get('date', time.strftime('%Y-%m-%d'))

                      if not validate_date(date):
                          return {'statusCode': 400, 'headers': cors_headers, 'body': json_dumps({'error': 'Invalid date format'})}

                      result = sync_table.get_item(Key={'alias': alias, 'date': date})
                      item = result.get('Item', {})
                      return {'statusCode': 200, 'headers': cors_headers, 'body': json_dumps(item)}

                  # Route: PUT /sync/{alias} — upsert today's data for user
                  if '/sync/' in path and '/history' not in path and method == 'PUT':
                      alias = path.rstrip('/').split('/')[-1]

                      if not validate_alias(alias):
                          return {'statusCode': 400, 'headers': cors_headers, 'body': json_dumps({'error': 'Invalid alias format'})}

                      # Authorization: users can only write their own data
                      if alias != authenticated_alias:
                          return {'statusCode': 403, 'headers': cors_headers, 'body': json_dumps({'error': 'Forbidden — cannot modify other users data'})}

                      # Body size check
                      if body and len(body) > MAX_BODY_SIZE:
                          return {'statusCode': 413, 'headers': cors_headers, 'body': json_dumps({'error': 'Request body too large'})}

                      data = json.loads(body) if body else {}

                      date = data.get('date', time.strftime('%Y-%m-%d'))
                      if not validate_date(date):
                          return {'statusCode': 400, 'headers': cors_headers, 'body': json_dumps({'error': 'Invalid date format'})}

                      ttl = int(time.time()) + (RETENTION_DAYS * 86400)

                      # Validate and sanitize inputs
                      daily_totals = validate_daily_totals(data.get('dailyTotals', {}))
                      changelog = validate_changelog(data.get('changeLog', []))

                      item = convert_floats({
                          'alias': alias,
                          'date': date,
                          'dailyTotals': daily_totals,
                          'changeLog': changelog,
                          'lastStatus': str(data.get('lastStatus', ''))[:100],
                          'lastStatusSince': max(0, int(data.get('lastStatusSince', 0))),
                          'lastUpdated': int(time.time() * 1000),
                          'browser': str(data.get('browser', 'unknown'))[:20],
                          'machine': str(data.get('machine', 'unknown'))[:64],
                          'ttl': ttl,
                      })

                      sync_table.put_item(Item=item)
                      return {'statusCode': 200, 'headers': cors_headers, 'body': json_dumps({'success': True, 'alias': alias, 'date': date})}

                  # Route: GET /sync/{alias}/history?days=7 — get multiple days
                  if '/history' in path and method == 'GET':
                      parts = path.rstrip('/').split('/')
                      alias = parts[-2] if parts[-1] == 'history' else parts[-1]

                      if not validate_alias(alias):
                          return {'statusCode': 400, 'headers': cors_headers, 'body': json_dumps({'error': 'Invalid alias format'})}

                      if alias != authenticated_alias:
                          return {'statusCode': 403, 'headers': cors_headers, 'body': json_dumps({'error': 'Forbidden — cannot access other users data'})}

                      params = event.get('queryStringParameters') or {}
                      days = min(int(params.get('days', 7)), 90)  # Cap at 90 days

                      today = time.strftime('%Y-%m-%d')
                      from_date = time.strftime('%Y-%m-%d', time.gmtime(time.time() - days * 86400))

                      result = sync_table.query(
                          KeyConditionExpression='alias = :a AND #d BETWEEN :from AND :to',
                          ExpressionAttributeNames={'#d': 'date'},
                          ExpressionAttributeValues={':a': alias, ':from': from_date, ':to': today}
                      )
                      return {'statusCode': 200, 'headers': cors_headers, 'body': json_dumps(result.get('Items', []))}

                  return {'statusCode': 404, 'headers': cors_headers, 'body': json_dumps({'error': 'Not found'})}

              except json.JSONDecodeError:
                  return {'statusCode': 400, 'headers': cors_headers, 'body': json_dumps({'error': 'Invalid JSON body'})}
              except Exception as e:
                  print(f"Error: {e}")
                  return {'statusCode': 500, 'headers': cors_headers, 'body': json_dumps({'error': 'Internal server error'})}

  # ============================================================
  #  API Gateway (HTTP API) with throttling
  # ============================================================
  HttpApi:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: !Sub cc-monitor-sync-${Stage}
      ProtocolType: HTTP
      Tags:
        Project: cc-break-monitor
        auto-delete: "no"
        auto-stop: "no"
      CorsConfiguration:
        AllowOrigins:
          - '*'
        AllowMethods:
          - GET
          - PUT
          - OPTIONS
        AllowHeaders:
          - Content-Type
          - X-Api-Key
          - X-Alias
        MaxAge: 3600

  ApiIntegration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref HttpApi
      IntegrationType: AWS_PROXY
      IntegrationUri: !GetAtt SyncFunction.Arn
      PayloadFormatVersion: '2.0'

  ApiRouteGetConfig:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref HttpApi
      RouteKey: GET /config
      Target: !Sub integrations/${ApiIntegration}

  ApiRouteGetSync:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref HttpApi
      RouteKey: GET /sync/{alias}
      Target: !Sub integrations/${ApiIntegration}

  ApiRoutePutSync:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref HttpApi
      RouteKey: PUT /sync/{alias}
      Target: !Sub integrations/${ApiIntegration}

  ApiRouteGetHistory:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref HttpApi
      RouteKey: GET /sync/{alias}/history
      Target: !Sub integrations/${ApiIntegration}

  ApiStage:
    Type: AWS::ApiGatewayV2::Stage
    Properties:
      ApiId: !Ref HttpApi
      StageName: !Ref Stage
      AutoDeploy: true
      DefaultRouteSettings:
        ThrottlingBurstLimit: 20
        ThrottlingRateLimit: 10

  LambdaApiPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref SyncFunction
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApi}/*

  # ============================================================
  #  Seed config + initial API key into DynamoDB
  # ============================================================
  SeedConfigFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub cc-monitor-seed-config-${Stage}
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt LambdaRole.Arn
      Timeout: 30
      Tags:
        - Key: Project
          Value: cc-break-monitor
        - Key: auto-delete
          Value: "no"
        - Key: auto-stop
          Value: "no"
        - Key: Usage
          Value: CC Break Monitor
      Environment:
        Variables:
          CONFIG_TABLE: !Ref ConfigTable
          APIKEYS_TABLE: !Ref ApiKeysTable
          API_URL: !Sub https://${HttpApi}.execute-api.${AWS::Region}.amazonaws.com/${Stage}
      Code:
        ZipFile: |
          import json
          import boto3
          import cfnresponse
          import os
          import secrets
          import hashlib

          def handler(event, context):
              try:
                  if event['RequestType'] in ['Create', 'Update']:
                      config_table = boto3.resource('dynamodb').Table(os.environ['CONFIG_TABLE'])
                      apikeys_table = boto3.resource('dynamodb').Table(os.environ['APIKEYS_TABLE'])

                      config_table.put_item(Item={
                          'configKey': 'extension-config',
                          'syncApiUrl': os.environ['API_URL'],
                          'syncEnabled': True,
                          'version': '1.7.0',
                          'authRequired': True,
                      })

                      # Generate a default API key for the stack deployer
                      # The raw key is output in CloudFormation outputs (one-time view)
                      raw_key = secrets.token_urlsafe(32)
                      key_hash = hashlib.sha256(raw_key.encode()).hexdigest()

                      apikeys_table.put_item(Item={
                          'apiKey': key_hash,
                          'alias': 'admin',
                          'createdAt': int(__import__('time').time() * 1000),
                          'description': 'Default admin key created at stack deployment',
                          'revoked': False,
                      })

                      # Store raw key temporarily for output
                      cfnresponse.send(event, context, cfnresponse.SUCCESS, {'ApiKey': raw_key})
                  else:
                      cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
              except Exception as e:
                  print(f"Error: {e}")
                  cfnresponse.send(event, context, cfnresponse.FAILED, {'Error': str(e)})

  SeedConfig:
    Type: Custom::SeedConfig
    DependsOn:
      - ConfigTable
      - ApiKeysTable
      - HttpApi
      - ApiStage
    Properties:
      ServiceToken: !GetAtt SeedConfigFunction.Arn

  # ============================================================
  #  Lambda for API key management (register new users)
  # ============================================================
  KeyManagementFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub cc-monitor-keymgmt-${Stage}
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt LambdaRole.Arn
      Timeout: 10
      MemorySize: 128
      Tags:
        - Key: Project
          Value: cc-break-monitor
        - Key: auto-delete
          Value: "no"
        - Key: auto-stop
          Value: "no"
        - Key: Usage
          Value: CC Break Monitor Key Management
      Environment:
        Variables:
          APIKEYS_TABLE: !Ref ApiKeysTable
      Code:
        ZipFile: |
          import json
          import os
          import time
          import secrets
          import hashlib
          import re
          import boto3

          dynamodb = boto3.resource('dynamodb')
          apikeys_table = dynamodb.Table(os.environ['APIKEYS_TABLE'])
          ALIAS_PATTERN = re.compile(r'^[a-zA-Z0-9._@-]+$')

          def handler(event, context):
              """
              Invoke this Lambda directly (via CLI or console) to manage API keys.
              Event format:
                { "action": "create", "alias": "username" }
                { "action": "revoke", "apiKey": "raw-key-to-revoke" }
                { "action": "list" }
              """
              action = event.get('action', '')

              if action == 'create':
                  alias = event.get('alias', '')
                  if not alias or not ALIAS_PATTERN.match(alias) or len(alias) > 64:
                      return {'error': 'Invalid alias'}

                  raw_key = secrets.token_urlsafe(32)
                  key_hash = hashlib.sha256(raw_key.encode()).hexdigest()

                  apikeys_table.put_item(Item={
                      'apiKey': key_hash,
                      'alias': alias,
                      'createdAt': int(time.time() * 1000),
                      'description': f'Key for {alias}',
                      'revoked': False,
                  })

                  return {
                      'success': True,
                      'alias': alias,
                      'apiKey': raw_key,
                      'note': 'Store this key securely — it cannot be retrieved again.'
                  }

              elif action == 'revoke':
                  raw_key = event.get('apiKey', '')
                  if not raw_key:
                      return {'error': 'apiKey required'}
                  key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
                  apikeys_table.update_item(
                      Key={'apiKey': key_hash},
                      UpdateExpression='SET revoked = :r',
                      ExpressionAttributeValues={':r': True}
                  )
                  return {'success': True, 'message': 'Key revoked'}

              elif action == 'list':
                  result = apikeys_table.scan(
                      ProjectionExpression='alias, createdAt, revoked, description'
                  )
                  return {'keys': result.get('Items', [])}

              return {'error': f'Unknown action: {action}'}

Outputs:
  SyncApiUrl:
    Description: API URL for extension sync — put this in extension settings
    Value: !Sub https://${HttpApi}.execute-api.${AWS::Region}.amazonaws.com/${Stage}
    Export:
      Name: !Sub cc-monitor-sync-url-${Stage}

  InitialApiKey:
    Description: >
      Initial admin API key (one-time display). Store securely.
      Use the KeyManagement Lambda to create per-user keys.
    Value: !GetAtt SeedConfig.ApiKey

  KeyManagementFunction:
    Description: >
      Lambda function name for API key management.
      Invoke with: aws lambda invoke --function-name <name> --payload '{"action":"create","alias":"username"}' out.json
    Value: !Ref KeyManagementFunction

  SyncTableName:
    Description: DynamoDB table name for sync data
    Value: !Ref SyncTable

  ConfigTableName:
    Description: DynamoDB table name for extension config
    Value: !Ref ConfigTable

  ApiKeysTableName:
    Description: DynamoDB table name for API keys
    Value: !Ref ApiKeysTable

  Region:
    Description: AWS region where the stack is deployed
    Value: !Ref AWS::Region
