import csv
import io
import json
import os
import zipfile
from datetime import datetime

from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Prefetch, Q
from django.http import HttpResponse, JsonResponse
from django.utils.decorators import method_decorator
from django.views.generic import TemplateView

from attachments.models import Attachment
from tcms.bugs.models import Bug
from tcms.management.models import Product
from tcms.testcases.models import TestCase
from tcms.testplans.models import TestPlan
from tcms.testruns.models import TestExecution, TestRun

from tcms_artifact_browser.utils import (
    FILE_CATEGORIES,
    build_zip_path,
    format_file_size,
    gather_bug_attachments,
    gather_run_attachments,
    get_file_category,
    get_file_extension,
    get_file_icon_class,
    sanitize_path,
)


# ==========================================
# Report Helpers
# ==========================================

REPORT_HEADERS = [
    "ID", "Filename", "Type", "Size", "Source", "Source ID",
    "Test Run", "Test Case", "Uploader", "Date",
]


def _build_report_metadata(request, report_type):
    """Build metadata dict for report headers."""
    return {
        "author": request.user.username,
        "generated": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "report_type": report_type,
    }


def _write_csv_metadata(writer, metadata):
    """Write metadata rows to a CSV writer."""
    writer.writerow(["Report", metadata["report_type"]])
    writer.writerow(["Author", metadata["author"]])
    writer.writerow(["Generated", metadata["generated"]])
    writer.writerow([])


def _write_excel_metadata(ws, metadata, start_row=1):
    """Write metadata rows to an Excel worksheet. Returns next available row."""
    from openpyxl.styles import Font

    bold_font = Font(bold=True)
    rows = [
        ("Report", metadata["report_type"]),
        ("Author", metadata["author"]),
        ("Generated", metadata["generated"]),
    ]
    for i, (label, value) in enumerate(rows):
        row = start_row + i
        cell_label = ws.cell(row=row, column=1, value=label)
        cell_label.font = bold_font
        ws.cell(row=row, column=2, value=value)

    return start_row + len(rows) + 1  # blank row after metadata


def _get_excel_styles():
    """Return consistent Excel table styles."""
    from openpyxl.styles import Alignment, Border, Font, PatternFill, Side

    return {
        "header_font": Font(bold=True, color="FFFFFF", size=10),
        "header_fill": PatternFill(
            start_color="0088CE", end_color="0088CE", fill_type="solid"
        ),
        "header_alignment": Alignment(
            horizontal="center", vertical="center", wrap_text=True
        ),
        "thin_border": Border(
            left=Side(style="thin", color="CCCCCC"),
            right=Side(style="thin", color="CCCCCC"),
            top=Side(style="thin", color="CCCCCC"),
            bottom=Side(style="thin", color="CCCCCC"),
        ),
        "header_border": Border(
            left=Side(style="thin", color="006699"),
            right=Side(style="thin", color="006699"),
            top=Side(style="thin", color="006699"),
            bottom=Side(style="medium", color="006699"),
        ),
        "data_alignment": Alignment(vertical="top", wrap_text=True),
        "stripe_fill": PatternFill(
            start_color="F2F7FB", end_color="F2F7FB", fill_type="solid"
        ),
    }


def _write_excel_table(ws, headers, rows, start_row=1):
    """Write a styled table to an Excel worksheet. Returns next available row."""
    styles = _get_excel_styles()

    for col_idx, header in enumerate(headers, 1):
        cell = ws.cell(row=start_row, column=col_idx, value=header)
        cell.font = styles["header_font"]
        cell.fill = styles["header_fill"]
        cell.alignment = styles["header_alignment"]
        cell.border = styles["header_border"]
    ws.row_dimensions[start_row].height = 28

    row_idx = start_row + 1
    for row in rows:
        for col_idx, value in enumerate(row, 1):
            cell = ws.cell(row=row_idx, column=col_idx, value=value)
            cell.border = styles["thin_border"]
            cell.alignment = styles["data_alignment"]
            if (row_idx - start_row) % 2 == 0:
                cell.fill = styles["stripe_fill"]
        row_idx += 1

    for col in ws.columns:
        max_length = 0
        col_letter = None
        for cell in col:
            if col_letter is None:
                col_letter = cell.column_letter
            if cell.value:
                max_length = max(max_length, len(str(cell.value)))
        if col_letter:
            ws.column_dimensions[col_letter].width = min(max(max_length + 3, 10), 50)

    ws.freeze_panes = "A{}".format(start_row + 1)
    return row_idx


def _write_docx_metadata(doc, metadata):
    """Add metadata paragraph to a Word document."""
    from docx.shared import Pt

    p = doc.add_paragraph()
    items = [
        ("Report: ", metadata["report_type"]),
        ("Author: ", metadata["author"]),
        ("Generated: ", metadata["generated"]),
    ]
    for i, (label, value) in enumerate(items):
        if i > 0:
            run = p.add_run("\n")
            run.font.size = Pt(8)
        run_label = p.add_run(label)
        run_label.bold = True
        run_label.font.size = Pt(8)
        run_value = p.add_run(value)
        run_value.font.size = Pt(8)


