from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from email.parser import BytesParser
from email.policy import default
from xml.etree import ElementTree as ET
from urllib import request, error
import ssl
import datetime
import html
import json
import mimetypes
import os
import re
import shutil
import subprocess
import zipfile

BASE_DIR = Path(__file__).resolve().parents[1]
RESOURCES_DIR = Path(os.getenv("OWALISAATTORI_RESOURCES_DIR") or BASE_DIR)
DATA_DIR = Path(os.getenv("OWALISAATTORI_DATA_DIR") or (BASE_DIR / "data"))
INPUTS_DIR = DATA_DIR / "inputs"
OUTPUTS_DIR = DATA_DIR / "outputs"
RUNS_DIR = OUTPUTS_DIR / "runs"
def resolve_template_path(env_key: str, primary_name: str, legacy_name: str) -> Path:
    override = (os.getenv(env_key) or "").strip()
    if override:
        return Path(override)
    primary = RESOURCES_DIR / primary_name
    if primary.exists():
        return primary
    return RESOURCES_DIR / legacy_name


MEMO_TEMPLATE_PATH = resolve_template_path(
    "OWALISAATTORI_MEMO_TEMPLATE_PATH",
    "Muistion_tyylimaarittelyt.docx",
    "Muistion tyylimäärittelyt.docx",
)
REPORT_TEMPLATE_PATH = resolve_template_path(
    "OWALISAATTORI_REPORT_TEMPLATE_PATH",
    "Laajan_raportin_tyylimaarittely.docx",
    "Laajan raportin tyylimäärittely.docx",
)

for directory in [INPUTS_DIR, OUTPUTS_DIR, RUNS_DIR]:
    directory.mkdir(parents=True, exist_ok=True)

SUPPORTED_EXTENSIONS = {".docx", ".pdf", ".txt", ".md", ".rtf", ".csv"}


def now_stamp() -> str:
    return datetime.datetime.now().strftime("%Y%m%d_%H%M%S")


def sanitize_name(name: str) -> str:
    clean = "".join(ch for ch in str(name or "") if ch.isalnum() or ch in "._- ")
    clean = clean.strip().replace(" ", "_")
    return clean or "file"


def unique_file_name(target_dir: Path, file_name: str) -> str:
    base = sanitize_name(Path(file_name).stem)
    suffix = Path(file_name).suffix.lower()
    if suffix not in SUPPORTED_EXTENSIONS:
        suffix = ""

    candidate = f"{base}{suffix}"
    index = 1
    while (target_dir / candidate).exists():
        candidate = f"{base}_{index}{suffix}"
        index += 1
    return candidate


def extract_docx_text(path: Path) -> str:
    try:
        with zipfile.ZipFile(path) as archive:
            xml_bytes = archive.read("word/document.xml")
        root = ET.fromstring(xml_bytes)
        ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
        lines = []
        for para in root.findall(".//w:p", ns):
            parts = [node.text or "" for node in para.findall(".//w:t", ns)]
            line = "".join(parts).strip()
            if line:
                lines.append(line)
        return "\n".join(lines)
    except Exception:
        return ""


def extract_pdf_text(path: Path) -> str:
    tool = shutil.which("pdftotext")
    if not tool:
        return ""
    try:
        result = subprocess.run([tool, str(path), "-"], capture_output=True, text=True, check=False)
        return result.stdout or ""
    except Exception:
        return ""


def extract_text_file(path: Path) -> str:
    for encoding in ["utf-8", "latin-1"]:
        try:
            return path.read_text(encoding=encoding)
        except Exception:
            continue
    return ""


def extract_rtf_text(path: Path) -> str:
    textutil = shutil.which("textutil")
    if textutil:
        try:
            result = subprocess.run(
                [textutil, "-convert", "txt", "-stdout", str(path)],
                capture_output=True,
                text=True,
                check=False,
            )
            if result.stdout:
                return result.stdout
        except Exception:
            pass

    raw = extract_text_file(path)
    if not raw:
        return ""
    return re.sub(r"\\[a-z]+\d* ?|[{}]", " ", raw)


def extract_text(path: Path) -> str:
    ext = path.suffix.lower()
    if ext == ".docx":
        return extract_docx_text(path)
    if ext == ".pdf":
        return extract_pdf_text(path)
    if ext in {".txt", ".md", ".csv"}:
        return extract_text_file(path)
    if ext == ".rtf":
        return extract_rtf_text(path)
    return ""


def count_words(text: str) -> int:
    return len(re.findall(r"\S+", text or ""))


