from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
from django.http import (
    HttpResponseBadRequest, HttpResponseForbidden, HttpResponse, JsonResponse
)
from django.template.loader import render_to_string
from django.views.decorators.http import require_http_methods, require_POST
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone

from .forms import IssueRefForm, ReferenceConfirmForm, ReferenceEditForm, LetterBodyForm
from .models import Sector, Reference, GlobalCounter
from .services import peek_next_index, issue_reference

from pathlib import Path
from docxtpl import DocxTemplate
from html2docx import html2docx
from docx import Document
from htmldocx import HtmlToDocx
from uuid import uuid4
import tempfile, os, shutil, base64, html, io, docx, re
from django.db import transaction, IntegrityError
import html as py_html
from html import escape as html_escape
from bs4 import BeautifulSoup
from pathlib import Path
import re


# ---------- helpers ----------
def _is_manager(user):
    return user.is_staff or user.is_superuser

def _can_edit(user, ref: Reference) -> bool:
    return _is_manager(user) or ref.assigned_to_id == user.id

def _can_delete(user, ref: Reference) -> bool:
    if _is_manager(user):
        last = Reference.objects.order_by("-created_at", "-id").first()
        return bool(last and last.pk == ref.pk)
    last_user = (
        Reference.objects
        .filter(assigned_to=user)
        .order_by("-created_at", "-id")
        .first()
    )
    return bool(last_user and last_user.pk == ref.pk)

# ---------- main pages ----------
@login_required
def dashboard(request):
    is_manager = request.user.is_staff or request.user.is_superuser

    if is_manager:
        qs = (
            Reference.objects
            .select_related("assigned_to")
            .order_by("-created_at", "-id")[:50]
        )
        last_overall = Reference.objects.order_by("-created_at", "-id").first()
        last_id_for_user = last_overall.id if last_overall else None
    else:
        qs = (
            Reference.objects
            .filter(assigned_to=request.user)
            .select_related("assigned_to")
            .order_by("-created_at", "-id")[:50]
        )
        last_user = (
            Reference.objects
            .filter(assigned_to=request.user)
            .order_by("-created_at", "-id")
            .first()
        )
        last_id_for_user = last_user.id if last_user else None

    # Render the sector select from IssueRefForm so choices are always present.
    dash_form = IssueRefForm()
    dash_form.fields["sector"].widget.attrs.update({
        "class": "form-select",
        "id": "dashDepartmentSelect",   # JS looks this up
    })

    return render(request, "refs/dashboard.html", {
        "form": dash_form,
        "recent": qs,
        "show_user_col": is_manager,
        "last_id_for_user": last_id_for_user,
        "is_manager": is_manager,
        "sectors": Sector.choices,
    })



# ---------- modal endpoints (used by popup) ----------


def _counter_should_reset(last: Reference, now, policy: str) -> bool:
    """
    Return True when the counter must reset comparing last Reference vs now.
    policy: 'yearly' | 'monthly' | 'daily' (case-insensitive).
    If last is None -> reset.
    """
    if not policy:
        return False
    policy = policy.lower()
    if last is None:
        return True
    try:
        last_dt = timezone.localtime(last.created_at)
    except Exception:
        # fallback if created_at missing/naive
        try:
            last_dt = timezone.make_aware(last.created_at)
        except Exception:
            return False

    if policy == "yearly":
        return last_dt.year != now.year
    if policy == "monthly":
        return (last_dt.year != now.year) or (last_dt.month != now.month)
    if policy == "daily":
        return (last_dt.year != now.year) or (last_dt.month != now.month) or (last_dt.day != now.day)
    return False


