import base64
import binascii
import io
import re
import uuid
from html import escape

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.http import JsonResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt

from plane.app.realtime.issue_events import publish_issue_event_on_commit
from plane.app.permissions import ROLE
from plane.app.serializers import ProjectSerializer
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized
from plane.authentication.nodedc_workspace_policy import get_nodedc_workspace_creation_policy
from plane.authentication.views.nodedc_workspace_adapter import parse_json_body
from plane.db.models import (
    Issue,
    IssueAssignee,
    IssueComment,
    FileAsset,
    IssueLabel,
    Label,
    Project,
    ProjectMember,
    State,
    User,
    Workspace,
    WorkspaceMember,
    DEFAULT_STATES,
)
from plane.settings.storage import S3Storage
from plane.utils.file_dedup import finalize_uploaded_file_asset
from plane.utils.upload_limits import get_project_storage_quota_state, get_workspace_file_size_limit


NODEDC_STRUCTURED_BLOCKS_KEY = "nodedc_structured_blocks"
AGENT_EMAIL_DOMAIN = "agents.nodedc.local"
AGENT_BOT_TYPE = "nodedc_codex_agent"
ALLOWED_PRIORITIES = ["none", "low", "medium", "high", "urgent"]
DEFAULT_AGENT_LABEL_COLOR = "#4E5355"
MAX_AGENT_AVATAR_URL_LENGTH = 2_000_000
MARKDOWN_HEADING_PATTERN = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*$")
HEX_COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}$")
DATA_IMAGE_PATTERN = re.compile(r"^data:image/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$")
PROJECT_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9_]{1,12}$")
BASE64_PATTERN = re.compile(r"^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$")
MAX_AGENT_ATTACHMENT_BYTES = 5 * 1024 * 1024
MAX_AGENT_COMMENTS = 1000


def unauthorized_response():
    return JsonResponse({"ok": False, "error": "internal_access_unauthorized"}, status=401)


def invalid_json_response():
    return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)


def validation_error(error, status=400):
    return JsonResponse({"ok": False, "error": error}, status=status)


def serialize_project(project):
    return {
        "id": str(project.id),
        "workspace_slug": project.workspace.slug,
        "name": project.name,
        "identifier": project.identifier,
    }


def serialize_state(state):
    return {
        "id": str(state.id),
        "name": state.name,
        "color": state.color,
        "group": state.group,
        "sequence": state.sequence,
        "default": state.default,
    }


def serialize_label(label):
    return {
        "id": str(label.id),
        "name": label.name,
        "color": label.color,
    }


def serialize_member(project_member):
    return {
        "id": str(project_member.member_id),
        "project_member_id": str(project_member.id),
        "display_name": project_member.member.display_name,
        "email": project_member.member.email,
        "role": project_member.role,
    }


def serialize_issue(issue):
    return {
        "id": str(issue.id),
        "project_id": str(issue.project_id),
        "workspace_slug": issue.workspace.slug,
        "title": issue.name,
        "description": issue.description_html,
        "priority": issue.priority,
        "state_id": str(issue.state_id) if issue.state_id else None,
        "sequence_id": issue.sequence_id,
        "detail_layout": issue.detail_layout or {},
        "label_ids": [
            str(label_id)
            for label_id in IssueLabel.objects.filter(
                issue=issue,
                deleted_at__isnull=True,
            ).values_list("label_id", flat=True)
        ],
        "assignee_ids": [
            str(assignee_id)
            for assignee_id in IssueAssignee.objects.filter(
                issue=issue,
                deleted_at__isnull=True,
            ).values_list("assignee_id", flat=True)
        ],
        "created_by": str(issue.created_by_id) if issue.created_by_id else None,
        "updated_by": str(issue.updated_by_id) if issue.updated_by_id else None,
        "created_at": issue.created_at.isoformat(),
        "updated_at": issue.updated_at.isoformat(),
    }


def serialize_comment(comment):
    return {
        "id": str(comment.id),
        "issue_id": str(comment.issue_id),
        "body": comment.comment_html,
        "actor_id": str(comment.actor_id) if comment.actor_id else None,
        "created_at": comment.created_at.isoformat(),
    }


def serialize_attachment(attachment):
    attributes = attachment.attributes or {}
    return {
        "id": str(attachment.id),
        "issue_id": str(attachment.issue_id),
        "file_name": attributes.get("name"),
        "mime_type": attributes.get("type"),
        "size": int(attachment.size or attributes.get("size") or 0),
        "sha256": attributes.get("sha256"),
        "asset_url": attachment.asset_url,
        "created_by": str(attachment.created_by_id) if attachment.created_by_id else None,
        "created_at": attachment.created_at.isoformat(),
    }