def _write_docx_table(doc, heading, headers, rows):
    """Write a styled table to a Word document."""
    from docx.enum.table import WD_TABLE_ALIGNMENT
    from docx.oxml.ns import qn
    from docx.shared import Pt, RGBColor

    if heading:
        doc.add_heading(heading, level=2)

    table = doc.add_table(rows=1, cols=len(headers))
    table.style = "Table Grid"
    table.alignment = WD_TABLE_ALIGNMENT.CENTER

    header_row = table.rows[0]
    for idx, header in enumerate(headers):
        cell = header_row.cells[idx]
        cell.text = ""
        p = cell.paragraphs[0]
        run = p.add_run(header)
        run.bold = True
        run.font.size = Pt(9)
        run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
        shading = cell._element.get_or_add_tcPr()
        shd = shading.makeelement(qn("w:shd"), {
            qn("w:val"): "clear",
            qn("w:color"): "auto",
            qn("w:fill"): "0088CE",
        })
        shading.append(shd)

    for row_num, row in enumerate(rows):
        row_cells = table.add_row().cells
        for idx, value in enumerate(row):
            cell = row_cells[idx]
            cell.text = ""
            p = cell.paragraphs[0]
            run = p.add_run(str(value))
            run.font.size = Pt(8)
            if row_num % 2 == 1:
                shading = cell._element.get_or_add_tcPr()
                shd = shading.makeelement(qn("w:shd"), {
                    qn("w:val"): "clear",
                    qn("w:color"): "auto",
                    qn("w:fill"): "F2F7FB",
                })
                shading.append(shd)

    doc.add_paragraph()


def _get_pdf_table_style():
    """Return consistent PDF table style."""
    from reportlab.lib import colors
    from reportlab.platypus import TableStyle

    return TableStyle([
        ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#0088ce")),
        ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
        ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
        ("FONTSIZE", (0, 0), (-1, 0), 8),
        ("FONTSIZE", (0, 1), (-1, -1), 7),
        ("ALIGN", (0, 0), (-1, 0), "CENTER"),
        ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
        ("BOX", (0, 0), (-1, 0), 1, colors.HexColor("#006699")),
        ("LINEBELOW", (0, 0), (-1, 0), 1.5, colors.HexColor("#006699")),
        ("ROWBACKGROUNDS", (0, 1), (-1, -1),
         [colors.white, colors.HexColor("#f2f7fb")]),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("TOPPADDING", (0, 0), (-1, -1), 4),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
        ("LEFTPADDING", (0, 0), (-1, -1), 6),
        ("RIGHTPADDING", (0, 0), (-1, -1), 6),
    ])


def _build_pdf_metadata_elements(metadata, styles):
    """Return a list of PDF elements for metadata."""
    from reportlab.platypus import Paragraph, Spacer

    meta_style = styles["Normal"]
    lines = [
        "<b>Report:</b> {}".format(metadata["report_type"]),
        "<b>Author:</b> {}".format(metadata["author"]),
        "<b>Generated:</b> {}".format(metadata["generated"]),
    ]
    elements = []
    for line in lines:
        elements.append(Paragraph(line, meta_style))
    elements.append(Spacer(1, 12))
    return elements


# ==========================================
# Report Data Helpers
# ==========================================