@require_POST
@login_required
def preview_reference_json(request):
    """
    POST with sector -> return modal HTML + preview string.
    Includes reset logic: if REF_INDEX_RESET_POLICY == 'yearly' and last Reference
    is from a previous year we preview index 1 without mutating DB.
    """
    form = IssueRefForm(request.POST)
    if not form.is_valid():
        return JsonResponse({"ok": False, "error": "Invalid sector."}, status=400)

    sector = form.cleaned_data["sector"]
    now = timezone.localtime()

    # Default next index from counter
    counter, _ = GlobalCounter.objects.get_or_create(scope_key="global", defaults={"next_index": 1})
    next_idx = counter.next_index

    # Apply reset policy for preview (no DB change)
    policy = getattr(settings, "REF_INDEX_RESET_POLICY", "").lower()
    if policy in ("yearly", "monthly", "daily"):
        last = Reference.objects.order_by("-created_at", "-id").first()
        if _counter_should_reset(last, now, policy):
            next_idx = 1
        else:
            try:
                last_year = int(getattr(last, "year", None)) if getattr(last, "year", None) else timezone.localtime(last.created_at).year
            except Exception:
                last_year = timezone.localtime(last.created_at).year
            if last_year != now.year:
                next_idx = 1

    preview_ref = f"{next_idx:03d}/RU&CO/{sector}/{now.day:02d}/{now.month:02d}/{now.year}"

    confirm_form = ReferenceConfirmForm(initial={
        "sector": sector,
        "status": "RESERVED",
        "index": next_idx,
        "day": now.day,
        "month": now.month,
        "year": now.year,
    })

    html = render_to_string(
        "refs/_reference_confirm_modal.html",
        {"preview_ref": preview_ref, "confirm_form": confirm_form},
        request=request,
    )
    return JsonResponse({"ok": True, "html": html, "preview_ref": preview_ref})


@require_http_methods(["POST"])
@login_required
def confirm_reference(request):
    """
    POST with ReferenceConfirmForm.
    Atomically consumes the next index from GlobalCounter and creates a Reference.
    Honors REF_INDEX_RESET_POLICY (yearly reset).
    """
    form = ReferenceConfirmForm(request.POST or None)
    if not form.is_valid():
        return JsonResponse({"ok": False, "error": "Please correct the form.", "errors": form.errors}, status=400)

    sector = form.cleaned_data["sector"]              # e.g. "LIT"
    title  = form.cleaned_data["title"].strip()
    status = form.cleaned_data["status"].upper()      # ensure "ISSUED"/"RESERVED"

    # Validate against your choices
    valid_statuses = {choice[0] for choice in Reference.STATUS_CHOICES}
    if status not in valid_statuses:
        return JsonResponse({"ok": False, "error": "Invalid status."}, status=400)

    now = timezone.localtime()

    with transaction.atomic():
        # Lock or create the single global counter row
        counter = (
            GlobalCounter.objects
            .select_for_update()
            .get_or_create(scope_key="global", defaults={"next_index": 1})[0]
        )

        # Apply reset policy before consuming
        policy = getattr(settings, "REF_INDEX_RESET_POLICY", "").lower()
        if policy in ("yearly", "monthly", "daily"):
            last = Reference.objects.order_by("-created_at", "-id").first()
            if _counter_should_reset(last, now, policy):
                counter.next_index = 1
            else:
                try:
                    last_year = int(getattr(last, "year", None)) if getattr(last, "year", None) else timezone.localtime(last.created_at).year
                except Exception:
                    last_year = timezone.localtime(last.created_at).year
                if last_year != now.year:
                    counter.next_index = 1

        # consume index
        idx = counter.next_index
        counter.next_index = idx + 1
        counter.save(update_fields=["next_index"])

        # Create the Reference using your model fields
        ref = Reference.objects.create(
            index=idx,
            sector=sector,
            day=now.day,
            month=now.month,
            year=now.year,
            title=title,
            status=status,                # "ISSUED" or "RESERVED"
            assigned_to=request.user,
        )

    # Use model's ref_string() for the final string
    return JsonResponse({"ok": True, "reference": ref.ref_string(), "id": ref.id})



# ---------- staff manage ----------
@user_passes_test(_is_manager)
def manage_references(request):
    items = Reference.objects.select_related("assigned_to").order_by("-created_at", "-id")
    last = items.first()
    last_id = last.id if last else None
    return render(request, "refs/manage.html", {"items": items, "last_id": last_id})