def serialize_issue_detail(issue):
    comments_queryset = IssueComment.objects.filter(
        issue=issue,
        deleted_at__isnull=True,
    ).order_by("created_at")
    comment_count = comments_queryset.count()
    comments = list(comments_queryset[:MAX_AGENT_COMMENTS])
    attachments = FileAsset.objects.filter(
        issue=issue,
        project=issue.project,
        workspace=issue.workspace,
        entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
        is_uploaded=True,
        is_deleted=False,
        deleted_at__isnull=True,
    ).order_by("created_at")
    return {
        **serialize_issue(issue),
        "comments": [serialize_comment(comment) for comment in comments],
        "comment_count": comment_count,
        "comments_truncated": comment_count > len(comments),
        "attachments": [serialize_attachment(attachment) for attachment in attachments],
    }


def resolve_project(project_id, workspace_slug=None):
    queryset = Project.objects.filter(
        id=project_id,
        deleted_at__isnull=True,
        archived_at__isnull=True,
    ).select_related("workspace")

    if workspace_slug:
        queryset = queryset.filter(workspace__slug=workspace_slug)

    return queryset.first()


def resolve_issue(project_id, issue_id, workspace_slug=None):
    project = resolve_project(project_id, workspace_slug)
    if project is None:
        return None, None

    issue = (
        Issue.issue_objects.filter(project=project, id=issue_id)
        .select_related("workspace", "project", "state")
        .first()
    )
    return project, issue


def get_agent_header(request, key):
    return request.headers.get(key, "").strip()


def get_agent_identity(request):
    agent_id = get_agent_header(request, "X-NODEDC-Agent-Id")
    owner_user_id = get_agent_header(request, "X-NODEDC-Agent-Owner-User-Id")
    token_id = get_agent_header(request, "X-NODEDC-Agent-Token-Id")

    if not agent_id or not owner_user_id or not token_id:
        return None

    return {
        "agent_id": agent_id,
        "owner_user_id": owner_user_id,
        "token_id": token_id,
    }


def get_agent_payload_metadata(payload):
    if not isinstance(payload, dict):
        return {}

    metadata = payload.get("_agent")
    return metadata if isinstance(metadata, dict) else {}


def normalize_agent_display_name(value, fallback):
    if isinstance(value, str):
        display_name = value.strip()
        if display_name:
            return display_name[:120]

    return fallback


def normalize_agent_avatar_url(value):
    if not isinstance(value, str):
        return ""

    avatar_url = value.strip()
    if not avatar_url or len(avatar_url) > MAX_AGENT_AVATAR_URL_LENGTH:
        return ""

    if avatar_url.startswith("data:image/"):
        return avatar_url if DATA_IMAGE_PATTERN.match(avatar_url) else ""

    if avatar_url.startswith("http://") or avatar_url.startswith("https://"):
        return avatar_url

    return ""


def get_agent_profile(request, payload=None):
    identity = get_agent_identity(request)
    if identity is None:
        return None

    short_id = identity["agent_id"].replace("-", "")[:12]
    fallback_display_name = f"Codex Agent {short_id}"
    metadata = get_agent_payload_metadata(payload)

    return {
        "display_name": normalize_agent_display_name(metadata.get("display_name"), fallback_display_name),
        "avatar_url": normalize_agent_avatar_url(metadata.get("avatar_url")),
        "short_id": short_id,
    }


def ensure_agent_actor(request, workspace, project=None, payload=None):
    identity = get_agent_identity(request)
    if identity is None:
        return None

    agent_id = identity["agent_id"]
    profile = get_agent_profile(request, payload)
    if profile is None:
        return None

    short_id = profile["short_id"]
    email = f"agent+{agent_id}@{AGENT_EMAIL_DOMAIN}"
    username = f"nodedc_agent_{short_id}"
    display_name = profile["display_name"]
    avatar_url = profile["avatar_url"]

    user, _ = User.objects.get_or_create(
        email=email,
        defaults={
            "username": username,
            "display_name": display_name,
            "first_name": display_name,
            "last_name": "",
            "avatar": avatar_url,
            "is_bot": True,
            "bot_type": AGENT_BOT_TYPE,
            "is_active": True,
        },
    )

    update_fields = []
    if not user.is_bot:
        user.is_bot = True
        update_fields.append("is_bot")
    if user.bot_type != AGENT_BOT_TYPE:
        user.bot_type = AGENT_BOT_TYPE
        update_fields.append("bot_type")
    if not user.is_active:
        user.is_active = True
        update_fields.append("is_active")
    if user.display_name != display_name:
        user.display_name = display_name
        update_fields.append("display_name")
    if user.first_name != display_name:
        user.first_name = display_name
        update_fields.append("first_name")
    if user.last_name:
        user.last_name = ""
        update_fields.append("last_name")
    if avatar_url and user.avatar != avatar_url:
        user.avatar = avatar_url
        update_fields.append("avatar")
    if avatar_url and user.avatar_asset_id:
        user.avatar_asset = None
        update_fields.append("avatar_asset")

    if update_fields:
        update_fields.append("updated_at")
        user.save(update_fields=update_fields)

    WorkspaceMember.objects.get_or_create(
        workspace=workspace,
        member=user,
        defaults={
            "role": 15,
            "company_role": "codex-agent",
            "is_active": True,
            "is_banned": False,
        },
    )

    if project is not None:
        ProjectMember.objects.get_or_create(
            workspace=workspace,
            project=project,
            member=user,
            defaults={
                "role": 15,
                "is_active": True,
            },
        )

    return user


