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

from desktop_generation_engine import DesktopGenerationEngine

_SCRIPT_BASE_DIR = Path(__file__).resolve().parents[1]
BASE_DIR = Path(os.getenv("OWALISAATTORI_RESOURCES_DIR") or _SCRIPT_BASE_DIR).resolve()
RESOURCES_DIR = 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"}
SUPPORTED_LANGUAGE_CODES = {"fi", "en", "sv"}
STYLE_ONLY_OUTPUT_KINDS = {"stylized", "stylized_plus"}
LANGUAGE_NAMES = {
    "fi": "Finnish",
    "en": "English",
    "sv": "Swedish",
}
GENERATION_ENGINE = DesktopGenerationEngine(repo_root=BASE_DIR, runs_dir=RUNS_DIR)


def normalize_language_code(language: str) -> str:
    code = str(language or "fi").strip().lower()
    if code in SUPPORTED_LANGUAGE_CODES:
        return code
    return "fi"


def normalize_output_kind_input(value: str) -> str:
    normalized = str(value or "auto").strip().lower() or "auto"
    collapsed = re.sub(r"\s+", " ", normalized)

    if collapsed in {"stylized", "stylized_1to1", "stylized-1to1"}:
        return "stylized"
    if collapsed in {"stilisoitu", "stilisoitu (1:1)", "stilisoitu 1:1", "stilisoi (1:1)", "stilisoi 1:1"}:
        return "stylized"
    if collapsed in {"stylized_plus", "stylized-plus", "stylized plus"}:
        return "stylized_plus"
    if collapsed in {
        "stilisoitu+",
        "stilisoitu plus",
        "stilisoitu (kaannos)",
        "stilisoitu (käännös)",
        "stilisoi",
        "stilisointi",
        "stilisoi+",
        "stilisoi plus",
    }:
        return "stylized_plus"
    return collapsed


def is_style_only_output_kind(value: str) -> bool:
    return normalize_output_kind_input(value) in STYLE_ONLY_OUTPUT_KINDS


def language_output_rule(language: str) -> str:
    code = normalize_language_code(language)
    language_name = LANGUAGE_NAMES.get(code, "Finnish")
    return (
        f"Target output language is {language_name} (code: {code}). "
        "If source material language differs, translate all generated content to the target language. "
        "Title, summary, section titles, and paragraph text must be in the target language. "
        "Do not produce mixed-language sentences. "
        "Keep proper nouns and official names unchanged unless a standard translated form exists."
    )