# ---------- edit / delete ----------
@login_required
@require_http_methods(["GET", "POST"])
def edit_reference(request, pk: int):
    ref = get_object_or_404(Reference, pk=pk)
    if not _can_edit(request.user, ref):
        return HttpResponseForbidden("You cannot edit this reference.")

    if request.method == "POST":
        form = ReferenceEditForm(request.POST, instance=ref)
        if form.is_valid():
            form.save()
            messages.success(request, "Reference updated.")
            return redirect("manage_references" if _is_manager(request.user) else "dashboard")
        messages.error(request, "Please correct the errors below.")
    else:
        form = ReferenceEditForm(instance=ref)

    return render(request, "refs/edit_reference.html", {"form": form, "ref": ref})

@login_required
@require_http_methods(["POST"])
def delete_reference(request, pk: int):
    ref = get_object_or_404(Reference, pk=pk)
    if not _can_delete(request.user, ref):
        messages.error(request, "Only your latest reference (or latest overall if staff) can be deleted.")
        return redirect("manage_references" if _is_manager(request.user) else "dashboard")
    ref.delete()
    messages.success(request, "Reference deleted.")
    return redirect("manage_references" if _is_manager(request.user) else "dashboard")


# ---------- letter (unchanged root paths) ----------
TEMPLATE_PATH = Path(settings.BASE_DIR) / "static" / "templates" / "letterhead_template.docx"
TMP_DIR = Path(getattr(settings, "MEDIA_ROOT", Path(settings.BASE_DIR) / "media")) / "tmp_letters"
TMP_DIR.mkdir(parents=True, exist_ok=True)

# --------------------------------------------------------------------------
# helpers for the letter pipeline (robust to htmldocx API differences)
# --------------------------------------------------------------------------


