"""Platform Lambda — contract ingestor.

Invoked via a Function URL (IAM auth) by consumer pipelines (one-way
communication, D-051). Accepts { consumerRepo, contractId, contract,
environment, action } and writes contracts to DynamoDB table acdl-contracts
(PK consumerRepo, SK contractId#submittedAt).

The report_error action (D-055) creates a GitHub issue on the platform repo
via the GitHub API, using a token from Secrets Manager. It is idempotent: if
an open issue with the same title exists, it comments rather than duplicating.

Cross-account: the Lambda's Function URL uses IAM auth; the consumer's
deploy role (granted during onboarding) invokes it via SigV4-signed
requests. The invoke policy is scoped via ABAC (consumer repo identity).
"""

import datetime
import json
import os
import urllib.error
import urllib.parse

import boto3

TABLE_NAME = os.environ.get("CONTRACTS_TABLE", "acdl-contracts")
CHANGE_REQUESTS_TABLE = os.environ.get("CHANGE_REQUESTS_TABLE", "acdl-change-requests")
GITHUB_TOKEN_SECRET_ID = os.environ.get("GITHUB_TOKEN_SECRET_ID", "acdl/github-token")
PLATFORM_REPO = os.environ.get("PLATFORM_REPO", "acdl/acdl")
# P1-9: Forge-agnostic API base URL. Defaults to GitHub; set GITHUB_API_BASE
# to a Gitea API root (e.g. https://git.cloudinit.dev/api/v1) for Gitea.
GITHUB_API_BASE = os.environ.get("GITHUB_API_BASE", "https://api.github.com")

_dynamodb = None
_secrets_client = None


def _get_dynamodb():
    global _dynamodb
    if _dynamodb is None:
        _dynamodb = boto3.resource("dynamodb")
    return _dynamodb


def _get_secrets_client():
    global _secrets_client
    if _secrets_client is None:
        _secrets_client = boto3.client("secretsmanager")
    return _secrets_client


def _iso8601_now():
    return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _forge_type():
    """P1-9: Detect whether the API base is GitHub or Gitea.

    Gitea API roots contain '/api/v1'; GitHub's is 'api.github.com'.
    """
    if "/api/v1" in GITHUB_API_BASE:
        return "gitea"
    return "github"


def _issues_search_url(owner, repo, encoded_query):
    """P1-9: Build the issue search URL based on forge type.

    GitHub uses /search/issues?q=...; Gitea uses /repos/{owner}/{repo}/issues?...
    with query params (no /search/issues endpoint).
    """
    if _forge_type() == "gitea":
        return (
            f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues"
            f"?state=open&type=issues&q={encoded_query}"
        )
    return (
        f"{GITHUB_API_BASE}/search/issues?q=repo:{owner}/{repo}"
        f"+is:issue+is:open+in:title+%22{encoded_query}%22"
    )


def _issues_create_url(owner, repo):
    """URL for creating an issue (same pattern for both GitHub + Gitea)."""
    return f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues"


def _issue_comments_url(owner, repo, issue_number):
    """URL for posting a comment on an issue (same for both forges)."""
    return f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues/{issue_number}/comments"


def _submit_contract(payload):
    consumer_repo = payload["consumerRepo"]
    contract_id = payload["contractId"]
    contract = payload["contract"]
    environment = payload["environment"]
    submitted_at = _iso8601_now()
    table = _get_dynamodb().Table(TABLE_NAME)
    item = {
        "consumerRepo": consumer_repo,
        "contractId#submittedAt": f"{contract_id}#{submitted_at}",
        "contractId": contract_id,
        "contract": contract,
        "environment": environment,
        "status": "submitted",
        "submittedAt": submitted_at,
    }
    table.put_item(TableName=TABLE_NAME, Item=item)
    return {
        "status": "ok",
        "contractId": contract_id,
        "action": "submit_contract",
        "submittedAt": submitted_at,
    }