def _get_all_attachment_rows(request):
    """
    Build report data rows from all attachments, optionally filtered by product.
    Returns (rows, queryset) where each row matches REPORT_HEADERS.
    """
    product_id = request.GET.get("product")
    run_id = request.GET.get("run_id")

    run_ct = ContentType.objects.get_for_model(TestRun)
    exec_ct = ContentType.objects.get_for_model(TestExecution)
    case_ct = ContentType.objects.get_for_model(TestCase)
    bug_ct = ContentType.objects.get_for_model(Bug)

    attachments = Attachment.objects.filter(
        content_type__in=[run_ct, exec_ct, case_ct, bug_ct]
    ).select_related("creator", "content_type").order_by(
        "content_type", "object_id", "attachment_file"
    )

    if run_id:
        # Get specific run's attachments
        try:
            run = TestRun.objects.get(pk=run_id)
        except TestRun.DoesNotExist:
            return []

        exec_ids = list(
            TestExecution.objects.filter(run=run)
            .values_list("pk", flat=True)
        )
        case_ids = list(
            TestExecution.objects.filter(run=run)
            .values_list("case_id", flat=True)
            .distinct()
        )
        bug_ids = list(
            Bug.executions.through.objects.filter(
                testexecution_id__in=exec_ids
            ).values_list("bug_id", flat=True).distinct()
        ) if exec_ids else []

        q = Q(content_type=run_ct, object_id=str(run.pk))
        if exec_ids:
            q |= Q(content_type=exec_ct, object_id__in=[str(i) for i in exec_ids])
        if case_ids:
            q |= Q(content_type=case_ct, object_id__in=[str(i) for i in case_ids])
        if bug_ids:
            q |= Q(content_type=bug_ct, object_id__in=[str(i) for i in bug_ids])

        attachments = attachments.filter(q)
    elif product_id:
        run_ids = list(
            TestRun.objects.filter(plan__product_id=product_id)
            .values_list("pk", flat=True)
        )
        exec_ids = list(
            TestExecution.objects.filter(run_id__in=run_ids)
            .values_list("pk", flat=True)
        )
        case_ids = list(
            TestExecution.objects.filter(run_id__in=run_ids)
            .values_list("case_id", flat=True)
            .distinct()
        )
        bug_ids = list(
            Bug.executions.through.objects.filter(
                testexecution_id__in=exec_ids
            ).values_list("bug_id", flat=True).distinct()
        ) if exec_ids else []

        q = Q(content_type=run_ct, object_id__in=[str(i) for i in run_ids])
        if exec_ids:
            q |= Q(content_type=exec_ct, object_id__in=[str(i) for i in exec_ids])
        if case_ids:
            q |= Q(content_type=case_ct, object_id__in=[str(i) for i in case_ids])
        if bug_ids:
            q |= Q(content_type=bug_ct, object_id__in=[str(i) for i in bug_ids])

        attachments = attachments.filter(q)

    rows = []
    for att in attachments:
        filename = att.filename
        try:
            size = att.attachment_file.size
        except (FileNotFoundError, OSError):
            size = 0

        # Determine source context
        source = att.content_type.model
        source_id = att.object_id
        run_info = ""
        case_info = ""

        if att.content_type == run_ct:
            try:
                r = TestRun.objects.get(pk=att.object_id)
                run_info = "TR-{}: {}".format(r.pk, r.summary)
            except TestRun.DoesNotExist:
                run_info = "TR-{}".format(att.object_id)
        elif att.content_type == exec_ct:
            try:
                e = TestExecution.objects.select_related(
                    "run", "case"
                ).get(pk=att.object_id)
                run_info = "TR-{}: {}".format(e.run.pk, e.run.summary)
                case_info = "TC-{}: {}".format(
                    e.case.pk, e.case.summary
                ) if e.case else ""
            except TestExecution.DoesNotExist:
                pass
        elif att.content_type == case_ct:
            try:
                c = TestCase.objects.get(pk=att.object_id)
                case_info = "TC-{}: {}".format(c.pk, c.summary)
                # Trace back to a test run through executions
                exec_link = (
                    TestExecution.objects.filter(case=c)
                    .select_related("run")
                    .first()
                )
                if exec_link:
                    run_info = "TR-{}: {}".format(
                        exec_link.run.pk, exec_link.run.summary
                    )
            except TestCase.DoesNotExist:
                pass
        elif att.content_type == bug_ct:
            try:
                bug = Bug.objects.get(pk=att.object_id)
                case_info = "BUG-{}: {}".format(bug.pk, bug.summary)
                # Trace bug → execution → run
                exec_link = bug.executions.select_related("run").first()
                if exec_link:
                    run_info = "TR-{}: {}".format(
                        exec_link.run.pk, exec_link.run.summary
                    )
            except Bug.DoesNotExist:
                pass

        rows.append([
            att.pk,
            filename,
            get_file_extension(filename),
            format_file_size(size),
            source,
            source_id,
            run_info,
            case_info,
            att.creator.username if att.creator else "",
            att.created.strftime("%Y-%m-%d %H:%M") if att.created else "",
        ])

    # Sort by Test Run, Test Case, Filename for consistent report ordering
    rows.sort(key=lambda r: (r[6], r[7], r[1]))

    return rows


# ==========================================
# Main Browser View
# ==========================================