def count_headings(text: str) -> int:
    lines = [line.strip() for line in (text or "").splitlines() if line.strip()]
    if not lines:
        return 0

    heading_count = 0
    for line in lines:
        is_numbered = bool(re.match(r"^(\d+(?:\.\d+)*)\s+\S+", line))
        is_markdown = bool(re.match(r"^#{1,6}\s+", line))
        is_short_upper = (
            len(line) <= 55
            and line.upper() == line
            and len(line.split()) <= 6
            and not re.search(r"[.!?;:]$", line)
        )
        if is_numbered or is_markdown or is_short_upper:
            heading_count += 1
    return heading_count


def compute_structure_score(text: str) -> float:
    clean = (text or "").strip()
    if not clean:
        return 0.0

    lines = [line.strip() for line in clean.splitlines() if line.strip()]
    words = re.findall(r"\S+", clean)
    sentences = re.split(r"[.!?]+", clean)
    sentence_lengths = [len(re.findall(r"\S+", s)) for s in sentences if s.strip()]

    heading_count = count_headings(clean)
    heading_density = min(1.0, heading_count / max(1, len(lines) * 0.18))

    punctuation_ratio = min(1.0, len(re.findall(r"[.!?]", clean)) / max(1, len(words) * 0.06))

    if sentence_lengths:
        avg_len = sum(sentence_lengths) / len(sentence_lengths)
        sentence_balance = 1.0 - min(1.0, abs(avg_len - 18.0) / 18.0)
    else:
        sentence_balance = 0.0

    paragraph_blocks = [p for p in re.split(r"\n\s*\n", clean) if p.strip()]
    paragraph_balance = min(1.0, len(paragraph_blocks) / 8.0)

    score = (
        0.4 * heading_density
        + 0.25 * punctuation_ratio
        + 0.2 * sentence_balance
        + 0.15 * paragraph_balance
    )
    return max(0.0, min(1.0, round(score, 4)))


def resolve_output_kind(output_kind: str, total_words: int, total_headings: int) -> str:
    forced = (output_kind or "auto").strip().lower()
    if forced in {"memo", "report"}:
        return forced
    if total_words >= 2500:
        return "report"
    if total_headings >= 8 and total_words >= 1200:
        return "report"
    return "memo"


def resolve_transformation_mode(structure_score: float) -> str:
    return "style_polish" if structure_score >= 0.7 else "deep_rewrite"


def safe_excerpt(text: str, max_chars: int = 18000) -> str:
    cleaned = re.sub(r"\s+", " ", text or "").strip()
    return cleaned[:max_chars]


def create_ssl_context() -> ssl.SSLContext:
    custom_cafile = (os.getenv("OWALISAATTORI_CA_BUNDLE") or "").strip()
    if custom_cafile:
        return ssl.create_default_context(cafile=custom_cafile)

    try:
        import certifi  # type: ignore
        return ssl.create_default_context(cafile=certifi.where())
    except Exception:
        return ssl.create_default_context()


def call_openai_chat(
    api_key: str,
    model: str,
    system_text: str,
    user_text: str,
    temperature: float = 0.2,
    max_tokens: int = 3200,
) -> tuple[str | None, str | None]:
    if not api_key:
        return None, "OPENAI_API_KEY puuttuu"

    def extract_content_text(content_obj) -> str:
        chunks = []

        def collect(node) -> None:
            if node is None:
                return
            if isinstance(node, str):
                text = node.strip()
                if text:
                    chunks.append(text)
                return
            if isinstance(node, list):
                for item in node:
                    collect(item)
                return
            if isinstance(node, dict):
                # Common OpenAI block formats: {"type":"text","text":"..."} etc.
                text = node.get("text")
                if isinstance(text, str) and text.strip():
                    chunks.append(text.strip())
                nested = node.get("content")
                if nested is not None:
                    collect(nested)
                output_text = node.get("output_text")
                if isinstance(output_text, str) and output_text.strip():
                    chunks.append(output_text.strip())

        collect(content_obj)
        return "\n".join(chunks).strip()

    def build_body(use_completion_tokens: bool) -> dict:
        body = {
            "model": model,
            "temperature": temperature,
            "messages": [
                {"role": "system", "content": system_text},
                {"role": "user", "content": user_text},
            ],
        }
        if use_completion_tokens:
            body["max_completion_tokens"] = max_tokens
        else:
            body["max_tokens"] = max_tokens
        return body

    def send_request(body: dict) -> tuple[str | None, str | None]:
        req = request.Request(
            "https://api.openai.com/v1/chat/completions",
            data=json.dumps(body).encode("utf-8"),
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {api_key}",
            },
            method="POST",
        )

        ssl_context = create_ssl_context()
        with request.urlopen(req, timeout=180, context=ssl_context) as response:
            payload = json.loads(response.read().decode("utf-8"))
            choices = payload.get("choices", [])
            first_choice = choices[0] if isinstance(choices, list) and choices else {}
            message = first_choice.get("message", {}) if isinstance(first_choice, dict) else {}

            content = extract_content_text(message.get("content")) if isinstance(message, dict) else ""
            if not content and isinstance(first_choice, dict):
                # Legacy compatibility fallback
                content = str(first_choice.get("text") or "").strip()
            if not content:
                return None, "OpenAI vastaus oli tyhja"
            return content, None

    try:
        prefer_completion_tokens = model.lower().startswith("gpt-5")
        first_body = build_body(prefer_completion_tokens)
        content, err = send_request(first_body)
        if not err:
            return content, None
        if err == "OpenAI vastaus oli tyhja":
            # Some model responses can be intermittently empty; retry once.
            return send_request(first_body)
        return content, err
    except error.HTTPError as exc:
        detail = ""
        try:
            detail = exc.read().decode("utf-8")
        except Exception:
            detail = str(exc)
        # Retry with the other token parameter if model rejects the first one.
        if "unsupported_parameter" in detail and "max_tokens" in detail:
            try:
                return send_request(build_body(True))
            except Exception:
                pass
        if "unsupported_parameter" in detail and "max_completion_tokens" in detail:
            try:
                return send_request(build_body(False))
            except Exception:
                pass
        return None, f"OpenAI HTTP virhe: {detail[:600]}"
    except ssl.SSLCertVerificationError as exc:
        return (
            None,
            "OpenAI SSL-varmennus epaonnistui. Asenna/paivita certifi: "
            "python3 -m pip install -r scripts/requirements.txt "
            f"(detail: {exc})",
        )
    except Exception as exc:
        text = str(exc)
        if "CERTIFICATE_VERIFY_FAILED" in text:
            return (
                None,
                "OpenAI SSL-varmennus epaonnistui. Asenna/paivita certifi: "
                "python3 -m pip install -r scripts/requirements.txt "
                f"(detail: {text})",
            )
        return None, f"OpenAI virhe: {exc}"