def validate_internal_request(request):
    if not is_internal_logout_request_authorized(request):
        return unauthorized_response()

    if get_agent_identity(request) is None:
        return validation_error("missing_agent_headers", status=400)

    return None


def resolve_agent_owner(request):
    identity = get_agent_identity(request)
    if identity is None:
        return None

    try:
        return User.objects.filter(id=identity["owner_user_id"], is_active=True).first()
    except (TypeError, ValueError, ValidationError):
        return None


def validate_agent_workspace_entitlement(request, workspace_slug):
    if not workspace_slug:
        return validation_error("workspace_slug_required")

    owner = resolve_agent_owner(request)
    if owner is None:
        return validation_error("agent_owner_not_found", status=403)

    workspace_policy = get_nodedc_workspace_creation_policy(owner, workspace_slug=workspace_slug)
    service_modules = workspace_policy.get("service_modules") or {}
    if service_modules.get("codex_agents") is not True:
        return validation_error("codex_agents_not_entitled", status=403)

    return None


def get_agent_owner_workspace_membership(request, workspace):
    owner = resolve_agent_owner(request)
    if owner is None:
        return None, validation_error("agent_owner_not_found", status=403)

    workspace_member = WorkspaceMember.objects.filter(
        workspace=workspace,
        member=owner,
        is_active=True,
        is_banned=False,
        deleted_at__isnull=True,
    ).first()
    if workspace_member is None:
        return None, validation_error("workspace_access_denied", status=403)

    return workspace_member, None


def validate_agent_project_access(request, project):
    entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
    if entitlement_error is not None:
        return entitlement_error

    workspace_member, membership_error = get_agent_owner_workspace_membership(request, project.workspace)
    if membership_error is not None:
        return membership_error

    if workspace_member.role == ROLE.ADMIN.value:
        return None

    owner = resolve_agent_owner(request)
    if owner is None:
        return validation_error("agent_owner_not_found", status=403)

    if ProjectMember.objects.filter(
        project=project,
        member=owner,
        role__gte=ROLE.MEMBER.value,
        is_active=True,
        deleted_at__isnull=True,
    ).exists():
        return None

    return validation_error("project_access_denied", status=403)


def get_agent_accessible_projects(request, workspace_slug):
    if not workspace_slug:
        return [], validation_error("workspace_slug_required")

    entitlement_error = validate_agent_workspace_entitlement(request, workspace_slug)
    if entitlement_error is not None:
        return [], entitlement_error

    project_queryset = Project.objects.filter(
        workspace__slug=workspace_slug,
        deleted_at__isnull=True,
        archived_at__isnull=True,
    ).select_related("workspace")
    first_project = project_queryset.first()
    if first_project is None:
        return [], None

    workspace_member, membership_error = get_agent_owner_workspace_membership(request, first_project.workspace)
    if membership_error is not None:
        return [], membership_error

    if workspace_member.role == ROLE.ADMIN.value:
        return list(project_queryset), None

    owner = resolve_agent_owner(request)
    if owner is None:
        return [], validation_error("agent_owner_not_found", status=403)

    project_ids = ProjectMember.objects.filter(
        workspace=first_project.workspace,
        member=owner,
        role__gte=ROLE.MEMBER.value,
        is_active=True,
        deleted_at__isnull=True,
        project__deleted_at__isnull=True,
        project__archived_at__isnull=True,
    ).values_list("project_id", flat=True)
    return list(project_queryset.filter(id__in=project_ids)), None


def resolve_workspace(workspace_slug):
    if not isinstance(workspace_slug, str) or not workspace_slug.strip():
        return None
    return Workspace.objects.filter(slug=workspace_slug.strip(), deleted_at__isnull=True).first()


def generate_project_identifier(workspace, project_name):
    base = re.sub(r"[^A-Za-z0-9]", "", str(project_name or "").upper())[:12]
    if not base:
        base = f"PRJ{uuid.uuid4().hex[:8].upper()}"

    candidate = base
    suffix = 2
    while Project.objects.filter(
        workspace=workspace,
        identifier=candidate,
        deleted_at__isnull=True,
    ).exists():
        suffix_text = str(suffix)
        candidate = f"{base[: 12 - len(suffix_text)]}{suffix_text}"
        suffix += 1
    return candidate


def decode_agent_attachment(value):
    if not isinstance(value, str) or not value or not BASE64_PATTERN.fullmatch(value):
        return None
    try:
        content = base64.b64decode(value, validate=True)
    except (binascii.Error, ValueError):
        return None
    if not content or len(content) > MAX_AGENT_ATTACHMENT_BYTES:
        return None
    return content