@method_decorator(login_required, name="dispatch")
class ArtifactBrowserView(TemplateView):
    """
    Artifact Browser with tree navigation.
    Left panel: Product > TestPlan > TestRun tree
    Right panel: Attachment list for selected test run
    """

    template_name = "tcms_artifact_browser/browser.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        runs_qs = TestRun.objects.select_related("build", "manager").order_by("summary")
        plans_qs = TestPlan.objects.prefetch_related(
            Prefetch("run", queryset=runs_qs)
        ).order_by("name")

        products = Product.objects.prefetch_related(
            Prefetch("plan", queryset=plans_qs)
        ).order_by("name")

        # Collect all run IDs to compute attachment existence
        all_runs = []
        for product in products:
            for plan in product.plan.all():
                for run in plan.run.all():
                    all_runs.append(run)

        all_run_ids = [r.pk for r in all_runs]

        # Compute which runs have attachments (4 bulk queries)
        runs_with_attachments = self._get_runs_with_attachments(all_run_ids)

        tree_data = []
        for product in products:
            plans = product.plan.all()
            plan_list = []
            product_run_count = 0
            for plan in plans:
                runs = plan.run.all()
                run_list = [
                    {
                        "id": run.pk,
                        "summary": run.summary,
                        "has_attachments": run.pk in runs_with_attachments,
                    }
                    for run in runs
                ]
                product_run_count += len(run_list)
                plan_list.append({
                    "id": plan.pk,
                    "name": plan.name,
                    "count": len(run_list),
                    "runs": run_list,
                })

            tree_data.append({
                "id": product.pk,
                "name": product.name,
                "count": product_run_count,
                "plans": plan_list,
            })

        context["tree_data"] = tree_data

        # Bug tree: bugs grouped by product, only those with attachments
        bug_ct = ContentType.objects.get_for_model(Bug)
        bug_ids_with_att = set(
            Attachment.objects.filter(
                content_type=bug_ct,
            ).values_list("object_id", flat=True)
        )

        bug_tree_data = []
        if bug_ids_with_att:
            bugs_with_att = (
                Bug.objects.filter(pk__in=[int(b) for b in bug_ids_with_att])
                .select_related("product")
                .order_by("product__name", "summary")
            )

            product_bugs = {}
            for bug in bugs_with_att:
                prod = bug.product
                if prod.pk not in product_bugs:
                    product_bugs[prod.pk] = {
                        "id": prod.pk,
                        "name": prod.name,
                        "bugs": [],
                    }
                product_bugs[prod.pk]["bugs"].append({
                    "id": bug.pk,
                    "summary": bug.summary,
                    "status": "open" if bug.status else "closed",
                })

            bug_tree_data = sorted(
                product_bugs.values(), key=lambda p: p["name"]
            )

        context["bug_tree_data"] = bug_tree_data

        # Merged product list for the filter dropdown (runs + bugs)
        all_products = {p["id"]: p["name"] for p in tree_data}
        for bp in bug_tree_data:
            if bp["id"] not in all_products:
                all_products[bp["id"]] = bp["name"]
        context["filter_products"] = sorted(
            [{"id": pid, "name": pname} for pid, pname in all_products.items()],
            key=lambda p: p["name"],
        )

        return context

    @staticmethod
    def _get_runs_with_attachments(run_ids):
        """Return set of run IDs that have at least one attachment."""
        if not run_ids:
            return set()

        run_ct = ContentType.objects.get_for_model(TestRun)
        exec_ct = ContentType.objects.get_for_model(TestExecution)
        case_ct = ContentType.objects.get_for_model(TestCase)
        bug_ct = ContentType.objects.get_for_model(Bug)

        result = set()

        # 1. Runs with direct attachments
        direct = set(
            Attachment.objects.filter(
                content_type=run_ct,
                object_id__in=[str(r) for r in run_ids],
            ).values_list("object_id", flat=True)
        )
        result.update(int(rid) for rid in direct)

        # 2. Build run→exec and run→case maps
        exec_qs = (
            TestExecution.objects.filter(run_id__in=run_ids)
            .values_list("run_id", "pk", "case_id")
        )
        run_exec_map = {}
        run_case_map = {}
        all_exec_ids = set()
        all_case_ids = set()
        for run_id, exec_id, case_id in exec_qs:
            run_exec_map.setdefault(run_id, set()).add(exec_id)
            all_exec_ids.add(exec_id)
            if case_id:
                run_case_map.setdefault(run_id, set()).add(case_id)
                all_case_ids.add(case_id)

        # 3. Exec IDs with attachments
        execs_with_att = set()
        if all_exec_ids:
            execs_with_att = set(
                Attachment.objects.filter(
                    content_type=exec_ct,
                    object_id__in=[str(e) for e in all_exec_ids],
                ).values_list("object_id", flat=True)
            )

        for run_id, exec_ids in run_exec_map.items():
            if run_id in result:
                continue
            for eid in exec_ids:
                if str(eid) in execs_with_att:
                    result.add(run_id)
                    break

        # 4. Case IDs with attachments
        cases_with_att = set()
        if all_case_ids:
            cases_with_att = set(
                Attachment.objects.filter(
                    content_type=case_ct,
                    object_id__in=[str(c) for c in all_case_ids],
                ).values_list("object_id", flat=True)
            )

        for run_id, case_ids in run_case_map.items():
            if run_id in result:
                continue
            for cid in case_ids:
                if str(cid) in cases_with_att:
                    result.add(run_id)
                    break

        # 5. Bugs linked via executions with attachments
        if all_exec_ids:
            bug_links = (
                Bug.executions.through.objects.filter(
                    testexecution_id__in=all_exec_ids
                ).values_list("testexecution_id", "bug_id")
            )
            exec_bug_map = {}
            all_bug_ids = set()
            for exec_id, bug_id in bug_links:
                exec_bug_map.setdefault(exec_id, set()).add(bug_id)
                all_bug_ids.add(bug_id)

            if all_bug_ids:
                bugs_with_att = set(
                    Attachment.objects.filter(
                        content_type=bug_ct,
                        object_id__in=[str(b) for b in all_bug_ids],
                    ).values_list("object_id", flat=True)
                )

                # Map exec→run for reverse lookup
                exec_run_map = {}
                for rid, eids in run_exec_map.items():
                    for eid in eids:
                        exec_run_map[eid] = rid

                for exec_id, bug_ids_set in exec_bug_map.items():
                    rid = exec_run_map.get(exec_id)
                    if rid and rid not in result:
                        for bid in bug_ids_set:
                            if str(bid) in bugs_with_att:
                                result.add(rid)
                                break

        return result


# ==========================================
# API Endpoints
# ==========================================


@login_required
def api_run_attachments(request, run_id):
    """Return all attachments for a test run, grouped by source."""
    data = gather_run_attachments(run_id)
    if data is None:
        return JsonResponse({"error": "Test run not found"}, status=404)

    # Apply optional filters
    file_type = request.GET.get("file_type")
    search = request.GET.get("search", "").strip().lower()
    uploader = request.GET.get("uploader", "").strip().lower()
    date_from = request.GET.get("date_from")
    date_to = request.GET.get("date_to")

    if file_type or search or uploader or date_from or date_to:
        for group in data["groups"]:
            filtered = group["attachments"]
            if file_type:
                filtered = [
                    a for a in filtered if a["file_category"] == file_type
                ]
            if search:
                filtered = [
                    a for a in filtered if search in a["filename"].lower()
                ]
            if uploader:
                filtered = [
                    a for a in filtered if uploader in a["creator"].lower()
                ]
            if date_from:
                filtered = [
                    a for a in filtered if a["created"] >= date_from
                ]
            if date_to:
                filtered = [
                    a for a in filtered if a["created"] <= date_to
                ]
            group["attachments"] = filtered

        # Remove empty groups and recalculate totals
        data["groups"] = [g for g in data["groups"] if g["attachments"]]
        data["total_attachments"] = sum(
            len(g["attachments"]) for g in data["groups"]
        )
        total_size = sum(
            a["size"] for g in data["groups"] for a in g["attachments"]
        )
        data["total_size"] = total_size
        data["total_size_display"] = format_file_size(total_size)

    return JsonResponse(data)