def extract_json_object(raw_text: str) -> dict | None:
    text = (raw_text or "").strip()
    if not text:
        return None

    try:
        parsed = json.loads(text)
        if isinstance(parsed, dict):
            return parsed
    except Exception:
        pass

    match = re.search(r"\{.*\}", text, flags=re.S)
    if not match:
        return None

    candidate = match.group(0)
    try:
        parsed = json.loads(candidate)
        if isinstance(parsed, dict):
            return parsed
    except Exception:
        return None
    return None


def normalize_document_payload(payload: dict, fallback_title: str) -> dict:
    title = str(payload.get("title") or fallback_title).strip() or fallback_title
    summary = str(payload.get("summary") or "").strip()
    sections_raw = payload.get("sections") or []

    sections = []
    if isinstance(sections_raw, list):
        for idx, section in enumerate(sections_raw, start=1):
            if not isinstance(section, dict):
                continue
            section_title = str(section.get("title") or f"Osio {idx}").strip()
            paragraphs_raw = section.get("paragraphs") or []
            paragraphs = []
            if isinstance(paragraphs_raw, list):
                paragraphs = [str(item).strip() for item in paragraphs_raw if str(item).strip()]
            if not paragraphs:
                continue
            sections.append({"title": section_title, "paragraphs": paragraphs})

    return {
        "title": title,
        "summary": summary,
        "sections": sections,
    }


def reshape_document_for_output_kind(payload: dict, output_kind: str) -> dict:
    # Content structure must remain source-driven for both memo and report.
    return normalize_document_payload(payload, fallback_title="Owalisaattori")


def split_sentences(text: str) -> list[str]:
    cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
    if not cleaned:
        return []
    parts = re.split(r"(?<=[.!?])\s+", cleaned)
    return [part.strip() for part in parts if part.strip()]


def uppercase_first_letter(text: str) -> str:
    cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
    if not cleaned:
        return ""
    for idx, ch in enumerate(cleaned):
        if ch.isalpha():
            return f"{cleaned[:idx]}{ch.upper()}{cleaned[idx + 1:]}"
    return cleaned


def normalize_point_rationale_paragraph(text: str) -> str:
    sentences = split_sentences(text)
    if not sentences:
        return ""
    if len(sentences) <= 1:
        return sentences[0]
    # Keep the lead sentence + 4-6 rationale sentences where possible.
    lead = sentences[0]
    rationale = sentences[1:7]
    return " ".join([lead] + rationale).strip()


def enforce_paragraph_logic(payload: dict) -> dict:
    normalized = normalize_document_payload(payload, fallback_title=payload.get("title") or "Owalisaattori")
    for section in normalized.get("sections", []):
        section["paragraphs"] = [
            normalize_point_rationale_paragraph(paragraph)
            for paragraph in section.get("paragraphs", [])
            if str(paragraph or "").strip()
        ]
    return normalized