def html_from_text(value):
    text = value.strip() if isinstance(value, str) else ""
    return f"<p>{escape(text)}</p>" if text else "<p></p>"


def normalize_structured_title(value, fallback):
    title = value.strip() if isinstance(value, str) else ""
    if not title:
        return fallback

    heading_match = MARKDOWN_HEADING_PATTERN.match(title)
    if heading_match:
        return heading_match.group(1).strip() or fallback

    return title


def split_leading_markdown_heading(value):
    if not isinstance(value, str):
        return None, ""

    lines = value.splitlines()
    leading_blank_count = 0
    for line in lines:
        if line.strip():
            break
        leading_blank_count += 1

    if leading_blank_count >= len(lines):
        return None, ""

    first_content_line = lines[leading_blank_count]
    heading_match = MARKDOWN_HEADING_PATTERN.match(first_content_line)
    if not heading_match:
        return None, value.strip()

    body_lines = lines[leading_blank_count + 1 :]
    while body_lines and not body_lines[0].strip():
        body_lines.pop(0)

    return heading_match.group(1).strip(), "\n".join(body_lines).strip()


def normalize_structured_block(block):
    if not isinstance(block, dict):
        return None

    block_type = block.get("type")
    if block_type == "text":
        extracted_title, body = split_leading_markdown_heading(block.get("body"))
        title = normalize_structured_title(block.get("title") or extracted_title, "Раздел")

        return {
            "id": str(block.get("id") or f"text-{title.lower().replace(' ', '-')}"),
            "type": "text",
            "title": title,
            "body": body,
        }

    if block_type == "checker":
        title = normalize_structured_title(block.get("title"), "Чеклист")
        raw_items = block.get("items")
        items = []
        if isinstance(raw_items, list):
            for item in raw_items:
                if not isinstance(item, dict):
                    continue
                text = item.get("text")
                if not isinstance(text, str) or not text.strip():
                    continue
                items.append(
                    {
                        "id": str(item.get("id") or f"item-{len(items) + 1}"),
                        "text": text.strip(),
                        "checked": item.get("checked") is True,
                    }
                )

        return {
            "id": str(block.get("id") or f"checker-{title.lower().replace(' ', '-')}"),
            "type": "checker",
            "title": title,
            "items": items,
        }

    return None


def normalize_structured_blocks(structured_blocks):
    normalized_blocks = []
    for block in structured_blocks:
        normalized_block = normalize_structured_block(block)
        if normalized_block is None:
            return None
        normalized_blocks.append(normalized_block)

    return normalized_blocks


def merge_structured_blocks(detail_layout, structured_blocks):
    if structured_blocks is None:
        return detail_layout or {}

    if not isinstance(structured_blocks, list):
        return None

    normalized_blocks = normalize_structured_blocks(structured_blocks)
    if normalized_blocks is None:
        return None

    updated_layout = dict(detail_layout or {})
    updated_layout[NODEDC_STRUCTURED_BLOCKS_KEY] = normalized_blocks
    return updated_layout


def update_issue_labels(issue, label_ids):
    labels = list(Label.objects.filter(id__in=label_ids, project=issue.project, deleted_at__isnull=True))
    if len(labels) != len(set(label_ids)):
        return False

    IssueLabel.objects.filter(issue=issue, deleted_at__isnull=True).delete()
    for label in labels:
        IssueLabel.objects.create(issue=issue, label=label, project=issue.project, workspace=issue.workspace)
    return True


def normalize_label_color(value):
    if isinstance(value, str) and HEX_COLOR_PATTERN.match(value.strip()):
        return value.strip()
    return DEFAULT_AGENT_LABEL_COLOR


def normalize_label_input(raw_label):
    if isinstance(raw_label, str):
        name = raw_label.strip()
        color = DEFAULT_AGENT_LABEL_COLOR
    elif isinstance(raw_label, dict):
        name = raw_label.get("name").strip() if isinstance(raw_label.get("name"), str) else ""
        color = normalize_label_color(raw_label.get("color"))
    else:
        return None

    if not name:
        return None

    return {
        "name": name[:255],
        "color": color,
    }


def ensure_project_labels(project, raw_labels, actor, agent_id):
    if not isinstance(raw_labels, list) or not raw_labels or len(raw_labels) > 50:
        return None

    ensured_labels = []
    for raw_label in raw_labels:
        label_input = normalize_label_input(raw_label)
        if label_input is None:
            return None

        label = Label.objects.filter(
            project=project,
            name__iexact=label_input["name"],
            deleted_at__isnull=True,
        ).first()

        if label is None:
            try:
                label = Label(
                    project=project,
                    workspace=project.workspace,
                    name=label_input["name"],
                    color=label_input["color"],
                    external_source=AGENT_BOT_TYPE,
                    external_id=agent_id,
                )
                label.save(created_by_id=actor.id)
            except IntegrityError:
                label = Label.objects.filter(
                    project=project,
                    name__iexact=label_input["name"],
                    deleted_at__isnull=True,
                ).first()
                if label is None:
                    return None

        ensured_labels.append(label)

    return ensured_labels