@login_required
def api_bug_attachments(request, bug_id):
    """Return all attachments for a bug."""
    data = gather_bug_attachments(bug_id)
    if data is None:
        return JsonResponse({"error": "Bug not found"}, status=404)

    # Apply optional filters (same as api_run_attachments)
    file_type = request.GET.get("file_type")
    search = request.GET.get("search", "").strip().lower()
    uploader = request.GET.get("uploader", "").strip().lower()
    date_from = request.GET.get("date_from")
    date_to = request.GET.get("date_to")

    if file_type or search or uploader or date_from or date_to:
        for group in data["groups"]:
            filtered = group["attachments"]
            if file_type:
                filtered = [
                    a for a in filtered if a["file_category"] == file_type
                ]
            if search:
                filtered = [
                    a for a in filtered if search in a["filename"].lower()
                ]
            if uploader:
                filtered = [
                    a for a in filtered if uploader in a["creator"].lower()
                ]
            if date_from:
                filtered = [
                    a for a in filtered if a["created"] >= date_from
                ]
            if date_to:
                filtered = [
                    a for a in filtered if a["created"] <= date_to
                ]
            group["attachments"] = filtered

        data["groups"] = [g for g in data["groups"] if g["attachments"]]
        data["total_attachments"] = sum(
            len(g["attachments"]) for g in data["groups"]
        )
        total_size = sum(
            a["size"] for g in data["groups"] for a in g["attachments"]
        )
        data["total_size"] = total_size
        data["total_size_display"] = format_file_size(total_size)

    return JsonResponse(data)


@login_required
def api_attachment_preview(request, pk):
    """Return metadata and optional content for an attachment preview."""
    try:
        att = Attachment.objects.select_related("creator").get(pk=pk)
    except Attachment.DoesNotExist:
        return JsonResponse({"error": "Attachment not found"}, status=404)

    filename = att.filename
    category = get_file_category(filename)

    try:
        size = att.attachment_file.size
    except (FileNotFoundError, OSError):
        size = 0

    result = {
        "id": att.pk,
        "filename": filename,
        "url": att.attachment_file.url,
        "file_type": get_file_extension(filename),
        "file_category": category,
        "file_icon": get_file_icon_class(category),
        "size": size,
        "size_display": format_file_size(size),
        "creator": att.creator.username if att.creator else "",
        "created": att.created.strftime("%Y-%m-%d %H:%M") if att.created else "",
        "content": None,
    }

    # For text files, read up to 100KB of content
    if category == "text":
        try:
            att.attachment_file.open("rb")
            raw = att.attachment_file.read(102400)  # 100KB
            att.attachment_file.close()
            result["content"] = raw.decode("utf-8", errors="replace")
        except (FileNotFoundError, OSError):
            result["content"] = "(File not accessible)"

    # For markdown files, render as HTML
    elif category == "markdown":
        from tcms.core.templatetags.extra_filters import markdown2html

        try:
            att.attachment_file.open("rb")
            raw = att.attachment_file.read(102400)  # 100KB
            att.attachment_file.close()
            md_text = raw.decode("utf-8", errors="replace")
            result["content"] = str(markdown2html(md_text))
        except (FileNotFoundError, OSError):
            result["content"] = "(File not accessible)"

    # For document files, extract readable content
    elif category == "document":
        ext = get_file_extension(filename)
        result["content"] = _extract_document_content(att, ext)

    # For PDFs, provide the serve URL for iframe embedding
    if category == "pdf":
        result["serve_url"] = "/tcms_artifact_browser/api/attachment/{}/serve/".format(
            att.pk
        )

    return JsonResponse(result)


def _extract_document_content(att, ext):
    """Extract readable content from DOCX or XLSX files."""
    try:
        att.attachment_file.open("rb")
        file_data = io.BytesIO(att.attachment_file.read())
        att.attachment_file.close()
    except (FileNotFoundError, OSError):
        return "(File not accessible)"

    if ext in ("docx", "doc"):
        return _extract_docx_content(file_data)
    elif ext in ("xlsx", "xls"):
        return _extract_xlsx_content(file_data)
    return None


def _extract_docx_content(file_data):
    """Extract text paragraphs from a DOCX file as HTML."""
    try:
        from docx import Document

        doc = Document(file_data)
        parts = []
        for para in doc.paragraphs[:200]:
            text = para.text.strip()
            if not text:
                continue
            if para.style and para.style.name.startswith("Heading"):
                level = para.style.name.replace("Heading ", "")
                if level.isdigit():
                    parts.append(
                        "<h{0}>{1}</h{0}>".format(
                            min(int(level), 6), text
                        )
                    )
                    continue
            parts.append("<p>{}</p>".format(text))

        # Also extract tables
        for table in doc.tables[:10]:
            parts.append('<table class="table table-bordered table-condensed">')
            for i, row in enumerate(table.rows[:50]):
                tag = "th" if i == 0 else "td"
                parts.append("<tr>")
                for cell in row.cells:
                    parts.append(
                        "<{0}>{1}</{0}>".format(tag, cell.text.strip())
                    )
                parts.append("</tr>")
            parts.append("</table>")

        return "\n".join(parts) if parts else "(Empty document)"
    except Exception:
        return "(Could not parse document)"