def ensure_min_paragraphs(paragraphs: list[str], minimum: int = 2) -> list[str]:
    normalized = [
        normalize_point_rationale_paragraph(paragraph)
        for paragraph in paragraphs
        if str(paragraph or "").strip()
    ]
    normalized = [item for item in normalized if item]
    if not normalized:
        return []
    if len(normalized) >= minimum:
        return normalized

    only = normalized[0]
    sentences = split_sentences(only)
    if len(sentences) >= 2:
        midpoint = max(1, len(sentences) // 2)
        first = " ".join(sentences[:midpoint]).strip()
        second = " ".join(sentences[midpoint:]).strip()
        if first and second:
            normalized = [first, second]
        else:
            normalized = [only, only]
    else:
        normalized = [only, only]

    while len(normalized) < minimum:
        normalized.append(normalized[-1])
    return normalized


def is_short_memo_with_short_paragraphs(output_kind: str, sections: list[dict]) -> bool:
    if output_kind != "memo":
        return False
    paragraphs = []
    for section in sections:
        for paragraph in section.get("paragraphs", []):
            text = normalize_point_rationale_paragraph(paragraph)
            if text:
                paragraphs.append(text)
    if not paragraphs:
        return False
    avg_words = sum(count_words(item) for item in paragraphs) / len(paragraphs)
    return len(paragraphs) <= 8 and avg_words <= 28


def fallback_document(
    source_text: str,
    output_kind: str,
    instruction: str,
    transformation_mode: str,
) -> dict:
    paragraphs = [
        p.strip() for p in re.split(r"\n\s*\n", source_text or "") if p.strip()
    ]
    if not paragraphs:
        paragraphs = ["Aineisto ei sisaltanyt analysoitavaa tekstia."]

    target_count = 5 if output_kind == "report" else 3

    chunks = []
    chunk_size = max(1, len(paragraphs) // max(1, target_count))
    cursor = 0
    for idx in range(target_count):
        subset = paragraphs[cursor: cursor + chunk_size]
        if not subset:
            subset = paragraphs[-1:]
        lead = split_sentences(subset[0])[0] if split_sentences(subset[0]) else subset[0]
        title = " ".join(lead.split()[:6]).strip()
        if not title:
            title = f"Osio {idx + 1}"
        chunks.append({"title": title, "paragraphs": subset[:4]})
        cursor += chunk_size

    summary = (
        "Teksti jalostettiin fallback-tilassa. "
        f"Moodi: {transformation_mode}. "
        f"Kayttajaohje huomioitu: {'kylla' if instruction.strip() else 'ei'}"
    )

    return {
        "title": "Owalisaattori tulos",
        "summary": summary,
        "sections": chunks,
    }


def build_draft_messages(
    source_assets: list[dict],
    output_kind: str,
    transformation_mode: str,
    instruction: str,
    language: str,
) -> tuple[str, str]:
    source_block = []
    for asset in source_assets:
        source_block.append(
            {
                "name": asset.get("name"),
                "word_count": asset.get("word_count"),
                "heading_count": asset.get("heading_count"),
                "excerpt": safe_excerpt(asset.get("extracted_text", ""), 5500),
            }
        )

    mode_guidance = (
        "For both memo and report: build sectioning entirely from source material logic. "
        "Do not inject fixed heading sets or template-driven headings. "
        "Output kind controls only abstraction depth and length, not section labels."
    )
    system_text = (
        "You are an expert editorial analyst. "
        "Return strictly valid JSON with keys: title, summary, sections. "
        "sections must be an array of objects: {title, paragraphs}. "
        "Each section must contain 2-5 paragraphs. "
        "Paragraph rule: each paragraph starts with one lead-point sentence "
        "followed by 4-6 rationale sentences. "
        "Tone requirements: short, direct, clear, no jargon. "
        "Do not let any external document template shape content structure. "
        f"{mode_guidance}"
    )

    user_payload = {
        "task": "Create a structured draft document from raw source materials.",
        "language": language,
        "output_kind": output_kind,
        "transformation_mode": transformation_mode,
        "instruction": instruction or "",
        "output_constraints": {
            "memo_target_length": "compact",
            "report_target_length": "extended",
            "lead_plus_rationale": "1 + 4-6 sentences",
        },
        "sources": source_block,
    }

    user_text = json.dumps(user_payload, ensure_ascii=False)
    return system_text, user_text


def build_polish_messages(
    draft_payload: dict,
    output_kind: str,
    transformation_mode: str,
    instruction: str,
    language: str,
) -> tuple[str, str]:
    mode_guidance = (
        "Keep existing content structure source-driven. "
        "Do not add fixed memo/report heading sets. "
        "Only improve style, clarity, and paragraph internal logic."
    )
    system_text = (
        "You are a brand editor for Owal Group. "
        "Rewrite only style and clarity, preserve section structure. "
        "Return strictly valid JSON with same shape: title, summary, sections[]. "
        "Paragraph rule: each paragraph starts with one lead-point sentence "
        "followed by 4-6 rationale sentences. "
        "Use concise, direct language and avoid consultant jargon. "
        f"{mode_guidance}"
    )

    user_payload = {
        "task": "Polish draft content to match brand tone of voice.",
        "language": language,
        "output_kind": output_kind,
        "transformation_mode": transformation_mode,
        "instruction": instruction or "",
        "brand_rules": [
            "Be short and clear",
            "Stick to one main point per paragraph",
            "Use direct language",
            "Avoid slang and jargon",
        ],
        "draft": draft_payload,
    }
    return system_text, json.dumps(user_payload, ensure_ascii=False)


def create_generation_payload(
    api_key: str,
    draft_model: str,
    polish_model: str,
    source_assets: list[dict],
    output_kind: str,
    transformation_mode: str,
    instruction: str,
    language: str,
) -> tuple[dict, list[str]]:
    warnings = []

    draft_system, draft_user = build_draft_messages(
        source_assets=source_assets,
        output_kind=output_kind,
        transformation_mode=transformation_mode,
        instruction=instruction,
        language=language,
    )

    draft_raw, draft_err = call_openai_chat(
        api_key=api_key,
        model=draft_model,
        system_text=draft_system,
        user_text=draft_user,
        temperature=0.2,
        max_tokens=3600,
    )

    draft_json = extract_json_object(draft_raw or "") if draft_raw else None
    if draft_err or not draft_json:
        if draft_err:
            warnings.append(f"Draft-vaiheessa virhe: {draft_err}")
        else:
            warnings.append("Draft-vaiheen JSON jouduttiin korvaamaan fallbackilla")
        draft_json = fallback_document(
            source_text="\n\n".join(asset.get("extracted_text", "") for asset in source_assets),
            output_kind=output_kind,
            instruction=instruction,
            transformation_mode=transformation_mode,
        )

    normalized_draft = normalize_document_payload(draft_json, fallback_title="Owalisaattori")
    if not normalized_draft.get("sections"):
        warnings.append("Draft-vaihe tuotti tyhjan rakenteen, fallback aktivoitiin")
        normalized_draft = fallback_document(
            source_text="\n\n".join(asset.get("extracted_text", "") for asset in source_assets),
            output_kind=output_kind,
            instruction=instruction,
            transformation_mode=transformation_mode,
        )
    normalized_draft = reshape_document_for_output_kind(normalized_draft, output_kind)
    normalized_draft = enforce_paragraph_logic(normalized_draft)

    polish_system, polish_user = build_polish_messages(
        draft_payload=normalized_draft,
        output_kind=output_kind,
        transformation_mode=transformation_mode,
        instruction=instruction,
        language=language,
    )

    polish_max_tokens = 6200 if output_kind == "report" else 4200
    polish_raw, polish_err = call_openai_chat(
        api_key=api_key,
        model=polish_model,
        system_text=polish_system,
        user_text=polish_user,
        temperature=0.15,
        max_tokens=polish_max_tokens,
    )

    # Retry once with a stricter/shorter ask if the first polish reply is empty.
    if polish_err == "OpenAI vastaus oli tyhja":
        retry_payload = {
            "task": "Polish style only, preserve structure exactly.",
            "language": language,
            "output_kind": output_kind,
            "instruction": instruction or "",
            "must_return": "strict_json_only",
            "shape": {"title": "string", "summary": "string", "sections": [{"title": "string", "paragraphs": ["string"]}]},
            "draft": normalized_draft,
        }
        retry_user = json.dumps(retry_payload, ensure_ascii=False)
        polish_raw, polish_err = call_openai_chat(
            api_key=api_key,
            model=polish_model,
            system_text=(
                "Return strictly valid JSON only. "
                "Do not add markdown. Preserve section structure and paragraph count."
            ),
            user_text=retry_user,
            temperature=0.1,
            max_tokens=polish_max_tokens,
        )

    polish_json = extract_json_object(polish_raw or "") if polish_raw else None
    if polish_err or not polish_json:
        if polish_err and polish_err != "OpenAI vastaus oli tyhja":
            warnings.append(f"Polish-vaiheessa virhe: {polish_err}")
        elif not polish_json and polish_raw:
            warnings.append("Polish-vaiheen JSON jouduttiin korvaamaan draft-versiolla")
        polished = normalized_draft
    else:
        polished = normalize_document_payload(polish_json, fallback_title=normalized_draft.get("title", "Owalisaattori"))
        if not polished.get("sections"):
            warnings.append("Polish-vaihe tuotti tyhjan rakenteen, draft-versio palautettiin")
            polished = normalized_draft
    polished = reshape_document_for_output_kind(polished, output_kind)
    polished = enforce_paragraph_logic(polished)

    return polished, warnings


def get_style_name(doc, candidates: list[str], fallback: str) -> str:
    available = {style.name for style in doc.styles}
    for candidate in candidates:
        if candidate in available:
            return candidate
    return fallback if fallback in available else "Normal"


def clear_document_body(doc, qn_fn) -> None:
    body = doc._element.body
    for child in list(body):
        if child.tag != qn_fn("w:sectPr"):
            body.remove(child)


def add_toc_field(paragraph, OxmlElement_cls, qn_fn) -> None:
    run_begin = paragraph.add_run()
    fld_char_begin = OxmlElement_cls("w:fldChar")
    fld_char_begin.set(qn_fn("w:fldCharType"), "begin")

    instr_text = OxmlElement_cls("w:instrText")
    instr_text.set(qn_fn("xml:space"), "preserve")
    instr_text.text = 'TOC \\o "1-3" \\h \\z \\u'

    fld_char_separate = OxmlElement_cls("w:fldChar")
    fld_char_separate.set(qn_fn("w:fldCharType"), "separate")

    fld_char_end = OxmlElement_cls("w:fldChar")
    fld_char_end.set(qn_fn("w:fldCharType"), "end")

    run_begin._r.append(fld_char_begin)
    run_begin._r.append(instr_text)
    run_begin._r.append(fld_char_separate)

    paragraph.add_run("Paivita sisallysluettelo Wordissa (F9).")

    run_end = paragraph.add_run()
    run_end._r.append(fld_char_end)


def generate_docx(
    run_dir: Path,
    payload: dict,
    output_kind: str,
    source_assets: list[dict],
) -> tuple[Path | None, str | None]:
    try:
        from docx import Document
        from docx.shared import Pt, Inches, RGBColor
    except Exception:
        return (
            None,
            "python-docx puuttuu. Asenna riippuvuudet: "
            "python3 -m pip install -r scripts/requirements.txt",
        )

    # Use a neutral minimal document layout for every mode.
    # Templates are intentionally not used to avoid structural steering.
    try:
        document = Document()
    except Exception as exc:
        return None, f"DOCX-alustus epaonnistui: {exc}"

    for section in document.sections:
        try:
            # Keep a clearer gap between header (logo) and body text on every page.
            section.top_margin = Inches(1.6)
            section.header_distance = Inches(0.6)
        except Exception:
            pass

    body_font = "Mark Offc Pro"
    point_font = "Mark Offc Pro Medium"
    style_body = "Normal"
    document.styles[style_body].font.name = body_font
    document.styles[style_body].font.size = Pt(10)

    title = payload.get("title") or "Owalisaattori"
    summary = payload.get("summary") or ""

    logo_path = RESOURCES_DIR / "logos" / "OwalGroup-Logo-Red.png"
    if logo_path.exists() and document.sections:
        try:
            header = document.sections[0].header
            header_p = header.paragraphs[0] if header.paragraphs else header.add_paragraph()
            header_p.paragraph_format.space_after = Pt(18)
            header_run = header_p.add_run()
            header_run.add_picture(str(logo_path), width=Inches(1.5))
        except Exception:
            pass

    p_title = document.add_paragraph(str(title).upper())
    p_title.style = style_body
    p_title.paragraph_format.line_spacing = 1.2
    if p_title.runs:
        p_title_run = p_title.runs[0]
        p_title_run.font.name = "Mark Offc Pro Bold"
        p_title_run.bold = True
        p_title_run.font.size = Pt(13)
        p_title_run.font.color.rgb = RGBColor(0xFF, 0x66, 0x33)

    if summary:
        p_summary = document.add_paragraph(normalize_point_rationale_paragraph(summary))
        p_summary.style = style_body
        p_summary.paragraph_format.line_spacing = 1.2

    sections = payload.get("sections", [])
    disable_lead_emphasis = is_short_memo_with_short_paragraphs(output_kind, sections)

    for section in sections:
        heading_text = uppercase_first_letter(section.get("title") or "Osio")
        p_h = document.add_paragraph(heading_text)
        p_h.style = style_body
        p_h.paragraph_format.line_spacing = 1.2
        if p_h.runs:
            p_h_run = p_h.runs[0]
            p_h_run.font.name = "Mark Offc Pro Medium"
            p_h_run.font.size = Pt(11)

        section_paragraphs = ensure_min_paragraphs(section.get("paragraphs", []), minimum=2)
        for paragraph in section_paragraphs:
            text = normalize_point_rationale_paragraph(paragraph)
            p = document.add_paragraph()
            p.style = style_body
            p.paragraph_format.line_spacing = 1.2
            sentences = split_sentences(text)
            if not sentences:
                continue
            if disable_lead_emphasis:
                run_text = p.add_run(" ".join(sentences))
                run_text.font.name = body_font
                run_text.font.size = Pt(10)
            else:
                lead = sentences[0]
                rest = " ".join(sentences[1:]).strip()
                run_lead = p.add_run(lead)
                run_lead.font.name = point_font
                run_lead.font.size = Pt(10)
                if rest:
                    run_rest = p.add_run(f" {rest}")
                    run_rest.font.name = body_font
                    run_rest.font.size = Pt(10)

    out_name = f"owalisaattori_{output_kind}_{now_stamp()}.docx"
    out_path = run_dir / out_name
    try:
        document.save(str(out_path))
    except Exception as exc:
        return None, f"DOCX tallennus epaonnistui: {exc}"

    return out_path, None


def parse_multipart(headers, raw_body: bytes) -> tuple[dict, list[dict]]:
    content_type = headers.get("content-type", "")
    if "multipart/form-data" not in content_type:
        return {}, []

    message = BytesParser(policy=default).parsebytes(
        b"Content-Type: " + content_type.encode("utf-8") + b"\r\n\r\n" + raw_body
    )

    fields = {}
    files = []

    for part in message.iter_parts():
        disposition = part.get("Content-Disposition", "")
        if not disposition:
            continue

        name = part.get_param("name", header="content-disposition")
        filename = part.get_filename()
        payload = part.get_payload(decode=True) or b""

        if filename:
            files.append(
                {
                    "field_name": name or "files",
                    "name": filename,
                    "content_type": part.get_content_type(),
                    "data": payload,
                }
            )
        elif name:
            fields[name] = payload.decode("utf-8", errors="ignore")

    return fields, files


def build_input_assets(saved_files: list[dict]) -> list[dict]:
    assets = []
    for idx, item in enumerate(saved_files, start=1):
        path = Path(item["path"])
        text = extract_text(path)
        words = count_words(text)
        heading_count = count_headings(text)

        mime, _ = mimetypes.guess_type(path.name)
        assets.append(
            {
                "id": f"A{idx}",
                "name": path.name,
                "path": str(path),
                "mime": mime or "application/octet-stream",
                "extracted_text": text,
                "word_count": words,
                "heading_count": heading_count,
            }
        )
    return assets


def save_run_manifest(run_dir: Path, payload: dict) -> None:
    manifest_path = run_dir / "run_manifest.json"
    with manifest_path.open("w", encoding="utf-8") as handle:
        json.dump(payload, handle, ensure_ascii=False, indent=2)


class Handler(BaseHTTPRequestHandler):
    def _set_headers(self, status=200):
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type, X-OpenAI-Key")
        self.end_headers()

    def do_OPTIONS(self):
        self._set_headers(204)

    def do_POST(self):
        if self.path == "/generate-document":
            self.handle_generate_document()
            return
        if self.path == "/health":
            self.handle_health()
            return
        if self.path == "/delete-run-data":
            self.handle_delete_run_data()
            return

        self._set_headers(404)
        self.wfile.write(json.dumps({"ok": False, "error": "not_found"}).encode("utf-8"))

    def handle_generate_document(self):
        length = int(self.headers.get("content-length", "0"))
        raw_body = self.rfile.read(length)
        fields, files = parse_multipart(self.headers, raw_body)

        if not files:
            self._set_headers(400)
            self.wfile.write(json.dumps({"ok": False, "error": "files_required"}).encode("utf-8"))
            return

        api_key = (self.headers.get("X-OpenAI-Key") or "").strip()
        if not api_key:
            self._set_headers(400)
            self.wfile.write(json.dumps({"ok": False, "error": "missing_api_key"}).encode("utf-8"))
            return

        instruction = (fields.get("instruction") or "").strip()
        output_kind_input = (fields.get("output_kind") or "auto").strip().lower()
        draft_model = (fields.get("draft_model") or "gpt-4.1").strip() or "gpt-4.1"
        polish_model = (fields.get("polish_model") or "gpt-5.2").strip() or "gpt-5.2"
        language = (fields.get("language") or "fi").strip() or "fi"

        run_id = f"run_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}"
        run_dir = RUNS_DIR / run_id
        inputs_dir = run_dir / "inputs"
        inputs_dir.mkdir(parents=True, exist_ok=True)

        saved_files = []
        warnings = []
        for file_item in files:
            original_name = file_item.get("name") or "file"
            ext = Path(original_name).suffix.lower()
            if ext not in SUPPORTED_EXTENSIONS:
                warnings.append(f"Ohitettu tiedosto (ei tuettu): {original_name}")
                continue

            target_name = unique_file_name(inputs_dir, original_name)
            target_path = inputs_dir / target_name
            with target_path.open("wb") as handle:
                handle.write(file_item.get("data") or b"")

            saved_files.append({
                "name": target_name,
                "path": str(target_path),
            })

        if not saved_files:
            self._set_headers(400)
            self.wfile.write(json.dumps({"ok": False, "error": "no_supported_files", "warnings": warnings}).encode("utf-8"))
            return

        assets = build_input_assets(saved_files)
        total_words = sum(item.get("word_count", 0) for item in assets)
        total_headings = sum(item.get("heading_count", 0) for item in assets)
        merged_text = "\n\n".join(item.get("extracted_text", "") for item in assets)

        structure_score = compute_structure_score(merged_text)
        if output_kind_input in {"memo", "report"}:
            resolved_output_kind = output_kind_input
        else:
            resolved_output_kind = resolve_output_kind(output_kind_input, total_words, total_headings)
        transformation_mode = resolve_transformation_mode(structure_score)

        doc_payload, generation_warnings = create_generation_payload(
            api_key=api_key,
            draft_model=draft_model,
            polish_model=polish_model,
            source_assets=assets,
            output_kind=resolved_output_kind,
            transformation_mode=transformation_mode,
            instruction=instruction,
            language=language,
        )
        warnings.extend(generation_warnings)

        docx_path, docx_error = generate_docx(
            run_dir=run_dir,
            payload=doc_payload,
            output_kind=resolved_output_kind,
            source_assets=assets,
        )

        if docx_error or not docx_path:
            self._set_headers(500)
            self.wfile.write(json.dumps({"ok": False, "error": docx_error or "docx_generation_failed", "warnings": warnings}).encode("utf-8"))
            return

        manifest = {
            "run_id": run_id,
            "created_at": datetime.datetime.now().isoformat(),
            "input": {
                "instruction": instruction,
                "output_kind_requested": output_kind_input,
                "draft_model": draft_model,
                "polish_model": polish_model,
                "language": language,
                "asset_count": len(assets),
                "total_words": total_words,
                "total_headings": total_headings,
                "structure_score": structure_score,
            },
            "result": {
                "resolved_output_kind": resolved_output_kind,
                "transformation_mode": transformation_mode,
                "summary": doc_payload.get("summary", ""),
                "docx_path": str(docx_path),
            },
            "warnings": warnings,
        }
        save_run_manifest(run_dir, manifest)

        response = {
            "ok": True,
            "run_id": run_id,
            "output_kind_requested": output_kind_input,
            "resolved_output_kind": resolved_output_kind,
            "transformation_mode": transformation_mode,
            "summary": doc_payload.get("summary", ""),
            "sections": doc_payload.get("sections", []),
            "docx_path": str(docx_path),
            "template_used": "minimal_brand_layout_v1",
            "default_file_name": docx_path.name,
            "warnings": warnings,
        }

        self._set_headers(200)
        self.wfile.write(json.dumps(response, ensure_ascii=False).encode("utf-8"))

    def handle_health(self):
        api_key = (self.headers.get("X-OpenAI-Key") or "").strip()
        if not api_key:
            self._set_headers(200)
            self.wfile.write(json.dumps({"ok": False, "error": "missing_api_key"}).encode("utf-8"))
            return

        text, err = call_openai_chat(
            api_key=api_key,
            model="gpt-4.1",
            system_text="Answer with single token: ok",
            user_text="ok",
            temperature=0,
            max_tokens=5,
        )
        if err:
            self._set_headers(200)
            self.wfile.write(json.dumps({"ok": False, "error": err}).encode("utf-8"))
            return

        self._set_headers(200)
        self.wfile.write(json.dumps({"ok": True, "source": "openai", "reply": (text or "")[:20]}).encode("utf-8"))

    def handle_delete_run_data(self):
        length = int(self.headers.get("content-length", "0"))
        raw = self.rfile.read(length) if length else b"{}"

        try:
            payload = json.loads(raw.decode("utf-8") or "{}")
        except Exception:
            payload = {}

        run_id = str(payload.get("run_id") or "").strip()
        if not re.match(r"^run_[0-9_]+$", run_id):
            self._set_headers(400)
            self.wfile.write(json.dumps({"ok": False, "error": "invalid_run_id"}).encode("utf-8"))
            return

        run_dir = RUNS_DIR / run_id
        if not run_dir.exists():
            self._set_headers(200)
            self.wfile.write(json.dumps({"ok": True, "deleted": False}).encode("utf-8"))
            return

        try:
            shutil.rmtree(run_dir)
        except Exception as exc:
            self._set_headers(500)
            self.wfile.write(json.dumps({"ok": False, "error": str(exc)}).encode("utf-8"))
            return

        self._set_headers(200)
        self.wfile.write(json.dumps({"ok": True, "deleted": True}).encode("utf-8"))


def main():
    port = int(os.getenv("OWALISAATTORI_PORT") or "5006")
    server = HTTPServer(("0.0.0.0", port), Handler)
    print(f"Owalisaattori API running on http://127.0.0.1:{port}")
    server.serve_forever()


if __name__ == "__main__":
    main()