def _html_to_docx_file(html_str: str, out_path: str) -> None:
    """
    Convert prepared HTML -> .docx preserving:
      - paragraph boundaries (<p>, <div>, <h1>-<h6>)
      - lists (<ul>, <ol>, nested <li>)
      - inline formatting (<b>, <i>, <u>, <a>, <span> color)
      - paragraph alignment from style="text-align:...", align="...", or common editor classes
      - multi-space runs (NBSP-preserving)
    """
    from bs4 import BeautifulSoup, NavigableString
    from docx import Document
    from docx.shared import RGBColor, Pt
    from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
    import re

    html_str = html_str or ""

    # Debug: write the intermediate HTML next to the docx for inspection
    try:
        with open(f"{out_path}.html", "w", encoding="utf-8") as f:
            f.write(html_str)
    except Exception:
        pass

    soup = BeautifulSoup(html_str, "html.parser")

    def fix_space_runs(s: str) -> str:
        """Convert runs of spaces -> NBSP-preserving form for Word."""
        if not s:
            return s
        out = []
        prev_char = "\n"
        i = 0
        n = len(s)
        while i < n:
            ch = s[i]
            if ch == " ":
                j = i
                while j < n and s[j] == " ":
                    j += 1
                run_len = j - i
                if prev_char == "\n" or prev_char == "":
                    out.append("\u00A0" * run_len)
                else:
                    out.append(" " if run_len == 1 else " " + ("\u00A0" * (run_len - 1)))
                prev_char = " "
                i = j
            else:
                out.append(ch)
                prev_char = ch
                i += 1
        return "".join(out)

    def apply_run_formatting(run, style):
        if not style:
            return
        if style.get("bold"):
            run.bold = True
        if style.get("italic"):
            run.italic = True
        if style.get("underline"):
            run.underline = True
        color = style.get("color")
        if color:
            try:
                run.font.color.rgb = RGBColor(color[0], color[1], color[2])
            except Exception:
                pass
        if style.get("font_name"):
            try:
                run.font.name = style["font_name"]
            except Exception:
                pass

    def style_from_tag(tag, base_style):
        s = dict(base_style or {})
        name = tag.name.lower() if getattr(tag, "name", None) else None
        if name in ("b", "strong"):
            s["bold"] = True
        if name in ("i", "em"):
            s["italic"] = True
        if name == "u":
            s["underline"] = True
        if name == "a":
            s.setdefault("underline", True)
            s.setdefault("color", (0x00, 0x00, 0xFF))
        style_attr = (tag.get("style") or "").lower()
        if "font-weight: bold" in style_attr:
            s["bold"] = True
        if "font-style: italic" in style_attr:
            s["italic"] = True
        if "text-decoration: underline" in style_attr:
            s["underline"] = True
        m = re.search(r"color\s*:\s*#([0-9a-f]{6})", style_attr)
        if m:
            hexv = m.group(1)
            s["color"] = (int(hexv[0:2], 16), int(hexv[2:4], 16), int(hexv[4:6], 16))
        return s

    def parse_alignment(node):
        """Detect alignment from style, align attr, or common editor classes (e.g. ql-align-center)."""
        # 1) inline style
        style_attr = (getattr(node, "get", lambda k, d=None: d)("style") or "").lower()
        m = re.search(r"text-align\s*:\s*(left|right|center|justify)", style_attr)
        if m:
            val = m.group(1)
            return {
                "left": WD_PARAGRAPH_ALIGNMENT.LEFT,
                "right": WD_PARAGRAPH_ALIGNMENT.RIGHT,
                "center": WD_PARAGRAPH_ALIGNMENT.CENTER,
                "justify": WD_PARAGRAPH_ALIGNMENT.JUSTIFY,
            }.get(val)
        # 2) align attribute
        align_attr = (getattr(node, "get", lambda k, d=None: d)("align") or "").lower()
        if align_attr in ("left", "right", "center", "justify"):
            return {
                "left": WD_PARAGRAPH_ALIGNMENT.LEFT,
                "right": WD_PARAGRAPH_ALIGNMENT.RIGHT,
                "center": WD_PARAGRAPH_ALIGNMENT.CENTER,
                "justify": WD_PARAGRAPH_ALIGNMENT.JUSTIFY,
            }.get(align_attr)
        # 3) common editor classnames (Quill/CKEditor patterns)
        classes = node.get("class") or []
        cls = " ".join(classes).lower()
        if "ql-align-center" in cls or "align-center" in cls or "text-center" in cls:
            return WD_PARAGRAPH_ALIGNMENT.CENTER
        if "ql-align-right" in cls or "align-right" in cls or "text-right" in cls:
            return WD_PARAGRAPH_ALIGNMENT.RIGHT
        if "ql-align-justify" in cls or "align-justify" in cls or "text-justify" in cls:
            return WD_PARAGRAPH_ALIGNMENT.JUSTIFY
        return None

    def _save_image_src_to_temp(src: str) -> str:
        """
        Save an <img> source to a temp file and return the path.

        Improvements:
         - Handles srcset by picking first candidate
         - Unquotes percent-encoded filenames (e.g. %20 -> space)
         - Maps MEDIA_URL (e.g. /media/...) to MEDIA_ROOT
         - Downloads http(s) URLs, decodes data: URIs
        """
        import re
        import base64
        import tempfile
        import pathlib
        import os
        import urllib.request
        from urllib.parse import urlparse, unquote, urlsplit

        if not src:
            return ""

        src = str(src).strip()

        # If src looks like a srcset, pick the first URL candidate
        if "," in src and " " in src:
            try:
                first = src.split(",")[0].strip()
                candidate = first.split(" ")[0].strip()
                if candidate:
                    src = candidate
            except Exception:
                pass

        # Unquote percent-encoding (e.g. %20 -> space)
        try:
            src_unquoted = unquote(src)
        except Exception:
            src_unquoted = src

        # Normalize and remove surrounding quotes
        src_unquoted = src_unquoted.strip().strip('"').strip("'")

        # DATA URI
        if src_unquoted.startswith("data:"):
            try:
                header, b64 = src_unquoted.split(",", 1)
                m = re.match(r"data:(image/[^;]+);base64", header)
                ext = ".png"
                if m:
                    typ = m.group(1)
                    if "/" in typ:
                        ext = "." + typ.split("/")[1]
                data = base64.b64decode(b64)
                tf = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
                tf.write(data)
                tf.flush()
                tf.close()
                return tf.name
            except Exception:
                pass

        # HTTP(S) URL -> download
        parsed = urlparse(src_unquoted)
        if parsed.scheme in ("http", "https"):
            try:
                suffix = os.path.splitext(parsed.path)[1] or ".img"
                tf = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
                with urllib.request.urlopen(src_unquoted) as resp:
                    tf.write(resp.read())
                tf.flush()
                tf.close()
                return tf.name
            except Exception:
                pass

        # blob: cannot access server-side
        if src_unquoted.startswith("blob:"):
            return ""

        # Remove any query/fragment from the path
        try:
            parts = urlsplit(src_unquoted)
            path_only = parts.path
        except Exception:
            path_only = src_unquoted

        # Try mapping /media/... -> MEDIA_ROOT
        try:
            media_root = getattr(settings, "MEDIA_ROOT", None)
            media_url = getattr(settings, "MEDIA_URL", None) or "/media/"
            # Normalize media_url to start with '/'
            if media_url and not media_url.startswith("/"):
                media_url = "/" + media_url
            # If the path starts with media_url, resolve in MEDIA_ROOT
            if media_root and path_only.startswith(media_url):
                rel = path_only[len(media_url):].lstrip("/\\")
                candidate = pathlib.Path(media_root) / rel
                if candidate.exists():
                    return str(candidate)
            # Also try removing leading slash and joining to MEDIA_ROOT
            if media_root and not path_only.startswith(media_url):
                rel = path_only.lstrip("/\\")
                candidate = pathlib.Path(media_root) / rel
                if candidate.exists():
                    return str(candidate)
        except Exception:
            pass

        # Try absolute or relative filesystem paths
        try:
            p = pathlib.Path(path_only)
            if p.exists():
                return str(p)
            # Try relative to project BASE_DIR
            base = Path(getattr(settings, "BASE_DIR", pathlib.Path.cwd()))
            alt = base / path_only.lstrip("/\\")
            if alt.exists():
                return str(alt)
        except Exception:
            pass

        # Debug log for failing src
        try:
            import tempfile as _tf
            dbg = Path(_tf.gettempdir()) / "rubco_img_debug.log"
            with open(dbg, "a", encoding="utf-8") as f:
                f.write(f"FAILED_IMAGE_SRC: original={src} unquoted={src_unquoted} path_only={path_only}\n")
        except Exception:
            pass

        return ""


    def walk_inline_and_add(paragraph, node, base_style):
        """Walk inline children; add runs with inherited style. Supports <img> insertion."""
        from docx.shared import Inches
        import tempfile
        for child in node.contents:
            if isinstance(child, NavigableString):
                text = str(child)
                if text:
                    run = paragraph.add_run(fix_space_runs(text))
                    apply_run_formatting(run, base_style)
                continue
            nm = (child.name or "").lower()
            new_style = style_from_tag(child, base_style)

            if nm == "br":
                paragraph.add_run().add_break()
                continue

            if nm == "img":
                # try many common attributes CKEditor/other editors may set
                src_candidates = [
                    child.get("src"),
                    child.get("data-cke-saved-src"),
                    child.get("data-src"),
                    child.get("data-original"),
                    child.get("data-image"),
                    child.get("data-url"),
                    child.get("srcset"),
                    (child.attrs.get("data") if hasattr(child, "attrs") else None),
                ]
                src = ""
                for c in src_candidates:
                    if not c:
                        continue
                    c = str(c).strip()
                    if c:
                        src = c
                        break
                if not src:
                    # as last resort, try any attribute that contains 'src' in its name
                    for k, v in getattr(child, "attrs", {}).items():
                        if "src" in k and v:
                            src = str(v).strip()
                            break

                if not src:
                    # log missing src for debugging and skip
                    try:
                        import tempfile as _tf
                        dbg = Path(_tf.gettempdir()) / "rubco_img_debug.log"
                        with open(dbg, "a", encoding="utf-8") as f:
                            f.write(f"IMG_WITHOUT_SRC: attrs={child.attrs}\n")
                    except Exception:
                        pass
                    continue

                tmp_img = _save_image_src_to_temp(src)
                if not tmp_img:
                    # log failing src for debugging
                    try:
                        import tempfile as _tf
                        dbg = Path(_tf.gettempdir()) / "rubco_img_debug.log"
                        with open(dbg, "a", encoding="utf-8") as f:
                            f.write(f"IMG_SAVE_FAILED: src={src}\n")
                    except Exception:
                        pass
                    continue

                try:
                    run = paragraph.add_run()
                    # try to honor inline width style if present
                    width = None
                    style_attr = (child.get("style") or "").lower()
                    mpx = re.search(r"width\s*:\s*(\d+)\s*px", style_attr)
                    if mpx:
                        try:
                            px = int(mpx.group(1))
                            width = Inches(px / 96.0)
                        except Exception:
                            width = None
                    else:
                        min_ = re.search(r"width\s*:\s*([\d\.]+)\s*in", style_attr)
                        if min_:
                            try:
                                width = Inches(float(min_.group(1)))
                            except Exception:
                                width = None
                    if width is None:
                        width = Inches(1.6)
                    run.add_picture(tmp_img, width=width)
                finally:
                    # remove temp file if it was created in tempdir
                    try:
                        import os, tempfile as _tf
                        tmpdir = _tf.gettempdir()
                        if tmp_img and os.path.exists(tmp_img) and os.path.commonpath([tmp_img, tmpdir]) == tmpdir:
                            os.unlink(tmp_img)
                    except Exception:
                        pass
                continue

            # inline tags: descend and preserve formatting
            walk_inline_and_add(paragraph, child, new_style)



    def add_paragraph(doc, node, alignment=None, style_name=None):
        p = doc.add_paragraph(style=style_name) if style_name else doc.add_paragraph()
        if alignment is not None:
            try:
                p.alignment = alignment
            except Exception:
                pass
        walk_inline_and_add(p, node, None)
        return p

    def process_list(doc, list_node, list_type="ul", level=0):
        """
        Create a paragraph per <li>. Use built-in 'List Bullet' / 'List Number' styles.
        level is used only to set a basic left indent if needed.
        """
        style_name = "List Number" if list_type == "ol" else "List Bullet"
        for li in list_node.find_all("li", recursive=False):
            # If li contains only a block child (e.g. <p>), use that child for inline processing to avoid flattening
            inline_source = li
            # prefer first <p> or <div> inside li as the source of inline content
            first_block = li.find(["p", "div"], recursive=False)
            if first_block is not None:
                inline_source = first_block
            p = doc.add_paragraph(style=style_name)
            try:
                p.paragraph_format.left_indent = Pt(12 * level)
            except Exception:
                pass
            walk_inline_and_add(p, inline_source, None)
            # handle nested lists inside this li
            for child in li.contents:
                if getattr(child, "name", None) in ("ul", "ol"):
                    process_list(doc, child, list_type=child.name.lower(), level=level + 1)

    # Build document
    try:
        doc = Document()

        # Collect top-level block elements (allow ul/ol to surface even if inside wrapper div)
        raw_blocks = [tag for tag in soup.find_all(["p", "div", "ul", "ol", "h1", "h2", "h3", "h4", "h5", "h6"])]

        # Keep only those that are not nested inside other block containers OR expand wrappers
        top_blocks = [tag for tag in raw_blocks if not tag.find_parent(["p", "div", "ul", "ol"])]

        # If none found (rare), fallback to body children
        if not top_blocks:
            root = soup.body or soup
            top_blocks = [c for c in root.contents if getattr(c, "name", None) in ("p", "div", "ul", "ol", "h1", "h2", "h3", "h4", "h5", "h6")]

        # Expand wrapper blocks that contain immediate block children (so <div><ul>... will expose the ul)
        expanded = []
        for tag in top_blocks:
            immed = [c for c in tag.contents if getattr(c, "name", None) in ("p", "div", "ul", "ol", "h1", "h2", "h3", "h4", "h5", "h6")]
            if immed:
                expanded.extend(immed)
            else:
                expanded.append(tag)
        if expanded:
            top_blocks = expanded

        # Final fallback: whole soup as single block
        if not top_blocks:
            top_blocks = [soup]

        for bi, block in enumerate(top_blocks):
            tag_name = (getattr(block, "name", "") or "").lower()
            alignment = parse_alignment(block)

            if tag_name in ("h1", "h2", "h3", "h4", "h5", "h6"):
                style_map = {"h1": "Heading 1", "h2": "Heading 2", "h3": "Heading 3", "h4": "Heading 4", "h5": "Heading 5", "h6": "Heading 6"}
                p = doc.add_paragraph(style=style_map.get(tag_name))
                if alignment is not None:
                    p.alignment = alignment
                walk_inline_and_add(p, block, None)
                continue

            if tag_name in ("ul", "ol"):
                process_list(doc, block, list_type=tag_name)
                if bi != len(top_blocks) - 1:
                    doc.add_paragraph()
                continue

            # default paragraph/div
            add_paragraph(doc, block, alignment=alignment)
            if bi != len(top_blocks) - 1:
                doc.add_paragraph()

        if len(doc.paragraphs) == 0:
            doc.add_paragraph("")

        doc.save(out_path)
        return
    except Exception:
        try:
            doc = Document()
            doc.add_paragraph("Error converting HTML to DOCX. See accompanying .html for input.")
            doc.save(out_path)
        except Exception:
            with open(out_path, "wb") as f:
                f.write(b"")
        return