def _extract_xlsx_content(file_data):
    """Extract cells from an XLSX file as an HTML table."""
    try:
        from openpyxl import load_workbook

        wb = load_workbook(file_data, read_only=True, data_only=True)
        parts = []

        for sheet_name in wb.sheetnames[:5]:
            ws = wb[sheet_name]
            parts.append(
                "<h4>{}</h4>".format(sheet_name)
            )
            parts.append(
                '<table class="table table-bordered table-condensed '
                'table-striped">'
            )
            for i, row in enumerate(ws.iter_rows(max_row=100, values_only=True)):
                tag = "th" if i == 0 else "td"
                parts.append("<tr>")
                for cell in row:
                    val = "" if cell is None else str(cell)
                    parts.append("<{0}>{1}</{0}>".format(tag, val))
                parts.append("</tr>")
            parts.append("</table>")

        wb.close()
        return "\n".join(parts) if parts else "(Empty spreadsheet)"
    except Exception:
        return "(Could not parse spreadsheet)"


@login_required
def api_serve_file(request, pk):
    """Serve an attachment file with headers allowing iframe embedding."""
    import mimetypes

    try:
        att = Attachment.objects.get(pk=pk)
    except Attachment.DoesNotExist:
        return JsonResponse({"error": "Attachment not found"}, status=404)

    try:
        att.attachment_file.open("rb")
        file_data = att.attachment_file.read()
        att.attachment_file.close()
    except (FileNotFoundError, OSError):
        return JsonResponse({"error": "File not accessible"}, status=404)

    content_type, _ = mimetypes.guess_type(att.filename)
    if not content_type:
        content_type = "application/octet-stream"

    response = HttpResponse(file_data, content_type=content_type)
    response["X-Frame-Options"] = "SAMEORIGIN"
    response["Content-Disposition"] = 'inline; filename="{}"'.format(att.filename)
    return response


@login_required
def api_search_attachments(request):
    """Search attachments by filename across all content types."""
    query = request.GET.get("q", "").strip()
    if len(query) < 2:
        return JsonResponse({"results": [], "query": query})

    run_ct = ContentType.objects.get_for_model(TestRun)
    exec_ct = ContentType.objects.get_for_model(TestExecution)
    case_ct = ContentType.objects.get_for_model(TestCase)
    bug_ct = ContentType.objects.get_for_model(Bug)

    attachments = (
        Attachment.objects.filter(
            content_type__in=[run_ct, exec_ct, case_ct, bug_ct],
            attachment_file__icontains=query,
        )
        .select_related("creator", "content_type")[:100]
    )

    results = []
    for att in attachments:
        filename = att.filename
        try:
            size = att.attachment_file.size
        except (FileNotFoundError, OSError):
            size = 0

        category = get_file_category(filename)

        context_info = ""
        if att.content_type == run_ct:
            try:
                r = TestRun.objects.get(pk=att.object_id)
                context_info = "Run: TR-{} {}".format(r.pk, r.summary)
            except TestRun.DoesNotExist:
                context_info = "Run: TR-{}".format(att.object_id)
        elif att.content_type == exec_ct:
            try:
                e = TestExecution.objects.select_related(
                    "run", "case"
                ).get(pk=att.object_id)
                context_info = "Run: TR-{} / Case: TC-{}".format(
                    e.run.pk, e.case.pk if e.case else "?"
                )
            except TestExecution.DoesNotExist:
                context_info = "Execution: {}".format(att.object_id)
        elif att.content_type == case_ct:
            try:
                c = TestCase.objects.get(pk=att.object_id)
                context_info = "Case: TC-{} {}".format(c.pk, c.summary)
            except TestCase.DoesNotExist:
                context_info = "Case: TC-{}".format(att.object_id)
        elif att.content_type == bug_ct:
            try:
                bug = Bug.objects.get(pk=att.object_id)
                context_info = "Bug: BUG-{} {}".format(bug.pk, bug.summary)
            except Bug.DoesNotExist:
                context_info = "Bug: BUG-{}".format(att.object_id)

        results.append({
            "id": att.pk,
            "filename": filename,
            "url": att.attachment_file.url,
            "file_type": get_file_extension(filename),
            "file_category": category,
            "file_icon": get_file_icon_class(category),
            "size": size,
            "size_display": format_file_size(size),
            "creator": att.creator.username if att.creator else "",
            "created": att.created.strftime("%Y-%m-%d %H:%M") if att.created else "",
            "context": context_info,
        })

    return JsonResponse({"results": results, "query": query})