def update_issue_assignees(issue, member_ids):
    project_members = list(
        ProjectMember.objects.filter(
            project=issue.project,
            member_id__in=member_ids,
            is_active=True,
            deleted_at__isnull=True,
        ).select_related("member")
    )
    if len(project_members) != len(set(member_ids)):
        return False

    IssueAssignee.objects.filter(issue=issue, deleted_at__isnull=True).delete()
    for project_member in project_members:
        IssueAssignee.objects.create(
            issue=issue,
            assignee=project_member.member,
            project=issue.project,
            workspace=issue.workspace,
        )
    return True


@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentProjectResolveEndpoint(View):
    def post(self, request):
        error_response = validate_internal_request(request)
        if error_response is not None:
            return error_response

        payload = parse_json_body(request)
        if payload is None:
            return invalid_json_response()

        grants = payload.get("grants")
        if not isinstance(grants, list):
            return validation_error("grants_required")

        project_filters = []
        workspace_slugs = []
        for grant in grants:
            if not isinstance(grant, dict):
                continue
            workspace_slug = grant.get("workspace_slug")
            project_id = grant.get("project_id")
            if not isinstance(workspace_slug, str) or not workspace_slug:
                continue
            if isinstance(project_id, str) and project_id:
                project_filters.append((workspace_slug, project_id))
            else:
                workspace_slugs.append(workspace_slug)

        projects = []
        if workspace_slugs:
            for workspace_slug in workspace_slugs:
                accessible_projects, access_error = get_agent_accessible_projects(request, workspace_slug)
                if access_error is not None:
                    continue
                projects.extend(accessible_projects)
        for workspace_slug, project_id in project_filters:
            project = resolve_project(project_id, workspace_slug)
            if project is not None and validate_agent_project_access(request, project) is None:
                projects.append(project)

        unique_projects = {str(project.id): project for project in projects}
        return JsonResponse(
            {
                "ok": True,
                "projects": [serialize_project(project) for project in unique_projects.values()],
            }
        )


@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentProjectCreateEndpoint(View):
    def post(self, request):
        error_response = validate_internal_request(request)
        if error_response is not None:
            return error_response

        payload = parse_json_body(request)
        if payload is None:
            return invalid_json_response()

        workspace = resolve_workspace(payload.get("workspace_slug"))
        if workspace is None:
            return validation_error("workspace_not_found", status=404)

        entitlement_error = validate_agent_workspace_entitlement(request, workspace.slug)
        if entitlement_error is not None:
            return entitlement_error

        workspace_member, membership_error = get_agent_owner_workspace_membership(request, workspace)
        if membership_error is not None:
            return membership_error
        if workspace_member.role != ROLE.ADMIN.value:
            return validation_error("workspace_admin_required", status=403)

        name = payload.get("name")
        if not isinstance(name, str) or not name.strip():
            return validation_error("project_name_required")
        name = name.strip()

        identity = get_agent_identity(request)
        existing_project = Project.objects.filter(
            workspace=workspace,
            name=name,
            external_source=AGENT_BOT_TYPE,
            external_id=identity["agent_id"] if identity else "",
            deleted_at__isnull=True,
            archived_at__isnull=True,
        ).first()
        if existing_project is not None:
            return JsonResponse({"ok": True, "project": serialize_project(existing_project), "reused": True})

        identifier = payload.get("identifier")
        if identifier is not None:
            if not isinstance(identifier, str) or not PROJECT_IDENTIFIER_PATTERN.fullmatch(identifier.strip()):
                return validation_error("invalid_project_identifier")
            identifier = identifier.strip().upper()
        else:
            identifier = generate_project_identifier(workspace, name)

        actor = ensure_agent_actor(request, workspace, payload=payload)
        owner = resolve_agent_owner(request)
        if actor is None or owner is None:
            return validation_error("missing_agent_headers")

        serializer = ProjectSerializer(
            data={
                "name": name,
                "identifier": identifier,
                "description": str(payload.get("description") or "")[:20000],
            },
            context={"workspace_id": workspace.id},
        )
        if not serializer.is_valid():
            return JsonResponse(
                {"ok": False, "error": "invalid_project", "details": serializer.errors},
                status=400,
            )

        with transaction.atomic():
            project = serializer.save(
                created_by=actor,
                updated_by=actor,
                external_source=AGENT_BOT_TYPE,
                external_id=identity["agent_id"] if identity else "",
            )
            ProjectMember.objects.get_or_create(
                project=project,
                member=owner,
                defaults={"role": ROLE.ADMIN.value, "is_active": True},
            )
            ProjectMember.objects.get_or_create(
                project=project,
                member=actor,
                defaults={"role": ROLE.MEMBER.value, "is_active": True},
            )
            workspace_admin_ids = WorkspaceMember.objects.filter(
                workspace=workspace,
                role=ROLE.ADMIN.value,
                is_active=True,
                is_banned=False,
                deleted_at__isnull=True,
            ).values_list("member_id", flat=True)
            for admin_id in workspace_admin_ids:
                ProjectMember.objects.get_or_create(
                    project=project,
                    member_id=admin_id,
                    defaults={"role": ROLE.ADMIN.value, "is_active": True},
                )
            State.objects.bulk_create(
                [
                    State(
                        name=state["name"],
                        color=state["color"],
                        project=project,
                        sequence=state["sequence"],
                        workspace=workspace,
                        group=state["group"],
                        default=state.get("default", False),
                        created_by=actor,
                    )
                    for state in DEFAULT_STATES
                ]
            )

        return JsonResponse({"ok": True, "project": serialize_project(project), "reused": False}, status=201)