def _render_docx_to_bytes(our_ref: str, body_html: str) -> bytes:
    """
    Render the letter by converting the HTML body to a temporary .docx and
    inserting it as a subdocument into your letterhead template. Return bytes.
    """
    tpl = DocxTemplate(str(TEMPLATE_PATH))

    # 1) Build a temporary .docx for the body
    with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp:
        tmp_path = tmp.name
    _html_to_docx_file(body_html, tmp_path)

    # 2) Insert that .docx as a subdocument, then clean up
    try:
        subdoc = tpl.new_subdoc(tmp_path)
    finally:
        try:
            os.unlink(tmp_path)
        except Exception:
            pass

    # 3) Render into the letterhead
    tpl.render({"our_ref": html_escape(our_ref), "body": subdoc})

    # 4) Return final bytes
    with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as out_tmp:
        tpl.save(out_tmp.name)
        out_tmp.seek(0)
        data = out_tmp.read()
    os.unlink(out_tmp.name)
    return data


def _render_docx_to_path(docx_path: Path, our_ref: str, body_html: str) -> None:
    """
    Same as above but writes directly to docx_path.
    """
    tpl = DocxTemplate(str(TEMPLATE_PATH))

    with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp:
        tmp_path = tmp.name
    _html_to_docx_file(body_html, tmp_path)

    try:
        subdoc = tpl.new_subdoc(tmp_path)
    finally:
        try:
            os.unlink(tmp_path)
        except Exception:
            pass

    tpl.render({"our_ref": html_escape(our_ref), "body": subdoc})
    tpl.save(str(docx_path))