@login_required
def api_statistics(request):
    """Return aggregate statistics for all artifacts."""
    product_id = request.GET.get("product")

    run_ct = ContentType.objects.get_for_model(TestRun)
    exec_ct = ContentType.objects.get_for_model(TestExecution)
    case_ct = ContentType.objects.get_for_model(TestCase)
    bug_ct = ContentType.objects.get_for_model(Bug)

    relevant_cts = [run_ct, exec_ct, case_ct, bug_ct]
    attachments = Attachment.objects.filter(content_type__in=relevant_cts)

    if product_id:
        run_ids = list(
            TestRun.objects.filter(plan__product_id=product_id)
            .values_list("pk", flat=True)
        )
        exec_ids = list(
            TestExecution.objects.filter(run_id__in=run_ids)
            .values_list("pk", flat=True)
        )
        case_ids = list(
            TestExecution.objects.filter(run_id__in=run_ids)
            .values_list("case_id", flat=True)
            .distinct()
        )
        bug_ids = list(
            Bug.executions.through.objects.filter(
                testexecution_id__in=exec_ids
            ).values_list("bug_id", flat=True).distinct()
        ) if exec_ids else []

        q = Q(content_type=run_ct, object_id__in=[str(i) for i in run_ids])
        if exec_ids:
            q |= Q(content_type=exec_ct, object_id__in=[str(i) for i in exec_ids])
        if case_ids:
            q |= Q(content_type=case_ct, object_id__in=[str(i) for i in case_ids])
        if bug_ids:
            q |= Q(content_type=bug_ct, object_id__in=[str(i) for i in bug_ids])

        attachments = attachments.filter(q)

    # Compute totals
    total = 0
    total_size = 0
    by_category = {}
    by_source = {"testrun": 0, "testexecution": 0, "testcase": 0, "bug": 0}

    for att in attachments.select_related("content_type"):
        filename = att.filename
        try:
            size = att.attachment_file.size
        except (FileNotFoundError, OSError):
            size = 0

        total += 1
        total_size += size

        cat = get_file_category(filename)
        by_category[cat] = by_category.get(cat, 0) + 1

        model_name = att.content_type.model
        if model_name in by_source:
            by_source[model_name] += 1

    by_file_type = [
        {"name": name, "count": count}
        for name, count in sorted(by_category.items(), key=lambda x: -x[1])
    ]

    source_labels = {
        "testrun": "Test Run",
        "testexecution": "Test Execution",
        "testcase": "Test Case",
        "bug": "Bug",
    }
    by_source_list = [
        {"name": source_labels.get(name, name), "count": count}
        for name, count in by_source.items()
        if count > 0
    ]

    # By product
    by_product = {}
    product_runs = TestRun.objects.select_related("plan__product").all()
    if product_id:
        product_runs = product_runs.filter(plan__product_id=product_id)

    for run in product_runs:
        prod_name = run.plan.product.name if run.plan and run.plan.product else "Unknown"
        if prod_name not in by_product:
            by_product[prod_name] = 0

    # Count attachments per product through runs
    run_ct_model = run_ct
    for att in attachments.filter(content_type=run_ct_model).select_related("content_type"):
        try:
            r = TestRun.objects.select_related("plan__product").get(pk=att.object_id)
            prod_name = r.plan.product.name if r.plan and r.plan.product else "Unknown"
            by_product[prod_name] = by_product.get(prod_name, 0) + 1
        except TestRun.DoesNotExist:
            pass

    for att in attachments.filter(content_type=exec_ct).select_related("content_type"):
        try:
            e = TestExecution.objects.select_related(
                "run__plan__product"
            ).get(pk=att.object_id)
            prod_name = (
                e.run.plan.product.name
                if e.run and e.run.plan and e.run.plan.product
                else "Unknown"
            )
            by_product[prod_name] = by_product.get(prod_name, 0) + 1
        except TestExecution.DoesNotExist:
            pass

    for att in attachments.filter(content_type=bug_ct).select_related("content_type"):
        try:
            bug = Bug.objects.get(pk=att.object_id)
            # Trace bug → executions → run → plan → product
            exec_link = bug.executions.select_related(
                "run__plan__product"
            ).first()
            if exec_link and exec_link.run and exec_link.run.plan and exec_link.run.plan.product:
                prod_name = exec_link.run.plan.product.name
            else:
                prod_name = "Unknown"
            by_product[prod_name] = by_product.get(prod_name, 0) + 1
        except Bug.DoesNotExist:
            pass

    by_product_list = [
        {"name": name, "count": count}
        for name, count in sorted(by_product.items(), key=lambda x: -x[1])
        if count > 0
    ]

    return JsonResponse({
        "total": total,
        "total_size": total_size,
        "total_size_display": format_file_size(total_size),
        "by_file_type": by_file_type,
        "by_source": by_source_list,
        "by_product": by_product_list,
    })


@login_required
def api_download_zip(request):
    """Create and return a ZIP file of selected attachments."""
    if request.method != "POST":
        return JsonResponse({"error": "POST required"}, status=405)

    try:
        body = json.loads(request.body)
    except (json.JSONDecodeError, ValueError):
        return JsonResponse({"error": "Invalid JSON"}, status=400)

    attachment_ids = body.get("attachment_ids", [])
    run_id = body.get("run_id")

    if not attachment_ids:
        return JsonResponse({"error": "No attachments selected"}, status=400)

    attachments = Attachment.objects.filter(pk__in=attachment_ids).select_related("creator")

    # Get run summary for path structure
    run_summary = "artifacts"
    if run_id:
        try:
            run = TestRun.objects.get(pk=run_id)
            run_summary = run.summary
        except TestRun.DoesNotExist:
            pass

    # Build execution/case mapping for path structure
    exec_ct = ContentType.objects.get_for_model(TestExecution)
    case_ct = ContentType.objects.get_for_model(TestCase)
    run_ct = ContentType.objects.get_for_model(TestRun)
    bug_ct = ContentType.objects.get_for_model(Bug)

    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
        seen_paths = set()
        for att in attachments:
            filename = att.filename
            is_run_level = att.content_type == run_ct

            case_summary = ""
            if att.content_type == exec_ct:
                try:
                    e = TestExecution.objects.select_related("case").get(
                        pk=att.object_id
                    )
                    case_summary = e.case.summary if e.case else ""
                except TestExecution.DoesNotExist:
                    pass
            elif att.content_type == case_ct:
                try:
                    c = TestCase.objects.get(pk=att.object_id)
                    case_summary = c.summary
                except TestCase.DoesNotExist:
                    pass
            elif att.content_type == bug_ct:
                try:
                    bug = Bug.objects.get(pk=att.object_id)
                    case_summary = "BUG-{}_{}".format(bug.pk, bug.summary)
                except Bug.DoesNotExist:
                    case_summary = "BUG-{}".format(att.object_id)

            zip_path = build_zip_path(
                run_summary, case_summary, filename, is_run_level
            )

            # Handle duplicate filenames
            original_path = zip_path
            counter = 1
            while zip_path in seen_paths:
                name, ext = os.path.splitext(original_path)
                zip_path = "{}_{}{}".format(name, counter, ext)
                counter += 1
            seen_paths.add(zip_path)

            try:
                att.attachment_file.open("rb")
                zf.writestr(zip_path, att.attachment_file.read())
                att.attachment_file.close()
            except (FileNotFoundError, OSError):
                zf.writestr(
                    zip_path + ".missing.txt",
                    "File not found on disk: {}".format(att.attachment_file.name),
                )

    buf.seek(0)
    safe_name = sanitize_path(run_summary)
    response = HttpResponse(buf.getvalue(), content_type="application/zip")
    response["Content-Disposition"] = (
        'attachment; filename="artifacts-{}.zip"'.format(safe_name)
    )
    return response