def _report_error(payload):
    """Create a GitHub issue on the platform repo for a deploy failure (D-055).

    Uses the GitHub token from Secrets Manager. Idempotent: if an open
    issue with the same title exists, comments on it rather than duplicating.
    """
    import urllib.request

    required = ["consumerRepo", "contractId", "error"]
    for field in required:
        if field not in payload:
            raise ValueError(f"report_error requires '{field}'")

    consumer_repo = payload["consumerRepo"]
    contract_id = payload["contractId"]
    error = payload.get("error", "unknown error")
    run_url = payload.get("runUrl", "")
    stack_trace = payload.get("stackTrace", "")[:2000]  # truncate

    # Get the GitHub token from Secrets Manager
    secrets = _get_secrets_client()
    try:
        secret_response = secrets.get_secret_value(SecretId=GITHUB_TOKEN_SECRET_ID)
        github_token = secret_response["SecretString"]
    except Exception as e:
        raise RuntimeError(f"failed to read GitHub token from Secrets Manager: {e}")

    owner, repo = PLATFORM_REPO.split("/")
    title = f"[ACDL-ALERT] Deploy failure: {consumer_repo} / {contract_id}"

    # Check for an existing open issue with the same title (idempotency)
    # URL-encode the contract_id to prevent search-query injection (P1-1).
    encoded_contract_id = urllib.parse.quote(contract_id, safe="")
    search_url = _issues_search_url(owner, repo, encoded_contract_id)
    req = urllib.request.Request(search_url)
    req.add_header("Authorization", f"token {github_token}")
    req.add_header("Accept", "application/vnd.github+json")
    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            search_result = json.loads(resp.read())
        existing = search_result.get("items", [])
    except urllib.error.HTTPError as e:
        if e.code == 404:
            existing = []
        else:
            import sys
            print(f"WARNING: GitHub issue search failed (HTTP {e.code}): {e}", file=sys.stderr)
            existing = []
    except urllib.error.URLError as e:
        import sys
        print(f"WARNING: GitHub issue search network error: {e}", file=sys.stderr)
        existing = []

    body = f"""## Deploy Failure Report

| Field | Value |
|-------|-------|
| **Consumer repo** | `{consumer_repo}` |
| **Contract ID** | `{contract_id}` |
| **Run URL** | {run_url if run_url else "_(not provided)_"} |
| **Environment** | {payload.get('environment', 'unknown')} |

## Error

```
{error}
```

## Stack Trace

```
{stack_trace}
```

_This issue was auto-created by the ACDL platform Lambda (D-055). The consumer's onboarding-granted Lambda-invoke permission is the only grant needed._
"""

    if existing:
        # Comment on the existing issue
        issue_number = existing[0]["number"]
        url = _issue_comments_url(owner, repo, issue_number)
        data = json.dumps({"body": body}).encode()
        req = urllib.request.Request(url, data=data, method="POST")
        req.add_header("Authorization", f"token {github_token}")
        req.add_header("Accept", "application/vnd.github+json")
        urllib.request.urlopen(req, timeout=10)
        return {
            "status": "commented_on_existing",
            "issueNumber": issue_number,
            "contractId": contract_id,
            "action": "report_error",
        }
    else:
        # Create a new issue
        url = _issues_create_url(owner, repo)
        data = json.dumps({
            "title": title,
            "body": body,
            "labels": ["platform-alert", "auto-generated"],
        }).encode()
        req = urllib.request.Request(url, data=data, method="POST")
        req.add_header("Authorization", f"token {github_token}")
        req.add_header("Accept", "application/vnd.github+json")
        resp = urllib.request.urlopen(req, timeout=10)
        issue = json.loads(resp.read())
        return {
            "status": "issue_created",
            "issueNumber": issue["number"],
            "issueUrl": issue["html_url"],
            "contractId": contract_id,
            "action": "report_error",
        }