@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentProjectContextEndpoint(View):
    def get(self, request, project_id):
        error_response = validate_internal_request(request)
        if error_response is not None:
            return error_response

        project = resolve_project(project_id, request.GET.get("workspace_slug"))
        if project is None:
            return validation_error("project_not_found", status=404)

        project_access_error = validate_agent_project_access(request, project)
        if project_access_error is not None:
            return project_access_error

        states = State.objects.filter(project=project, deleted_at__isnull=True).order_by("sequence")
        labels = Label.objects.filter(project=project, deleted_at__isnull=True).order_by("sort_order")
        members = (
            ProjectMember.objects.filter(project=project, is_active=True, deleted_at__isnull=True, member__is_bot=False)
            .select_related("member")
            .order_by("member__display_name")
        )

        return JsonResponse(
            {
                "ok": True,
                "project": serialize_project(project),
                "states": [serialize_state(state) for state in states],
                "labels": [serialize_label(label) for label in labels],
                "members": [serialize_member(member) for member in members],
            }
        )


@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentProjectLabelsEnsureEndpoint(View):
    def post(self, request, project_id):
        error_response = validate_internal_request(request)
        if error_response is not None:
            return error_response

        payload = parse_json_body(request)
        if payload is None:
            return invalid_json_response()

        project = resolve_project(project_id, payload.get("workspace_slug"))
        if project is None:
            return validation_error("project_not_found", status=404)

        project_access_error = validate_agent_project_access(request, project)
        if project_access_error is not None:
            return project_access_error

        actor = ensure_agent_actor(request, project.workspace, project, payload)
        if actor is None:
            return validation_error("missing_agent_headers")

        identity = get_agent_identity(request)
        ensured_labels = ensure_project_labels(
            project,
            payload.get("labels"),
            actor,
            identity["agent_id"] if identity else "",
        )
        if ensured_labels is None:
            return validation_error("invalid_labels")

        return JsonResponse({"ok": True, "labels": [serialize_label(label) for label in ensured_labels]}, status=201)


@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueListEndpoint(View):
    def get(self, request):
        error_response = validate_internal_request(request)
        if error_response is not None:
            return error_response

        project_id = request.GET.get("project_id")
        if not project_id:
            return validation_error("project_id_required")

        project = resolve_project(project_id, request.GET.get("workspace_slug"))
        if project is None:
            return validation_error("project_not_found", status=404)

        project_access_error = validate_agent_project_access(request, project)
        if project_access_error is not None:
            return project_access_error

        queryset = (
            Issue.issue_objects.filter(project=project)
            .select_related("workspace", "project", "state")
            .order_by("-updated_at")
        )
        query = request.GET.get("query")
        if query:
            queryset = queryset.filter(name__icontains=query)

        return JsonResponse({"ok": True, "issues": [serialize_issue(issue) for issue in queryset[:100]]})

    def post(self, request):
        error_response = validate_internal_request(request)
        if error_response is not None:
            return error_response

        payload = parse_json_body(request)
        if payload is None:
            return invalid_json_response()

        project = resolve_project(payload.get("project_id"), payload.get("workspace_slug"))
        if project is None:
            return validation_error("project_not_found", status=404)

        project_access_error = validate_agent_project_access(request, project)
        if project_access_error is not None:
            return project_access_error

        title = payload.get("title")
        if not isinstance(title, str) or not title.strip():
            return validation_error("title_required")

        priority = payload.get("priority") or "none"
        if priority not in ALLOWED_PRIORITIES:
            return validation_error("invalid_priority")

        detail_layout = merge_structured_blocks({}, payload.get("structured_blocks"))
        if detail_layout is None:
            return validation_error("invalid_structured_blocks")

        with transaction.atomic():
            actor = ensure_agent_actor(request, project.workspace, project, payload)
            if actor is None:
                return validation_error("missing_agent_headers")

            issue = Issue(
                project=project,
                workspace=project.workspace,
                name=title.strip(),
                description_html=html_from_text(payload.get("description")),
                priority=priority,
                detail_layout=detail_layout,
                external_source=AGENT_BOT_TYPE,
                external_id=get_agent_header(request, "X-NODEDC-Agent-Id"),
            )
            issue.save(created_by_id=actor.id)

        publish_issue_event_on_commit("issue.created", issue, actor_id=actor.id, changed_fields=payload.keys())
        return JsonResponse({"ok": True, "issue": serialize_issue(issue)}, status=201)