# ==========================================
# Report Endpoints
# ==========================================


@login_required
def api_report(request):
    """Download a CSV report of artifacts."""
    metadata = _build_report_metadata(request, "Artifacts")
    rows = _get_all_attachment_rows(request)

    response = HttpResponse(content_type="text/csv")
    response["Content-Disposition"] = 'attachment; filename="artifacts_report.csv"'

    writer = csv.writer(response)
    _write_csv_metadata(writer, metadata)
    writer.writerow(REPORT_HEADERS)

    for row in rows:
        writer.writerow(row)

    return response


@login_required
def api_report_excel(request):
    """Download an Excel report of artifacts."""
    from openpyxl import Workbook
    from openpyxl.utils import get_column_letter

    metadata = _build_report_metadata(request, "Artifacts")
    rows = _get_all_attachment_rows(request)

    wb = Workbook()
    ws = wb.active
    ws.title = "Artifacts"

    data_start = _write_excel_metadata(ws, metadata)

    last_row = _write_excel_table(ws, REPORT_HEADERS, rows, start_row=data_start)

    if last_row > data_start:
        ws.auto_filter.ref = (
            "A{}:{}{}".format(
                data_start,
                get_column_letter(len(REPORT_HEADERS)),
                last_row - 1,
            )
        )

    buf = io.BytesIO()
    wb.save(buf)
    buf.seek(0)

    response = HttpResponse(
        buf.getvalue(),
        content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    )
    response["Content-Disposition"] = 'attachment; filename="artifacts_report.xlsx"'
    return response


@login_required
def api_report_docx(request):
    """Download a Word document report of artifacts."""
    from docx import Document

    metadata = _build_report_metadata(request, "Artifacts")
    rows = _get_all_attachment_rows(request)

    doc = Document()
    doc.add_heading("Artifacts Report", level=1)
    _write_docx_metadata(doc, metadata)
    _write_docx_table(doc, "Artifacts", REPORT_HEADERS, rows)

    buf = io.BytesIO()
    doc.save(buf)
    buf.seek(0)

    response = HttpResponse(
        buf.getvalue(),
        content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    )
    response["Content-Disposition"] = 'attachment; filename="artifacts_report.docx"'
    return response


@login_required
def api_report_pdf(request):
    """Download a PDF report of artifacts."""
    from reportlab.lib.pagesizes import landscape, letter
    from reportlab.lib.styles import getSampleStyleSheet
    from reportlab.lib.units import inch
    from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table

    metadata = _build_report_metadata(request, "Artifacts")
    rows = _get_all_attachment_rows(request)
    styles = getSampleStyleSheet()

    buf = io.BytesIO()
    doc = SimpleDocTemplate(buf, pagesize=landscape(letter), topMargin=0.5 * inch)
    elements = []

    elements.append(Paragraph("Artifacts Report", styles["Title"]))
    elements.append(Spacer(1, 12))
    elements.extend(_build_pdf_metadata_elements(metadata, styles))

    cell_style = styles["Normal"]
    cell_style.fontSize = 7
    cell_style.leading = 9

    table_data = [REPORT_HEADERS]
    for row in rows:
        table_data.append([
            row[0],                                  # ID
            Paragraph(str(row[1]), cell_style),      # Filename
            row[2],                                  # Type
            row[3],                                  # Size
            row[4],                                  # Source
            row[5],                                  # Source ID
            Paragraph(str(row[6]), cell_style),      # Test Run
            Paragraph(str(row[7]), cell_style),      # Test Case
            row[8],                                  # Uploader
            row[9],                                  # Date
        ])

    col_widths = [
        0.3 * inch,   # ID
        1.5 * inch,   # Filename
        0.5 * inch,   # Type
        0.5 * inch,   # Size
        0.7 * inch,   # Source
        0.5 * inch,   # Source ID
        1.8 * inch,   # Test Run
        1.8 * inch,   # Test Case
        0.7 * inch,   # Uploader
        0.7 * inch,   # Date
    ]

    table = Table(table_data, colWidths=col_widths, repeatRows=1)
    table.setStyle(_get_pdf_table_style())
    elements.append(table)

    doc.build(elements)
    buf.seek(0)

    response = HttpResponse(buf.getvalue(), content_type="application/pdf")
    response["Content-Disposition"] = 'attachment; filename="artifacts_report.pdf"'
    return response
