from __future__ import annotations

import datetime
import importlib.util
import json
import os
import re
import zipfile
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET


class DesktopGenerationEngine:
    _INVALID_XML_CHARS_RE = re.compile(
        "["  # XML 1.0 forbidden chars
        "\x00-\x08"
        "\x0B-\x0C"
        "\x0E-\x1F"
        "\uD800-\uDFFF"
        "\uFFFE-\uFFFF"
        "]"
    )
    _MARKDOWN_HEADING_RE = re.compile(r"^\s*(#{1,6})\s+\S")
    _NUMBERED_HEADING_RE = re.compile(r"^\s*(\d+(?:\.\d+){0,4})(?:[.)])?\s+\S")
    _BULLET_RE = re.compile(r"^\s*(?:[-*•]|[0-9]+[.)])\s+\S")
    _PAGE_NUMBER_LINE_RE = re.compile(
        r"^\s*(?:sivu|page)\s*\d{1,4}(?:\s*(?:/|of)\s*\d{1,4})?\s*$",
        flags=re.IGNORECASE,
    )
    _PAGE_FRACTION_LINE_RE = re.compile(r"^\s*\d{1,4}\s*/\s*\d{1,4}\s*$")
    _ORPHAN_BULLET_LINE_RE = re.compile(r"^\s*(?:[-*•–—]+|[0-9]{1,2}[.)])\s*$")
    _SEPARATOR_LINE_RE = re.compile(r"^\s*[-_=~•·.]{3,}\s*$")
    _SUPPORTED_TRANSLATION_LANGUAGES = {"fi", "en", "sv"}
    _TRANSLATION_LANGUAGE_NAMES = {
        "fi": "Finnish",
        "en": "English",
        "sv": "Swedish",
    }
    _LANGUAGE_TOKEN_RE = re.compile(r"[A-Za-zÅÄÖåäö]+", flags=re.UNICODE)
    _LANGUAGE_STOPWORDS = {
        "fi": {
            "ja",
            "on",
            "että",
            "ei",
            "sekä",
            "myös",
            "kun",
            "jos",
            "tai",
            "oli",
            "ovat",
            "kuin",
            "voi",
            "voidaan",
            "tämä",
            "nämä",
            "joka",
            "jotka",
            "mutta",
            "kuten",
        },
        "en": {
            "the",
            "and",
            "is",
            "are",
            "to",
            "of",
            "in",
            "for",
            "with",
            "that",
            "this",
            "these",
            "those",
            "not",
            "as",
            "be",
            "or",
            "from",
            "by",
            "it",
        },
        "sv": {
            "och",
            "är",
            "att",
            "det",
            "som",
            "för",
            "med",
            "inte",
            "till",
            "en",
            "ett",
            "på",
            "av",
            "har",
            "den",
            "de",
            "vi",
            "ni",
            "kan",
            "också",
        },
    }

    def __init__(self, repo_root: Path, runs_dir: Path):
        self.repo_root = Path(repo_root).resolve()
        self.runs_dir = Path(runs_dir).resolve()
        self.runs_dir.mkdir(parents=True, exist_ok=True)
        self._api_module = None

    def _load_module(self):
        if self._api_module is not None:
            return self._api_module

        scripts_path = self.repo_root / "scripts" / "owalisaattori_api.py"
        if not scripts_path.exists():
            raise RuntimeError(f"Desktop API script missing: {scripts_path}")

        os.environ.setdefault("OWALISAATTORI_DATA_DIR", str((self.repo_root / "data").resolve()))
        os.environ.setdefault("OWALISAATTORI_RESOURCES_DIR", str(self.repo_root))
        os.environ.setdefault("OWALISAATTORI_MEMO_TEMPLATE_PATH", str((self.repo_root / "Muistion_tyylimaarittelyt.docx").resolve()))
        os.environ.setdefault("OWALISAATTORI_REPORT_TEMPLATE_PATH", str((self.repo_root / "Laajan_raportin_tyylimaarittely.docx").resolve()))

        spec = importlib.util.spec_from_file_location("owalisaattori_api_shared", str(scripts_path))
        if not spec or not spec.loader:
            raise RuntimeError("Failed to load desktop API module")

        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)
        self._api_module = module
        return module

    @staticmethod
    def _now_stamp() -> str:
        return datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")

    @staticmethod
    def _normalize_output_kind(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

    @classmethod
    def _is_style_only_kind(cls, value: str) -> bool:
        return cls._normalize_output_kind(value) == "stylized"

    def _create_run_dir(self) -> tuple[str, Path]:
        run_id = f"run_{self._now_stamp()}"
        run_dir = self.runs_dir / run_id
        run_dir.mkdir(parents=True, exist_ok=True)
        (run_dir / "inputs").mkdir(parents=True, exist_ok=True)
        return run_id, run_dir

    @staticmethod
    def _normalize_newlines(text: str) -> str:
        return str(text or "").replace("\r\n", "\n").replace("\r", "\n")

    @staticmethod
    def _strip_invalid_xml_chars(text: str) -> str:
        return DesktopGenerationEngine._INVALID_XML_CHARS_RE.sub("", str(text or ""))

    @staticmethod
    def _split_stylized_lines(text: str) -> list[str]:
        cleaned = DesktopGenerationEngine._strip_invalid_xml_chars(text)
        lines = DesktopGenerationEngine._normalize_newlines(cleaned).split("\n")
        return DesktopGenerationEngine._clean_stylized_lines(lines)

    @staticmethod
    def _canonical_line_key(value: str) -> str:
        return re.sub(r"\s+", " ", str(value or "").strip().lower())

    @staticmethod
    def _looks_like_header_footer_marker(value: str) -> bool:
        line = str(value or "").strip()
        if not line:
            return False
        lowered = line.lower()
        if any(
            token in lowered
            for token in (
                "confidential",
                "luottamuksellinen",
                "luonnos",
                "draft",
                "copyright",
                "all rights reserved",
            )
        ):
            return True
        if len(line) <= 60 and re.search(r"\d", line) and re.search(r"[-_/|]", line):
            word_count = len(re.findall(r"\w+", line, flags=re.UNICODE))
            if word_count <= 8:
                return True
        return False

    @staticmethod
    def _clean_stylized_lines(lines: list[str]) -> list[str]:
        normalized_lines = [
            re.sub(r"[ \t]+", " ", DesktopGenerationEngine._strip_invalid_xml_chars(line)).strip()
            for line in (lines or [])
        ]

        non_empty = [line for line in normalized_lines if line]
        if not non_empty:
            return []

        counts: dict[str, int] = {}
        for line in non_empty:
            key = DesktopGenerationEngine._canonical_line_key(line)
            counts[key] = counts.get(key, 0) + 1

        fraction_line_count = sum(
            1 for line in non_empty if DesktopGenerationEngine._PAGE_FRACTION_LINE_RE.match(line)
        )
        named_page_line_count = sum(
            1 for line in non_empty if DesktopGenerationEngine._PAGE_NUMBER_LINE_RE.match(line)
        )

        filtered: list[str] = []
        for line in normalized_lines:
            if not line:
                filtered.append("")
                continue

            if DesktopGenerationEngine._PAGE_NUMBER_LINE_RE.match(line):
                continue
            if DesktopGenerationEngine._PAGE_FRACTION_LINE_RE.match(line) and (
                fraction_line_count >= 2 or named_page_line_count >= 1
            ):
                continue
            if DesktopGenerationEngine._ORPHAN_BULLET_LINE_RE.match(line):
                continue
            if DesktopGenerationEngine._SEPARATOR_LINE_RE.match(line):
                continue

            key = DesktopGenerationEngine._canonical_line_key(line)
            repeats = counts.get(key, 0)
            if repeats >= 3 and DesktopGenerationEngine._looks_like_header_footer_marker(line):
                continue

            filtered.append(line)

        collapsed: list[str] = []
        previous_blank = True
        for line in filtered:
            is_blank = not line
            if is_blank and previous_blank:
                continue
            collapsed.append(line)
            previous_blank = is_blank

        while collapsed and not collapsed[0]:
            collapsed.pop(0)
        while collapsed and not collapsed[-1]:
            collapsed.pop()
        return collapsed

    @staticmethod
    def _is_heading_context(lines: list[str], index: int) -> bool:
        prev_blank = index == 0 or not str(lines[index - 1] or "").strip()
        next_blank = index == len(lines) - 1 or not str(lines[index + 1] or "").strip()
        return prev_blank or next_blank

    @staticmethod
    def _guess_heading_level(lines: list[str], index: int) -> int | None:
        raw_line = str(lines[index] or "")
        line = DesktopGenerationEngine._strip_invalid_xml_chars(raw_line).strip()
        if not line:
            return None

        markdown_match = DesktopGenerationEngine._MARKDOWN_HEADING_RE.match(line)
        if markdown_match:
            return min(3, len(markdown_match.group(1)))

        context_ok = DesktopGenerationEngine._is_heading_context(lines, index)
        if not context_ok:
            return None

        numbered_match = DesktopGenerationEngine._NUMBERED_HEADING_RE.match(line)
        is_bullet = DesktopGenerationEngine._BULLET_RE.match(line) is not None
        if is_bullet and not numbered_match:
            return None

        words = re.findall(r"\w+", line, flags=re.UNICODE)
        word_count = len(words)
        if word_count == 0 or word_count > 12 or len(line) > 110:
            return None

        if re.search(r"[.!?,]\s*$", line):
            return None

        if numbered_match:
            depth = numbered_match.group(1).count(".") + 1
            # Keep top-level numbered headings visually one level below H1.
            return 2 if depth <= 1 else 3

        letters = re.findall(r"[A-Za-zÅÄÖåäö]", line)
        if not letters:
            return None

        is_all_caps = line == line.upper() and word_count <= 8
        if index == 0:
            return 1
        if is_all_caps:
            return 2

        if not line[0].isupper():
            return None

        return 2 if word_count <= 8 else 3

    @staticmethod
    def _ensure_paragraph_style(
        document: Any,
        style_name: str,
        *,
        base_style_name: str,
        font_name: str,
        size_pt: float,
        bold: bool,
        line_spacing: float,
        space_before_pt: float,
        space_after_pt: float,
    ) -> Any:
        from docx.enum.style import WD_STYLE_TYPE
        from docx.shared import Pt

        try:
            style = document.styles[style_name]
        except KeyError:
            style = document.styles.add_style(style_name, WD_STYLE_TYPE.PARAGRAPH)

        try:
            style.base_style = document.styles[base_style_name]
        except Exception:
            pass

        style.font.name = font_name
        style.font.size = Pt(size_pt)
        style.font.bold = bool(bold)
        paragraph_format = style.paragraph_format
        paragraph_format.line_spacing = line_spacing
        paragraph_format.space_before = Pt(space_before_pt)
        paragraph_format.space_after = Pt(space_after_pt)
        return style

    @staticmethod
    def _extract_pptx_text(path: Path) -> str:
        # Extract visible slide text in original order for style-only conversion.
        try:
            with zipfile.ZipFile(path) as archive:
                slide_names = sorted(
                    [
                        name
                        for name in archive.namelist()
                        if name.startswith("ppt/slides/slide") and name.endswith(".xml")
                    ],
                    key=lambda name: int(re.search(r"slide(\d+)\.xml$", name).group(1)) if re.search(r"slide(\d+)\.xml$", name) else 0,
                )
                if not slide_names:
                    return ""

                ns = {
                    "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
                    "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
                }
                slides: list[str] = []
                for slide_name in slide_names:
                    xml_bytes = archive.read(slide_name)
                    root = ET.fromstring(xml_bytes)
                    paragraph_lines: list[str] = []
                    for paragraph in root.findall(".//a:p", ns):
                        parts = [node.text or "" for node in paragraph.findall(".//a:t", ns)]
                        if not parts:
                            continue
                        paragraph_lines.append("".join(parts))
                    if paragraph_lines:
                        slides.append("\n".join(paragraph_lines))
                return "\n\n".join(slides)
        except Exception:
            return ""

    @staticmethod
    def _extract_docx_text_deep(path: Path) -> str:
        # Fallback extractor for DOCX files where shared extract_text() returns empty.
        # Reads multiple wordprocessing parts (document/header/footer/notes/comments).
        try:
            with zipfile.ZipFile(path) as archive:
                all_parts = [
                    name
                    for name in archive.namelist()
                    if name.startswith("word/") and name.endswith(".xml")
                ]
                if not all_parts:
                    return ""

                primary = ["word/document.xml"]
                secondary_prefixes = ("word/header", "word/footer", "word/footnotes", "word/endnotes", "word/comments")
                secondary = sorted(
                    [name for name in all_parts if name != "word/document.xml" and name.startswith(secondary_prefixes)]
                )
                rest = sorted([name for name in all_parts if name not in set(primary + secondary)])
                ordered_parts = [name for name in (primary + secondary + rest) if name in all_parts]

                ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
                lines: list[str] = []
                for part_name in ordered_parts:
                    try:
                        xml_bytes = archive.read(part_name)
                        root = ET.fromstring(xml_bytes)
                    except Exception:
                        continue

                    for para in root.findall(".//w:p", ns):
                        text_nodes = [node.text or "" for node in para.findall(".//w:t", ns)]
                        if not text_nodes:
                            # Field codes can carry visible content in some documents.
                            text_nodes = [node.text or "" for node in para.findall(".//w:instrText", ns)]
                        line = "".join(text_nodes).strip()
                        if line:
                            lines.append(line)

                return "\n".join(lines)
        except Exception:
            return ""

    @staticmethod
    def _extract_pdf_text_fallback(path: Path) -> str:
        # Python-level fallback for environments where `pdftotext` is unavailable.
        try:
            from pypdf import PdfReader  # type: ignore
        except Exception:
            return ""

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

        page_texts: list[str] = []
        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)

    @classmethod
    def _extract_text_best_effort(cls, path: Path, ext: str, module: Any) -> str:
        if ext == ".pptx":
            text = cls._extract_pptx_text(path)
        elif ext == ".docx":
            text = str(module.extract_text(path) or "")
            if not text.strip():
                text = cls._extract_docx_text_deep(path)
        elif ext == ".pdf":
            text = str(module.extract_text(path) or "")
            if not text.strip():
                text = cls._extract_pdf_text_fallback(path)
        else:
            text = str(module.extract_text(path) or "")

        text = cls._normalize_newlines(text)
        text = cls._strip_invalid_xml_chars(text)
        return text

    @classmethod
    def _build_stylized_assets(cls, saved_files: list[dict[str, str]], module: Any) -> list[dict[str, Any]]:
        assets: list[dict[str, Any]] = []
        for item in saved_files:
            file_name = str(item.get("name") or "file")
            path = Path(str(item.get("path") or ""))
            ext = path.suffix.lower()

            text = cls._extract_text_best_effort(path=path, ext=ext, module=module)
            assets.append(
                {
                    "file_name": file_name,
                    "path": str(path),
                    "ext": ext,
                    "extracted_text": text,
                    "word_count": module.count_words(text),
                    "heading_count": module.count_headings(text),
                }
            )
        return assets

    @staticmethod
    def _repair_empty_assets(assets: list[dict[str, Any]], module: Any) -> int:
        repaired = 0
        for asset in assets:
            current_text = str(asset.get("extracted_text") or "")
            if current_text.strip():
                continue

            path = Path(str(asset.get("path") or ""))
            ext = path.suffix.lower()
            fallback = DesktopGenerationEngine._extract_text_best_effort(path=path, ext=ext, module=module)
            if not fallback.strip():
                continue

            asset["extracted_text"] = fallback
            asset["word_count"] = module.count_words(fallback)
            asset["heading_count"] = module.count_headings(fallback)
            repaired += 1
        return repaired

    @classmethod
    def _translation_language_name(cls, language: str) -> str:
        code = str(language or "").strip().lower()
        return cls._TRANSLATION_LANGUAGE_NAMES.get(code, "Finnish")

    @classmethod
    def _guess_language_code(cls, text: str) -> str | None:
        sample = cls._normalize_newlines(text)
        tokens = [token.lower() for token in cls._LANGUAGE_TOKEN_RE.findall(sample)]
        if not tokens:
            return None
        if len(tokens) > 600:
            tokens = tokens[:600]

        scores = {lang: 0 for lang in cls._LANGUAGE_STOPWORDS}
        token_set = set(tokens)

        for language, stopwords in cls._LANGUAGE_STOPWORDS.items():
            scores[language] += sum(1 for token in tokens if token in stopwords)

        sample_lower = " ".join(tokens)
        if "å" in sample_lower:
            scores["sv"] += 2
        if {"the", "and", "of", "to", "in"} & token_set:
            scores["en"] += 1
        if {"ja", "että", "sekä", "myös", "kun"} & token_set:
            scores["fi"] += 1
        if {"och", "att", "är", "inte", "för"} & token_set:
            scores["sv"] += 1

        ranked = sorted(scores.items(), key=lambda item: item[1], reverse=True)
        best_language, best_score = ranked[0]
        second_score = ranked[1][1] if len(ranked) > 1 else 0
        if best_score < 2:
            return None
        if best_score - second_score < 1:
            return None
        return best_language

    @classmethod
    def _should_translate_source_text(cls, *, source_text: str, target_language: str) -> bool:
        code = str(target_language or "").strip().lower()
        if code not in cls._SUPPORTED_TRANSLATION_LANGUAGES:
            return False

        detected = cls._guess_language_code(source_text)
        if detected:
            return detected != code

        # If source language is unclear, translate only when user explicitly targets non-default language.
        return code in {"en", "sv"}

    @classmethod
    def _translate_line_batch(
        cls,
        *,
        lines: list[str],
        target_language: str,
        api_key: str,
        model: str,
        module: Any,
    ) -> tuple[list[str] | None, str | None]:
        if not lines:
            return [], None

        target_name = cls._translation_language_name(target_language)
        system_text = (
            "You are a strict translator for business documents. "
            f"Translate each line to {target_name}. "
            "If a line is already in target language, copy it character-for-character unchanged. "
            "Do not rewrite, summarize, or expand content. "
            "Preserve line order and line count exactly. "
            "Keep bullets, numbering, and proper nouns."
        )
        user_payload = {
            "task": "Translate lines with 1:1 structure preservation.",
            "target_language": target_language,
            "output_format": {"translated_lines": ["..."]},
            "lines": lines,
        }
        user_text = json.dumps(user_payload, ensure_ascii=False)
        try:
            raw, err = module.call_openai_chat(
                api_key=api_key,
                model=model,
                system_text=system_text,
                user_text=user_text,
                temperature=0.0,
                max_tokens=4200,
            )
        except Exception as exc:
            return None, f"translation_call_exception: {exc}"
        if err:
            return None, err

        try:
            parsed = module.extract_json_object(raw or "")
        except Exception as exc:
            return None, f"translation_json_parse_exception: {exc}"
        translated = parsed.get("translated_lines") if isinstance(parsed, dict) else None
        if not isinstance(translated, list):
            return None, "invalid_translation_payload"

        normalized = [
            cls._strip_invalid_xml_chars(cls._normalize_newlines(str(item or ""))).replace("\n", " ").strip()
            for item in translated
        ]
        if len(normalized) != len(lines):
            return None, "translation_line_count_mismatch"
        return normalized, None

    @classmethod
    def _translate_stylized_assets(
        cls,
        *,
        source_assets: list[dict[str, Any]],
        target_language: str,
        api_key: str,
        model: str,
        module: Any,
    ) -> tuple[list[dict[str, Any]], list[str], bool]:
        warnings: list[str] = []
        code = str(target_language or "fi").strip().lower() or "fi"
        translation_performed = False
        missing_key_warned = False
        if code not in cls._SUPPORTED_TRANSLATION_LANGUAGES:
            warnings.append(f"Tuntematon kohdekieli '{target_language}', kaytetaan suomea.")
            code = "fi"

        translated_assets: list[dict[str, Any]] = []
        for asset in source_assets:
            cloned = dict(asset)
            source_text = str(asset.get("extracted_text") or "")
            needs_translation = cls._should_translate_source_text(
                source_text=source_text,
                target_language=code,
            )
            if not needs_translation:
                translated_assets.append(cloned)
                continue
            if not api_key:
                if not missing_key_warned:
                    warnings.append(
                        "Kaannosta ei voitu tehdä: OpenAI API-avain puuttuu. Sisalto pidettiin lahdekielella."
                    )
                    missing_key_warned = True
                translated_assets.append(cloned)
                continue
            lines = cls._split_stylized_lines(source_text)
            if not lines:
                translated_assets.append(cloned)
                continue

            translated_lines: list[str] = []
            batch_size = 140
            has_error = False
            for start in range(0, len(lines), batch_size):
                batch = lines[start : start + batch_size]
                try:
                    translated_batch, err = cls._translate_line_batch(
                        lines=batch,
                        target_language=code,
                        api_key=api_key,
                        model=model,
                        module=module,
                    )
                except Exception as exc:
                    translated_batch, err = None, f"translation_batch_exception: {exc}"
                if err or translated_batch is None:
                    warnings.append(
                        f"Kaannos epaonnistui tiedostolle {asset.get('file_name') or asset.get('name') or 'tiedosto'} "
                        f"(syy: {err or 'unknown'}). Kaytetaan lahdetekstia."
                    )
                    has_error = True
                    break
                translated_lines.extend(translated_batch)

            if has_error or len(translated_lines) != len(lines):
                translated_text = "\n".join(lines)
            else:
                translated_text = "\n".join(translated_lines)

            translated_text = cls._strip_invalid_xml_chars(cls._normalize_newlines(translated_text))
            if translated_text.strip() != cls._normalize_newlines(source_text).strip():
                translation_performed = True
            cloned["extracted_text"] = translated_text
            cloned["word_count"] = module.count_words(translated_text)
            cloned["heading_count"] = module.count_headings(translated_text)
            translated_assets.append(cloned)

        return translated_assets, warnings, translation_performed

    @staticmethod
    def _unique_file_name(target_dir: Path, file_name: str, allowed_extensions: set[str]) -> str:
        base = "".join(ch for ch in str(Path(file_name).stem or "file") if ch.isalnum() or ch in "._- ").strip().replace(" ", "_")
        base = base or "file"
        suffix = Path(file_name).suffix.lower()
        if suffix not in allowed_extensions:
            suffix = ""

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

    def _generate_stylized_docx(self, run_dir: Path, source_assets: list[dict]) -> tuple[Path | None, str | None]:
        try:
            from docx import Document
            from docx.shared import Inches, Pt, RGBColor
        except Exception:
            return (
                None,
                "python-docx missing. Install dependencies: python3 -m pip install -r scripts/requirements.txt",
            )

        try:
            document = Document()
        except Exception as exc:
            return None, f"DOCX init failed: {exc}"

        for section in document.sections:
            try:
                section.top_margin = Inches(1.6)
                section.header_distance = Inches(0.6)
            except Exception:
                pass

        body_style = document.styles["Normal"]
        body_style.font.name = "Mark Offc Pro"
        body_style.font.size = Pt(10)
        body_style.font.bold = False

        logo_path = self.repo_root / "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

        any_text = False
        wrote_asset = False
        for asset in source_assets:
            lines = self._split_stylized_lines(asset.get("extracted_text", ""))
            if not lines or not "".join(lines).strip():
                continue

            if wrote_asset:
                document.add_page_break()

            first_text_line_index = next((idx for idx, value in enumerate(lines) if str(value or "").strip()), None)
            for index, line in enumerate(lines):
                if first_text_line_index is not None and index == first_text_line_index:
                    heading_level = 1
                else:
                    heading_level = self._guess_heading_level(lines, index)
                p = document.add_paragraph(line)
                p.style = body_style
                p.paragraph_format.line_spacing = 1.2
                if p.runs:
                    run = p.runs[0]
                    if heading_level == 1:
                        run.font.name = "Mark Offc Pro Bold"
                        run.bold = True
                        run.font.size = Pt(13)
                        run.font.color.rgb = RGBColor(0xFF, 0x66, 0x33)
                    elif heading_level:
                        run.font.name = "Mark Offc Pro Medium"
                        run.font.size = Pt(11)
                    else:
                        run.font.name = "Mark Offc Pro"
                        run.font.size = Pt(10)
                any_text = True

            wrote_asset = True

        if not any_text:
            p = document.add_paragraph("Aineistosta ei loytynyt tekstisisaltoa.")
            p.style = body_style
            p.paragraph_format.line_spacing = 1.2
            for run in p.runs:
                run.font.name = "Mark Offc Pro"
                run.font.size = Pt(10)

        out_name = f"owalisaattori_stylized_{self._now_stamp()}.docx"
        out_path = run_dir / out_name
        try:
            document.save(str(out_path))
        except Exception as exc:
            return None, f"DOCX save failed: {exc}"

        return out_path, None

    def generate_document(
        self,
        *,
        files: list[dict],
        instruction: str,
        output_kind: str,
        draft_model: str,
        polish_model: str,
        language: str,
        api_key: str,
    ) -> dict[str, Any]:
        module = self._load_module()
        run_id, run_dir = self._create_run_dir()
        inputs_dir = run_dir / "inputs"

        warnings: list[str] = []
        saved_files: list[dict[str, str]] = []

        requested_kind = self._normalize_output_kind(output_kind)
        style_only_mode = self._is_style_only_kind(requested_kind)
        style_plus_mode = requested_kind == "stylized_plus"
        target_language = str(language or "fi").strip().lower() or "fi"
        supported_extensions = set(getattr(module, "SUPPORTED_EXTENSIONS", {".docx", ".pdf", ".txt", ".md", ".rtf", ".csv"}))
        if style_only_mode or style_plus_mode:
            supported_extensions = supported_extensions | {".pptx"}

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

            if (style_only_mode or style_plus_mode) and ext == ".pptx":
                target_name = self._unique_file_name(inputs_dir, original_name, supported_extensions)
            else:
                target_name = module.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("bytes") or b"")

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

        if not saved_files:
            raise ValueError("no_supported_files")

        if style_only_mode or style_plus_mode:
            assets = self._build_stylized_assets(saved_files, module)
            if style_only_mode and target_language in {"en", "sv"}:
                warnings.append(
                    "Stilisoitu (1:1) sailyttaa lahdekielen. "
                    "Kielivalinnan mukainen kaannos on kaytossa Stilisoitu-tilassa."
                )
        else:
            assets = module.build_input_assets(saved_files)
            self._repair_empty_assets(assets, module)

        empty_asset_names = [str(item.get("name") or item.get("file_name") or "tiedosto") for item in assets if not str(item.get("extracted_text") or "").strip()]
        if empty_asset_names:
            warnings.append(
                "Tekstipoiminta epaonnistui osalle tiedostoista: "
                + ", ".join(empty_asset_names[:5])
                + ("..." if len(empty_asset_names) > 5 else "")
            )
        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 = module.compute_structure_score(merged_text)
        if style_only_mode or style_plus_mode:
            render_assets = assets
            resolved_output_kind = "stylized"
            transformation_mode = "style_only"
            summary_text = (
                "Stilisoitu DOCX luotu ilman sisallollista uudelleenkirjoitusta. "
                "Varovainen puhdistus poisti ilmeisia artefakteja (esim. sivunumero- ja footer/header-roskaa). "
                "Otsikot tunnistettu automaattisesti."
            )

            if style_plus_mode:
                translated_assets, translation_warnings, translation_performed = self._translate_stylized_assets(
                    source_assets=assets,
                    target_language=target_language,
                    api_key=api_key,
                    model=(polish_model or "gpt-5.2").strip() or "gpt-5.2",
                    module=module,
                )
                warnings.extend(translation_warnings)
                render_assets = translated_assets
                resolved_output_kind = "stylized_plus"
                if translation_performed:
                    transformation_mode = "style_only_translate"
                    summary_text = (
                        "Stilisoitu DOCX luotu 1:1-tyyliputkella kohdekieleen kaantaen. "
                        "Sisalto pyritaan pitamaan mahdollisimman lahella lahdetta; "
                        "vain kieli ja typografinen muoto ovat ensisijaisia muutoksia."
                    )
                else:
                    transformation_mode = "style_only"
                    summary_text = (
                        "Stilisoitu DOCX luotu ilman sisallollista uudelleenkirjoitusta. "
                        "Kielivalinnalle ei tarvittu kaannosta, joten tulos vastaa 1:1-stilisointia."
                    )

            docx_path, docx_error = self._generate_stylized_docx(run_dir=run_dir, source_assets=render_assets)
            if docx_error or not docx_path:
                raise RuntimeError(str(docx_error or "docx_generation_failed"))
            manifest = {
                "run_id": run_id,
                "created_at": datetime.datetime.now().isoformat(),
                "input": {
                    "instruction": instruction or "",
                    "output_kind_requested": requested_kind,
                    "draft_model": draft_model or "gpt-4.1",
                    "polish_model": polish_model or "gpt-5.2",
                    "language": language or "fi",
                    "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": summary_text,
                    "docx_path": str(docx_path),
                },
                "warnings": warnings,
            }
            with (run_dir / "run_manifest.json").open("w", encoding="utf-8") as handle:
                json.dump(manifest, handle, ensure_ascii=False, indent=2)

            return {
                "ok": True,
                "run_id": run_id,
                "output_kind_requested": requested_kind,
                "resolved_output_kind": resolved_output_kind,
                "transformation_mode": transformation_mode,
                "summary": summary_text,
                "sections": [],
                "docx_path": str(docx_path),
                "default_file_name": Path(docx_path).name,
                "template_used": "owalgroup_typography_style_only_v1",
                "warnings": warnings,
                "run_dir": str(run_dir),
            }

        if requested_kind in {"memo", "report"}:
            resolved_output_kind = requested_kind
        else:
            resolved_output_kind = module.resolve_output_kind(
                requested_kind,
                total_words,
                total_headings,
                source_text=merged_text,
            )
        transformation_mode = module.resolve_transformation_mode(structure_score)

        payload, payload_warnings = module.create_generation_payload(
            api_key=api_key,
            draft_model=(draft_model or "gpt-4.1").strip() or "gpt-4.1",
            polish_model=(polish_model or "gpt-5.2").strip() or "gpt-5.2",
            source_assets=assets,
            output_kind=resolved_output_kind,
            transformation_mode=transformation_mode,
            instruction=instruction or "",
            language=(language or "fi").strip() or "fi",
        )
        warnings.extend(payload_warnings or [])

        docx_path, docx_error = module.generate_docx(
            run_dir=run_dir,
            payload=payload,
            output_kind=resolved_output_kind,
            source_assets=assets,
        )
        if docx_error or not docx_path:
            raise RuntimeError(str(docx_error or "docx_generation_failed"))

        manifest = {
            "run_id": run_id,
            "created_at": datetime.datetime.now().isoformat(),
            "input": {
                "instruction": instruction or "",
                "output_kind_requested": requested_kind,
                "draft_model": draft_model or "gpt-4.1",
                "polish_model": polish_model or "gpt-5.2",
                "language": language or "fi",
                "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": payload.get("summary", ""),
                "docx_path": str(docx_path),
            },
            "warnings": warnings,
        }
        with (run_dir / "run_manifest.json").open("w", encoding="utf-8") as handle:
            json.dump(manifest, handle, ensure_ascii=False, indent=2)

        return {
            "ok": True,
            "run_id": run_id,
            "output_kind_requested": requested_kind,
            "resolved_output_kind": resolved_output_kind,
            "transformation_mode": transformation_mode,
            "summary": payload.get("summary", ""),
            "sections": payload.get("sections", []),
            "docx_path": str(docx_path),
            "default_file_name": Path(docx_path).name,
            "template_used": "minimal_brand_layout_v1",
            "warnings": warnings,
            "run_dir": str(run_dir),
        }