@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueUpdateEndpoint(View):
    def get(self, request, issue_id):
        error_response = validate_internal_request(request)
        if error_response is not None:
            return error_response

        project, issue = resolve_issue(
            request.GET.get("project_id"),
            issue_id,
            request.GET.get("workspace_slug"),
        )
        if project is None:
            return validation_error("project_not_found", status=404)
        if issue is None:
            return validation_error("issue_not_found", status=404)

        project_access_error = validate_agent_project_access(request, project)
        if project_access_error is not None:
            return project_access_error

        return JsonResponse({"ok": True, "issue": serialize_issue_detail(issue)})

    def patch(self, request, issue_id):
        error_response = validate_internal_request(request)
        if error_response is not None:
            return error_response

        payload = parse_json_body(request)
        if payload is None:
            return invalid_json_response()

        project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
        if project is None:
            return validation_error("project_not_found", status=404)
        if issue is None:
            return validation_error("issue_not_found", status=404)

        project_access_error = validate_agent_project_access(request, project)
        if project_access_error is not None:
            return project_access_error

        detail_layout = (
            merge_structured_blocks(issue.detail_layout, payload.get("structured_blocks"))
            if "structured_blocks" in payload
            else issue.detail_layout
        )
        if detail_layout is None:
            return validation_error("invalid_structured_blocks")

        update_fields = []
        if isinstance(payload.get("title"), str) and payload["title"].strip():
            issue.name = payload["title"].strip()
            update_fields.append("name")
        if "description" in payload:
            issue.description_html = html_from_text(payload.get("description"))
            update_fields.append("description_html")
        if payload.get("priority") in ALLOWED_PRIORITIES:
            issue.priority = payload["priority"]
            update_fields.append("priority")
        elif "priority" in payload:
            return validation_error("invalid_priority")
        if "structured_blocks" in payload:
            issue.detail_layout = detail_layout
            update_fields.append("detail_layout")

        if update_fields:
            actor = ensure_agent_actor(request, project.workspace, project, payload)
            if actor is None:
                return validation_error("missing_agent_headers")
            issue.updated_by = actor
            update_fields.extend(["updated_by", "updated_at"])
            issue.save(update_fields=update_fields, disable_auto_set_user=True)
            publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=update_fields)

        return JsonResponse({"ok": True, "issue": serialize_issue(issue)})


@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueMoveEndpoint(View):
    def post(self, request, issue_id):
        error_response = validate_internal_request(request)
        if error_response is not None:
            return error_response

        payload = parse_json_body(request)
        if payload is None:
            return invalid_json_response()

        project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
        if project is None:
            return validation_error("project_not_found", status=404)
        if issue is None:
            return validation_error("issue_not_found", status=404)

        project_access_error = validate_agent_project_access(request, project)
        if project_access_error is not None:
            return project_access_error

        state = State.objects.filter(project=project, id=payload.get("state_id"), deleted_at__isnull=True).first()
        if state is None:
            return validation_error("state_not_found", status=404)

        actor = ensure_agent_actor(request, project.workspace, project, payload)
        if actor is None:
            return validation_error("missing_agent_headers")

        issue.state = state
        issue.updated_by = actor
        if state.group == "completed":
            issue.completed_at = timezone.now()
        else:
            issue.completed_at = None
        issue.save(update_fields=["state", "updated_by", "completed_at", "updated_at"], disable_auto_set_user=True)
        publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=["state"])
        return JsonResponse({"ok": True, "issue": serialize_issue(issue)})


@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueCommentEndpoint(View):
    def post(self, request, issue_id):
        error_response = validate_internal_request(request)
        if error_response is not None:
            return error_response

        payload = parse_json_body(request)
        if payload is None:
            return invalid_json_response()

        project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
        if project is None:
            return validation_error("project_not_found", status=404)
        if issue is None:
            return validation_error("issue_not_found", status=404)

        project_access_error = validate_agent_project_access(request, project)
        if project_access_error is not None:
            return project_access_error

        body = payload.get("body")
        if not isinstance(body, str) or not body.strip():
            return validation_error("body_required")

        actor = ensure_agent_actor(request, project.workspace, project, payload)
        if actor is None:
            return validation_error("missing_agent_headers")

        comment = IssueComment(
            project=project,
            workspace=project.workspace,
            issue=issue,
            actor=actor,
            comment_html=html_from_text(body),
            comment_json={},
        )
        comment.save(created_by_id=actor.id)
        return JsonResponse({"ok": True, "comment": serialize_comment(comment)}, status=201)