def language_fallback_strings(language: str) -> dict:
    code = normalize_language_code(language)
    if code == "en":
        return {
            "title": "Owalisaattori result",
            "section_prefix": "Section",
            "instruction_yes": "yes",
            "instruction_no": "no",
            "empty_source_text": "Source did not contain analyzable text.",
            "missing_detail_text": "The source material does not provide enough detail for this point.",
            "summary_template": (
                "Text was processed in fallback mode. "
                "Mode: {mode}. "
                "User instruction applied: {instruction_applied}."
            ),
        }
    if code == "sv":
        return {
            "title": "Owalisaattori resultat",
            "section_prefix": "Avsnitt",
            "instruction_yes": "ja",
            "instruction_no": "nej",
            "empty_source_text": "Källmaterialet innehöll ingen analyserbar text.",
            "missing_detail_text": "Källmaterialet innehåller inte tillräckligt med detaljer för denna punkt.",
            "summary_template": (
                "Texten bearbetades i fallback-läge. "
                "Läge: {mode}. "
                "Användarens instruktion beaktad: {instruction_applied}."
            ),
        }
    return {
        "title": "Owalisaattori tulos",
        "section_prefix": "Osio",
        "instruction_yes": "kyllä",
        "instruction_no": "ei",
        "empty_source_text": "Aineisto ei sisältänyt analysoitavaa tekstiä.",
        "missing_detail_text": "Aineisto ei sisällä riittävästi yksityiskohtaista tietoa tämän kohdan tueksi.",
        "summary_template": (
            "Teksti jalostettiin fallback-tilassa. "
            "Moodi: {mode}. "
            "Käyttäjäohje huomioitu: {instruction_applied}."
        ),
    }


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_pdf_text_fallback(path: Path) -> str:
    try:
        from pypdf import PdfReader  # type: ignore
    except Exception:
        return ""

    try:
        reader = PdfReader(str(path))
    except Exception:
        return ""

    page_texts = []
    for page in getattr(reader, "pages", []):
        try:
            text = str(page.extract_text() or "").strip()
        except Exception:
            text = ""
        if text:
            page_texts.append(text)
    return "\n\n".join(page_texts)


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":
        text = extract_pdf_text(path)
        if text.strip():
            return text
        return extract_pdf_text_fallback(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, source_text: str = "") -> str:
    forced = (output_kind or "auto").strip().lower()
    if forced in {"memo", "report", "stylized_plus"}:
        return forced

    source_lc = (source_text or "").lower()
    report_signal = bool(
        re.search(
            r"\b(raportti|tutkimusraportti|report|analysis report|executive report)\b",
            source_lc,
        )
    )
    if total_words >= 2500:
        return "report"
    if total_headings >= 8 and total_words >= 1200:
        return "report"
    if report_signal and total_words >= 700:
        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 epäonnistui. Asenna/päivitä 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 epäonnistui. Asenna/päivitä 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,
    language: str = "fi",
) -> dict:
    strings = language_fallback_strings(language)
    paragraphs = [
        p.strip() for p in re.split(r"\n\s*\n", source_text or "") if p.strip()
    ]
    if not paragraphs:
        paragraphs = [strings["empty_source_text"]]

    if output_kind == "report":
        target_count = 5
    elif output_kind == "stylized_plus":
        target_count = 4
    else:
        target_count = 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"{strings['section_prefix']} {idx + 1}"
        chunks.append({"title": title, "paragraphs": subset[:4]})
        cursor += chunk_size

    instruction_applied = strings["instruction_yes"] if instruction.strip() else strings["instruction_no"]
    summary = strings["summary_template"].format(
        mode=transformation_mode,
        instruction_applied=instruction_applied,
    )

    return {
        "title": strings["title"],
        "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]:
    target_language = normalize_language_code(language)
    language_rule = language_output_rule(target_language)
    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", ""), 9000),
            }
        )

    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."
    )
    stylized_plus_rule = ""
    if output_kind == "stylized_plus":
        stylized_plus_rule = (
            "This is stylized_plus mode: allow translation to target language and light editorial cleanup. "
            "Preserve all key facts and source intent. "
            "Keep section order source-driven, but strengthen clarity and heading hierarchy in Owal Group style. "
            "Do not invent new claims, metrics, or conclusions."
        )
    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. "
        "Grounding rule: use only facts, names, numbers, dates, and claims that are explicitly present in source excerpts. "
        "Never invent metrics, timelines, organizations, persons, quotes, findings, or recommendations. "
        "If source does not support a detail, write a neutral limitation sentence instead of guessing. "
        "Tone requirements: short, direct, clear, no jargon. "
        "Do not let any external document template shape content structure. "
        f"{mode_guidance} "
        f"{stylized_plus_rule} "
        f"{language_rule}"
    )

    user_payload = {
        "task": "Create a structured draft document from raw source materials.",
        "language": target_language,
        "translation_policy": "Translate to target language when source language differs.",
        "output_kind": output_kind,
        "transformation_mode": transformation_mode,
        "instruction": instruction or "",
        "output_constraints": {
            "memo_target_length": "compact",
            "report_target_length": "extended",
            "stylized_plus_target_length": "compact_to_medium",
            "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]:
    target_language = normalize_language_code(language)
    language_rule = language_output_rule(target_language)
    mode_guidance = (
        "Keep existing content structure source-driven. "
        "Do not add fixed memo/report heading sets. "
        "Only improve style, clarity, and paragraph internal logic."
    )
    stylized_plus_rule = ""
    if output_kind == "stylized_plus":
        stylized_plus_rule = (
            "Apply a slightly stronger Owal Group style polish than memo/report mode. "
            "You may tighten wording and unify terminology, but must preserve meaning. "
            "Keep paragraph and section-level fidelity to the source-driven draft."
        )
    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. "
        "Do not introduce any new factual content that is not in the draft. "
        "If a sentence would require guessing, replace it with a cautious limitation statement. "
        "Use concise, direct language and avoid consultant jargon. "
        f"{mode_guidance} "
        f"{stylized_plus_rule} "
        f"{language_rule}"
    )

    user_payload = {
        "task": "Polish draft content to match brand tone of voice.",
        "language": target_language,
        "translation_policy": "Translate to target language when source language differs.",
        "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 build_grounding_messages(
    source_assets: list[dict],
    candidate_payload: dict,
    output_kind: str,
    language: str,
) -> tuple[str, str]:
    target_language = normalize_language_code(language)
    strings = language_fallback_strings(target_language)
    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", ""), 9000),
            }
        )

    system_text = (
        "You are a strict factual grounding verifier. "
        "Return strictly valid JSON with same shape: title, summary, sections[]. "
        "Verify every factual statement against provided source excerpts. "
        "Never invent facts, numbers, entities, causality, quotes, or conclusions. "
        "If a statement is unsupported, rewrite that sentence to a neutral limitation statement. "
        "Preserve section order and paragraph count. "
        "Keep wording concise and businesslike."
    )

    user_payload = {
        "task": "Ground candidate document strictly to source material.",
        "language": target_language,
        "output_kind": output_kind,
        "unsupported_statement_rewrite": strings.get("missing_detail_text"),
        "candidate": candidate_payload,
        "sources": source_block,
    }
    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 = []
    strict_source_required = output_kind in {"memo", "report"}
    source_text = "\n\n".join(asset.get("extracted_text", "") for asset in source_assets)
    source_word_count = sum(int(asset.get("word_count", 0) or 0) for asset in source_assets)
    target_language = normalize_language_code(language)
    original_language = str(language or "").strip().lower()
    if original_language and original_language != target_language:
        warnings.append(
            f"Tuntematon kieli '{original_language}', käytetään '{target_language}'."
        )
    if strict_source_required and source_word_count < 120:
        raise RuntimeError(
            "Aineistosta poimittiin liian vähän tekstiä memo/report-generointiin. "
            "Tarkista tiedoston tekstisisältö tai kokeile stilisointitilaa."
        )

    draft_system, draft_user = build_draft_messages(
        source_assets=source_assets,
        output_kind=output_kind,
        transformation_mode=transformation_mode,
        instruction=instruction,
        language=target_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.0,
        max_tokens=3600,
    )

    draft_json = extract_json_object(draft_raw or "") if draft_raw else None
    if draft_err or not draft_json:
        if strict_source_required:
            reason = draft_err or "Draft-vaiheen JSON-rakenne puuttuu."
            raise RuntimeError(
                "Draft-vaihe epäonnistui, fallback estetty memo/report-tilassa. "
                f"Syy: {reason}"
            )
        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=source_text,
            output_kind=output_kind,
            instruction=instruction,
            transformation_mode=transformation_mode,
            language=target_language,
        )

    normalized_draft = normalize_document_payload(draft_json, fallback_title="Owalisaattori")
    if not normalized_draft.get("sections"):
        if strict_source_required:
            raise RuntimeError(
                "Draft-vaihe tuotti tyhjän rakenteen, fallback estetty memo/report-tilassa."
            )
        warnings.append("Draft-vaihe tuotti tyhjän rakenteen, fallback aktivoitiin")
        normalized_draft = fallback_document(
            source_text=source_text,
            output_kind=output_kind,
            instruction=instruction,
            transformation_mode=transformation_mode,
            language=target_language,
        )
    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=target_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.0,
        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": target_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 tyhjän rakenteen, draft-versio palautettiin")
            polished = normalized_draft
    polished = reshape_document_for_output_kind(polished, output_kind)
    polished = enforce_paragraph_logic(polished)

    if strict_source_required:
        grounding_system, grounding_user = build_grounding_messages(
            source_assets=source_assets,
            candidate_payload=polished,
            output_kind=output_kind,
            language=target_language,
        )
        grounding_raw, grounding_err = call_openai_chat(
            api_key=api_key,
            model=draft_model,
            system_text=grounding_system,
            user_text=grounding_user,
            temperature=0.0,
            max_tokens=polish_max_tokens,
        )
        grounding_json = extract_json_object(grounding_raw or "") if grounding_raw else None
        if grounding_err or not grounding_json:
            reason = grounding_err or "Grounding-vaiheen JSON-rakenne puuttuu."
            raise RuntimeError(
                "Grounding-vaihe epäonnistui memo/report-tilassa. "
                f"Syy: {reason}"
            )
        grounded = normalize_document_payload(
            grounding_json,
            fallback_title=polished.get("title", "Owalisaattori"),
        )
        if not grounded.get("sections"):
            raise RuntimeError("Grounding-vaihe tuotti tyhjän rakenteen memo/report-tilassa.")
        polished = reshape_document_for_output_kind(grounded, 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("Päivitä sisällysluettelo 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 epäonnistui: {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 epäonnistui: {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

        instruction = (fields.get("instruction") or "").strip()
        output_kind_input = normalize_output_kind_input(fields.get("output_kind") or "auto")
        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"
        api_key = (self.headers.get("X-OpenAI-Key") or "").strip()
        style_only_mode = is_style_only_output_kind(output_kind_input)
        if not style_only_mode and not api_key:
            self._set_headers(400)
            self.wfile.write(json.dumps({"ok": False, "error": "missing_api_key"}).encode("utf-8"))
            return

        prepared_files = []
        for file_item in files:
            prepared_files.append(
                {
                    "name": file_item.get("name") or "file",
                    "bytes": file_item.get("data") or b"",
                }
            )

        try:
            result = GENERATION_ENGINE.generate_document(
                files=prepared_files,
                instruction=instruction,
                output_kind=output_kind_input,
                draft_model=draft_model,
                polish_model=polish_model,
                language=language,
                api_key=api_key,
            )
        except ValueError as exc:
            if str(exc) == "no_supported_files":
                self._set_headers(400)
                self.wfile.write(json.dumps({"ok": False, "error": "no_supported_files"}).encode("utf-8"))
                return
            self._set_headers(400)
            self.wfile.write(json.dumps({"ok": False, "error": str(exc)}).encode("utf-8"))
            return
        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(result, 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()