def _docx_to_pdf_windows(docx_path: Path, pdf_path: Path):
    """
    Windows-only conversion using docx2pdf (MS Word/Office must be installed).
    """
    import pythoncom
    from docx2pdf import convert
    pythoncom.CoInitialize()
    try:
        convert(str(docx_path), str(pdf_path))
    finally:
        pythoncom.CoUninitialize()


def _prepare_html_for_word(html_str: str) -> str:
    """
    Preserve visible spacing for Word while allowing normal word-wrapping
    and using a proportional font.

    Uses the mso-spacerun trick: wrap extra spaces inside
    <span style="mso-spacerun:yes">   </span> (literal spaces). Also wrap
    whole result in a div with pre-wrap so newlines are kept.
    """
    if not html_str:
        return html_str

    # Normalize NBSP to entity (keep explicit non-breaking where present)
    s = html_str.replace("\u00A0", "&nbsp;")

    out = []
    in_tag = False
    i = 0
    n = len(s)
    prev_text_char = "\n"

    while i < n:
        ch = s[i]
        if ch == "<":
            in_tag = True
            out.append(ch)
            i += 1
        elif ch == ">":
            in_tag = False
            out.append(ch)
            i += 1
        else:
            if not in_tag and ch == " ":
                # count consecutive spaces
                j = i
                while j < n and s[j] == " ":
                    j += 1
                run_len = j - i

                if prev_text_char == "\n" or prev_text_char == "":
                    # Leading run: preserve entire indent inside mso-spacerun span (use literal spaces)
                    out.append(f'<span style="mso-spacerun:yes;">{" " * run_len}</span>')
                else:
                    if run_len == 1:
                        out.append(" ")
                    else:
                        # one normal space (breakable) + remaining inside mso span (literal spaces)
                        out.append(" " + f'<span style="mso-spacerun:yes;">{" " * (run_len - 1)}</span>')
                prev_text_char = " "
                i = j
            else:
                out.append(ch)
                prev_text_char = ch
                i += 1

    s2 = "".join(out)

    # Wrap in a div with pre-wrap so newlines are preserved; allow long-word breaks.
    return (
        '<div style="white-space: pre-wrap; overflow-wrap: anywhere; word-wrap: break-word; '
        'font-family: Calibri, Arial, sans-serif; margin: 0; padding: 0;">'
        + s2
        + "</div>"
    )