@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueAttachmentEndpoint(View):
    def post(self, request, issue_id):
        error_response = validate_internal_request(request)
        if error_response is not None:
            return error_response

        payload = parse_json_body(request)
        if payload is None:
            return invalid_json_response()

        project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
        if project is None:
            return validation_error("project_not_found", status=404)
        if issue is None:
            return validation_error("issue_not_found", status=404)

        project_access_error = validate_agent_project_access(request, project)
        if project_access_error is not None:
            return project_access_error

        file_name = payload.get("file_name")
        if (
            not isinstance(file_name, str)
            or not file_name.strip()
            or len(file_name.strip()) > 255
            or "/" in file_name
            or "\\" in file_name
            or "\x00" in file_name
        ):
            return validation_error("invalid_file_name")
        file_name = file_name.strip()

        mime_type = payload.get("mime_type")
        if not isinstance(mime_type, str) or mime_type not in settings.ATTACHMENT_MIME_TYPES:
            return validation_error("invalid_attachment_mime_type")

        content = decode_agent_attachment(payload.get("content_base64"))
        if content is None:
            return validation_error("invalid_attachment_content")

        workspace_limit = get_workspace_file_size_limit(project.workspace)
        if workspace_limit is not None and len(content) > workspace_limit:
            return validation_error("attachment_size_limit_exceeded")

        quota_state = get_project_storage_quota_state(project, requested_size=len(content))
        if not quota_state["allowed"]:
            return JsonResponse(
                {
                    "ok": False,
                    "error": "project_storage_quota_exceeded",
                    "project_id": str(project.id),
                    "quota": quota_state["quota"],
                    "used": quota_state["used"],
                    "remaining": quota_state["remaining"],
                },
                status=400,
            )

        actor = ensure_agent_actor(request, project.workspace, project, payload)
        identity = get_agent_identity(request)
        if actor is None or identity is None:
            return validation_error("missing_agent_headers")

        object_key = f"{project.workspace_id}/{uuid.uuid4().hex}-{file_name}"
        storage = S3Storage(is_server=True)
        if not storage.upload_file(io.BytesIO(content), object_key, content_type=mime_type):
            return validation_error("attachment_storage_unavailable", status=502)

        asset = None
        try:
            asset = FileAsset.objects.create(
                attributes={
                    "name": file_name,
                    "type": mime_type,
                    "size": len(content),
                },
                asset=object_key,
                size=len(content),
                workspace=project.workspace,
                project=project,
                issue=issue,
                created_by=actor,
                entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
                is_uploaded=False,
                external_source=AGENT_BOT_TYPE,
                external_id=identity["agent_id"],
            )
            finalized = finalize_uploaded_file_asset(asset)
            return JsonResponse(
                {
                    "ok": True,
                    "attachment": serialize_attachment(finalized.asset),
                    "deduplicated": finalized.deduplicated,
                },
                status=201,
            )
        except Exception:
            if asset is not None:
                FileAsset.all_objects.filter(pk=asset.pk).delete()
            storage.delete_files(object_names=[object_key])
            return validation_error("attachment_finalize_failed", status=502)


@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueLabelsEndpoint(View):
    def put(self, request, issue_id):
        error_response = validate_internal_request(request)
        if error_response is not None:
            return error_response

        payload = parse_json_body(request)
        if payload is None:
            return invalid_json_response()

        project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
        if project is None:
            return validation_error("project_not_found", status=404)
        if issue is None:
            return validation_error("issue_not_found", status=404)

        project_access_error = validate_agent_project_access(request, project)
        if project_access_error is not None:
            return project_access_error

        label_ids = payload.get("label_ids")
        if not isinstance(label_ids, list):
            return validation_error("label_ids_required")

        if not update_issue_labels(issue, label_ids):
            return validation_error("label_not_found", status=404)

        actor = ensure_agent_actor(request, project.workspace, project, payload)
        if actor is not None:
            issue.updated_by = actor
            issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True)
            publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=["labels"])

        return JsonResponse({"ok": True, "issue": serialize_issue(issue)})


@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueAssigneesEndpoint(View):
    def put(self, request, issue_id):
        error_response = validate_internal_request(request)
        if error_response is not None:
            return error_response

        payload = parse_json_body(request)
        if payload is None:
            return invalid_json_response()

        project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
        if project is None:
            return validation_error("project_not_found", status=404)
        if issue is None:
            return validation_error("issue_not_found", status=404)

        project_access_error = validate_agent_project_access(request, project)
        if project_access_error is not None:
            return project_access_error

        member_ids = payload.get("member_ids")
        if not isinstance(member_ids, list):
            return validation_error("member_ids_required")

        if not update_issue_assignees(issue, member_ids):
            return validation_error("member_not_found", status=404)

        actor = ensure_agent_actor(request, project.workspace, project, payload)
        if actor is not None:
            issue.updated_by = actor
            issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True)
            publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=["assignees"])

        return JsonResponse({"ok": True, "issue": serialize_issue(issue)})