def _validate_caller_identity(event, payload):
    """Validate that the payload's consumerRepo matches the invoking principal (P1-2).

    The Lambda's Function URL uses IAM auth. The caller's identity is available
    in event["requestContext"]["identity"]. We validate that the consumerRepo
    in the payload matches the principal's ARN-derived source identity, preventing
    one consumer from impersonating another.

    If the identity is not available (e.g. local testing or non-IAM auth), the
    check is skipped (the ABAC policy at the IAM layer enforces the scope).

    v1.14 (REQ-144): also validates contractId format, environment enum, and
    error length. The ABAC reliance is documented here: the Function URL IAM
    identity does not expose principal tags in the event, so full enforcement
    of consumerRepo ownership is at the IAM layer (ABAC via
    aws:PrincipalTag/acdl:owner). This function validates format only, not
    ownership.
    """
    identity = event.get("requestContext", {}).get("identity", {})
    caller_arn = identity.get("userArn", "")
    if not caller_arn:
        pass  # no identity available — rely on IAM ABAC enforcement
    payload_repo = payload.get("consumerRepo", "")
    if payload_repo:
        # consumerRepo must be org/repo format, <=128 chars
        if "/" not in payload_repo or len(payload_repo) > 128:
            raise ValueError(f"invalid consumerRepo format: {payload_repo!r}")

    # v1.14 (REQ-144): contractId format validation
    contract_id = payload.get("contractId", "")
    if contract_id:
        import re
        if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', contract_id):
            raise ValueError(f"invalid contractId format: {contract_id!r} (alphanumeric, hyphen, underscore; max 64 chars)")

    # v1.14 (REQ-144): environment enum validation
    environment = payload.get("environment", "")
    if environment:
        valid_envs = {"dev", "qa", "prod", "dr"}
        if environment not in valid_envs:
            raise ValueError(f"invalid environment: {environment!r} (must be one of {valid_envs})")

    # v1.14 (REQ-144): error length cap (for report_error action)
    error_msg = payload.get("error", "")
    if error_msg and len(str(error_msg)) > 10000:
        payload["error"] = str(error_msg)[:10000]


def _validate_change_request(payload):
    """REQ-93: Validate a change request ID against the CMDB (DynamoDB).

    Queries the acdl-change-requests table for the given changeRequestId.
    Returns the CR details if status is 'approved' and the consumerRepo matches.
    Raises ValueError if the CR is not found, not approved, or the repo doesn't match.
    """
    required = ["changeRequestId", "consumerRepo"]
    for field in required:
        if field not in payload:
            raise ValueError(f"validate_change_request requires '{field}'")

    change_request_id = payload["changeRequestId"]
    consumer_repo = payload["consumerRepo"]

    table = _get_dynamodb().Table(CHANGE_REQUESTS_TABLE)
    response = table.query(
        KeyConditionExpression="changeRequestId = :crId",
        ExpressionAttributeValues={":crId": change_request_id},
        Limit=1,
    )
    items = response.get("Items", [])
    if not items:
        raise ValueError(f"change request '{change_request_id}' not found in CMDB")

    cr = items[0]
    if cr.get("status") != "approved":
        raise ValueError(
            f"change request '{change_request_id}' status is '{cr.get('status')}', expected 'approved'"
        )

    if cr.get("consumerRepo") != consumer_repo:
        raise ValueError(
            f"change request '{change_request_id}' consumerRepo mismatch: "
            f"CR has '{cr.get('consumerRepo')}', request has '{consumer_repo}'"
        )

    return {
        "status": "approved",
        "changeRequestId": change_request_id,
        "consumerRepo": consumer_repo,
        "contractId": cr.get("contractId", ""),
        "action": "validate_change_request",
    }


def lambda_handler(event, context):
    """AWS Lambda handler entry point.

    Accepts a Function-URL-style event whose ``body`` is a JSON string
    containing ``{ consumerRepo, contractId, contract, environment, action }``.
    """
    try:
        body = event.get("body", "{}")
        if isinstance(body, str):
            payload = json.loads(body)
        else:
            payload = body
        action = payload.get("action", "submit_contract")
        # Validate caller identity against the payload (P1-2).
        _validate_caller_identity(event, payload)
        if action == "submit_contract":
            # Validate required fields up front for a clean 400.
            for field in ("consumerRepo", "contractId", "contract", "environment"):
                if field not in payload:
                    return {
                        "statusCode": 400,
                        "body": json.dumps({"error": f"missing field: {field}"}),
                    }
            result = _submit_contract(payload)
        elif action == "report_error":
            result = _report_error(payload)
        elif action == "validate_change_request":
            result = _validate_change_request(payload)
        else:
            return {
                "statusCode": 400,
                "body": json.dumps({"error": f"unknown action: {action}"}),
            }
        return {"statusCode": 200, "body": json.dumps(result)}
    except ValueError as e:
        return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
    except Exception as e:  # pragma: no cover - defensive top-level guard
        return {"statusCode": 500, "body": json.dumps({"error": str(e)})}