# --------------------------------------------------------------------------
# views (unchanged flow, just ensure we pass HTML, not escaped tags)
# --------------------------------------------------------------------------

@login_required
def letter_form(request):
    sector_form = IssueRefForm()
    sector_form.fields["sector"].widget.attrs.update({
        "class": "form-select",
        "id": "letterSectorSelect",
    })
    form = LetterBodyForm()
    return render(
        request,
        "letter_form.html",
        {
            "sector_form": sector_form,
            "sectors": Sector.choices,
            "form": form,
        },
    )


@login_required
def letter_generate_docx(request):
    if request.method != "POST":
        return HttpResponseBadRequest("POST only")

    our_ref = request.POST.get("our_ref", "").strip()
    body_html = request.POST.get("body_html")
    if body_html is None:
        body_plain = request.POST.get("body", "").rstrip()
        body_html = "<p>" + html_escape(body_plain).replace("\n", "<br/>") + "</p>"

    # >>> This is the crucial line that applies the fix <<<
    body_html = _prepare_html_for_word(body_html)

    data = _render_docx_to_bytes(our_ref, body_html)
    resp = HttpResponse(
        data,
        content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    )
    resp["Content-Disposition"] = 'attachment; filename="letter.docx"'
    return resp


@login_required
def letter_generate_pdf(request):
    if request.method != "POST":
        return HttpResponseBadRequest("POST only")

    our_ref = request.POST.get("our_ref", "").strip()
    body_html = request.POST.get("body_html")
    if body_html is None:
        body_plain = request.POST.get("body", "").rstrip()
        escaped_body = html_escape(body_plain).replace("\n", "<br/>")
        body_html = f"<p>{escaped_body}</p>"

    # >>> Preserve visible spacing
    body_html = _prepare_html_for_word(body_html)

    with tempfile.TemporaryDirectory() as td:
        docx_path = Path(td) / "letter.docx"
        pdf_path  = Path(td) / "letter.pdf"
        _render_docx_to_path(docx_path, our_ref, body_html)
        _docx_to_pdf_windows(docx_path, pdf_path)
        data = pdf_path.read_bytes()

    resp = HttpResponse(data, content_type="application/pdf")
    resp["Content-Disposition"] = 'attachment; filename="letter.pdf"'
    return resp


@login_required
def letter_print(request):
    if request.method != "POST":
        return HttpResponseBadRequest("POST only")

    our_ref = request.POST.get("our_ref", "").strip()
    body_html = request.POST.get("body_html")
    if body_html is None:
        body_plain = request.POST.get("body", "").rstrip()
        escaped_body = html_escape(body_plain).replace("\n", "<br/>")
        body_html = f"<p>{escaped_body}</p>"

    # >>> Preserve visible spacing
    body_html = _prepare_html_for_word(body_html)

    token_dir = TMP_DIR / str(uuid4())
    token_dir.mkdir(parents=True, exist_ok=True)
    try:
        docx_path = token_dir / "letter.docx"
        pdf_path  = token_dir / "letter.pdf"
        _render_docx_to_path(docx_path, our_ref, body_html)
        _docx_to_pdf_windows(docx_path, pdf_path)
        data = pdf_path.read_bytes()
    finally:
        shutil.rmtree(token_dir, ignore_errors=True)

    b64 = base64.b64encode(data).decode("ascii")
    return render(request, "print_pdf_inline.html", {"pdf_b64": b64})