import base64
import html
import json
import math
import os
import re
import subprocess
import sys
import unicodedata
import urllib.error
import urllib.request
from collections import Counter
from pathlib import Path

import streamlit as st
from docx import Document
from openai import OpenAI
from pypdf import PdfReader


BASE_DIR = Path(__file__).resolve().parent
DOCS_PATH = BASE_DIR / "documents"
BG_PATH = BASE_DIR / "liva_core_bg.png"

SUPPORTED_EXTENSIONS = {".pdf", ".docx", ".txt"}
TRIAL_DOCUMENT_LIMIT = 10
OLLAMA_URL = "http://127.0.0.1:11434"
DEFAULT_MODEL = "qwen2.5:3b"
FALLBACK_MODEL = "mistral"

st.set_page_config(page_title="LIVA CORE Trial", layout="wide")


def img_to_base64(path: Path) -> str:
    if not path.exists():
        return ""
    return base64.b64encode(path.read_bytes()).decode("ascii")


def safe_box_text(text: str) -> str:
    text = text.replace("```", "").replace("`", "")
    text = html.escape(text)
    return text.replace("\n", "<br>")


def show_box(text: str, height: int = 360) -> None:
    safe = safe_box_text(text)
    st.markdown(
        f"""
        <div class="answer-box" style="max-height: {height}px;">
        {safe}
        </div>
        """,
        unsafe_allow_html=True,
    )


def open_local_path(path: Path | str) -> None:
    target = str(path)
    if os.name == "nt":
        os.startfile(target)  # type: ignore[attr-defined]
    elif sys.platform == "darwin":
        subprocess.Popen(["open", target])
    else:
        subprocess.Popen(["xdg-open", target])


bg = img_to_base64(BG_PATH)

st.markdown(
    f"""
<style>
.stApp {{
    background-image:
        linear-gradient(rgba(5, 8, 14, 0.46), rgba(5, 8, 14, 0.72)),
        url("data:image/png;base64,{bg}");
    background-size: cover;
    background-position: center;
    background-attachment: fixed;
}}

.block-container {{
    padding-top: 2.2rem;
    padding-bottom: 3rem;
    max-width: 1120px;
}}

section[data-testid="stSidebar"] {{
    background-color: rgba(8, 11, 18, 0.96);
    border-right: 1px solid rgba(70, 200, 255, 0.16);
}}

.main-panel {{
    background: rgba(5, 9, 18, 0.72);
    border: 1px solid rgba(120, 210, 255, 0.20);
    border-radius: 14px;
    padding: 22px;
    box-shadow: 0 22px 70px rgba(0, 0, 0, 0.34);
    backdrop-filter: blur(18px);
}}

.hero-title {{
    font-size: 3.1rem;
    line-height: 1.02;
    font-weight: 800;
    margin: 0 0 0.35rem;
}}

.hero-subtitle {{
    color: #b8c7dc !important;
    font-size: 1.05rem;
    margin-bottom: 1rem;
}}

.status-grid {{
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: 12px;
    margin: 18px 0 20px;
}}

.status-card {{
    background: linear-gradient(180deg, rgba(14, 24, 42, 0.88), rgba(8, 13, 24, 0.78));
    border: 1px solid rgba(120, 210, 255, 0.22);
    border-radius: 12px;
    padding: 14px 15px;
    min-height: 94px;
}}

.status-card span {{
    display: block;
    color: #8da3bc !important;
    font-size: 0.78rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    margin-bottom: 7px;
}}

.status-card strong {{
    color: #f5f8ff !important;
    font-size: 1.15rem;
    line-height: 1.25;
}}

.status-ok {{
    border-color: rgba(92, 242, 176, 0.36);
}}

.status-warn {{
    border-color: rgba(255, 204, 92, 0.38);
}}

.answer-box,
.source-card,
.notice-box {{
    background: rgba(5, 8, 14, 0.88);
    border: 1px solid rgba(120, 210, 255, 0.22);
    border-radius: 12px;
    padding: 18px;
    color: white !important;
    line-height: 1.6;
    overflow-y: auto;
}}

.source-card {{
    margin: 10px 0;
}}

.source-meta {{
    color: #9fb3ca !important;
    font-size: 0.92rem;
    margin-top: 4px;
}}

.notice-box {{
    border-color: rgba(255, 204, 92, 0.38);
    background: rgba(76, 58, 12, 0.62);
    margin: 12px 0;
}}

.question-panel {{
    margin-top: 12px;
    padding-top: 4px;
}}

.stTextInput input, .stTextArea textarea {{
    background-color: rgba(12, 16, 26, 0.94) !important;
    color: white !important;
    border: 1px solid rgba(70, 200, 255, 0.70) !important;
    border-radius: 10px !important;
    font-size: 17px !important;
}}

.stButton button {{
    background: linear-gradient(90deg, #0f6bff, #24d4ff);
    color: white !important;
    border: 1px solid rgba(180, 235, 255, 0.50);
    border-radius: 10px;
    font-size: 16px !important;
    padding: 9px 18px;
    box-shadow: 0 12px 28px rgba(36, 212, 255, 0.18);
}}

h1, h2, h3, p, label, div, span, li {{
    color: white !important;
}}

@media (max-width: 800px) {{
    .status-grid {{
        grid-template-columns: repeat(2, minmax(0, 1fr));
    }}
    .hero-title {{
        font-size: 2.25rem;
    }}
}}
</style>
""",
    unsafe_allow_html=True,
)


def normalize_text(text: str) -> str:
    text = text.lower()
    text = unicodedata.normalize("NFKD", text)
    text = "".join(ch for ch in text if not unicodedata.combining(ch))
    return text


def tokenize(text: str) -> list[str]:
    normalized = normalize_text(text)
    return [token for token in re.findall(r"[\w]+", normalized, flags=re.UNICODE) if len(token) > 1]


def discover_documents() -> list[Path]:
    DOCS_PATH.mkdir(parents=True, exist_ok=True)
    return [
        path
        for path in sorted(DOCS_PATH.rglob("*"))
        if path.is_file() and path.suffix.lower() in SUPPORTED_EXTENSIONS
    ]


def read_pdf(path: Path) -> str:
    text_parts = []
    reader = PdfReader(str(path))
    for page in reader.pages:
        text_parts.append(page.extract_text() or "")
    return "\n".join(text_parts)


def read_docx(path: Path) -> str:
    doc = Document(str(path))
    paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
    table_text = []
    for table in doc.tables:
        for row in table.rows:
            table_text.append(" | ".join(cell.text for cell in row.cells))
    return "\n".join(paragraphs + table_text)


def read_txt(path: Path) -> str:
    for encoding in ("utf-8-sig", "utf-8", "cp1252", "latin-1"):
        try:
            return path.read_text(encoding=encoding)
        except UnicodeDecodeError:
            continue
    return path.read_text(encoding="utf-8", errors="ignore")


def read_document(path: Path) -> str:
    suffix = path.suffix.lower()
    if suffix == ".pdf":
        return read_pdf(path)
    if suffix == ".docx":
        return read_docx(path)
    if suffix == ".txt":
        return read_txt(path)
    return ""


def chunk_text(text: str, size: int = 1100, overlap: int = 180) -> list[str]:
    text = re.sub(r"\s+", " ", text).strip()
    if not text:
        return []
    chunks = []
    start = 0
    while start < len(text):
        end = min(start + size, len(text))
        chunks.append(text[start:end].strip())
        if end >= len(text):
            break
        start = max(0, end - overlap)
    return chunks


def load_documents() -> tuple[list[dict], list[dict], int]:
    documents = discover_documents()
    entries = []
    errors = []

    for path in documents[:TRIAL_DOCUMENT_LIMIT]:
        try:
            text = read_document(path)
        except Exception as exc:
            errors.append({"source": path.name, "path": str(path), "message": f"Die Datei konnte nicht gelesen werden: {exc}"})
            continue

        chunks = chunk_text(text)
        if not chunks:
            errors.append({"source": path.name, "path": str(path), "message": "Kein lesbarer Text gefunden."})
            continue

        for index, chunk in enumerate(chunks):
            entries.append(
                {
                    "source": path.name,
                    "path": str(path),
                    "chunk": chunk,
                    "chunk_index": index,
                    "tokens": tokenize(chunk),
                }
            )

    for path in documents[TRIAL_DOCUMENT_LIMIT:]:
        errors.append(
            {
                "source": path.name,
                "path": str(path),
                "message": f"Trial-Limit erreicht: Es werden maximal {TRIAL_DOCUMENT_LIMIT} PDF/DOCX/TXT Dokumente verarbeitet.",
            }
        )

    return entries, errors, len(documents)


def score_entry(question: str, entry: dict) -> float:
    query_tokens = tokenize(question)
    if not query_tokens:
        return 0.0

    chunk_tokens = entry["tokens"]
    if not chunk_tokens:
        return 0.0

    query_counter = Counter(query_tokens)
    chunk_counter = Counter(chunk_tokens)
    score = 0.0

    for token, weight in query_counter.items():
        count = chunk_counter.get(token, 0)
        if count:
            score += (2.0 + math.log1p(count)) * weight
        else:
            partial_hits = sum(1 for chunk_token in chunk_counter if token in chunk_token or chunk_token in token)
            if partial_hits:
                score += min(partial_hits, 4) * 0.35

    normalized_question = normalize_text(question)
    normalized_chunk = normalize_text(entry["chunk"])
    if normalized_question and normalized_question in normalized_chunk:
        score += 8.0

    unique_query = set(query_tokens)
    overlap = unique_query.intersection(chunk_counter)
    if unique_query:
        score += 4.0 * (len(overlap) / len(unique_query))

    return score


def search(question: str, entries: list[dict], limit: int = 6) -> list[dict]:
    scored = []
    for entry in entries:
        score = score_entry(question, entry)
        if score > 0:
            item = dict(entry)
            item["score"] = score
            scored.append(item)
    scored.sort(key=lambda item: item["score"], reverse=True)
    return scored[:limit]


SYSTEM_PROMPT = """
Du bist der lokale Dokumentenassistent in LIVA CORE.

Wichtige Regeln:
- Antworte immer in derselben Sprache, in der der Nutzer gefragt hat.
- Nutze nur die bereitgestellten Dokumentauszuege.
- Nenne keine Fakten, die nicht aus den Quellen ableitbar sind.
- Antworte kurz, klar und geschaeftstauglich.
- Gib konkrete naechste Schritte, wenn sie aus den Quellen ableitbar sind.
"""


def build_context(results: list[dict]) -> str:
    context = []
    for result in results:
        context.append(f"QUELLE: {result['source']}\n{result['chunk']}")
    return "\n\n".join(context)


def ollama_json(path: str, payload: dict | None = None, timeout: int = 12) -> dict:
    data = None
    method = "GET"
    headers = {}
    if payload is not None:
        data = json.dumps(payload).encode("utf-8")
        method = "POST"
        headers["Content-Type"] = "application/json"
    request = urllib.request.Request(f"{OLLAMA_URL}{path}", data=data, headers=headers, method=method)
    with urllib.request.urlopen(request, timeout=timeout) as response:
        return json.loads(response.read().decode("utf-8"))


def get_ollama_status() -> dict:
    try:
        data = ollama_json("/api/tags", timeout=3)
    except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError):
        return {"running": False, "models": [], "model": DEFAULT_MODEL, "fallback": False}

    models = [item.get("name", "") for item in data.get("models", [])]
    model = ""
    fallback = False
    if DEFAULT_MODEL in models:
        model = DEFAULT_MODEL
    elif FALLBACK_MODEL in models:
        model = FALLBACK_MODEL
        fallback = True
    else:
        model = DEFAULT_MODEL
    return {"running": True, "models": models, "model": model, "fallback": fallback}


def ask_ollama(model: str, question: str, results: list[dict]) -> str:
    user_prompt = f"""
Frage des Nutzers:
{question}

Gefundene Informationen aus den Dokumenten:
{build_context(results)}

Erstelle eine klare Antwort mit Quellenbezug. Wenn die Quellen nicht ausreichen, sage das offen.
"""
    payload = {
        "model": model,
        "system": SYSTEM_PROMPT,
        "prompt": user_prompt,
        "stream": False,
        "options": {"temperature": 0.2},
    }
    data = ollama_json("/api/generate", payload=payload, timeout=90)
    return data.get("response", "").strip() or "Keine Antwort vom lokalen Modell erhalten."


def ask_openai(api_key: str, question: str, results: list[dict]) -> str:
    client = OpenAI(api_key=api_key)
    user_prompt = f"""
Frage des Nutzers:
{question}

Gefundene Informationen aus den Dokumenten:
{build_context(results)}

Gib darauf eine kurze, klare und handlungsorientierte Antwort.
Antworte unbedingt in derselben Sprache wie die Frage des Nutzers.
"""
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": user_prompt},
        ],
        temperature=0.3,
    )
    return response.choices[0].message.content or ""


ollama_status = get_ollama_status()
documents = discover_documents()
trial_used = min(len(documents), TRIAL_DOCUMENT_LIMIT)

st.markdown('<div class="main-panel">', unsafe_allow_html=True)
st.markdown('<div class="hero-title">LIVA CORE</div>', unsafe_allow_html=True)
st.markdown(
    '<div class="hero-subtitle">Lokale KI-Dokumentenintelligenz fuer private Wissensarbeit, Teams und Unternehmen.</div>',
    unsafe_allow_html=True,
)

engine_status = "Bereit" if ollama_status["running"] and ollama_status["model"] in ollama_status["models"] else "Nicht bereit"
engine_class = "status-ok" if engine_status == "Bereit" else "status-warn"
model_label = ollama_status["model"]
if ollama_status["fallback"]:
    model_label = f"{FALLBACK_MODEL} (Fallback)"

st.markdown(
    f"""
    <div class="status-grid">
        <div class="status-card {engine_class}">
            <span>Local AI Engine</span>
            <strong>Ollama</strong>
        </div>
        <div class="status-card {engine_class}">
            <span>Model</span>
            <strong>{html.escape(model_label)}</strong>
        </div>
        <div class="status-card">
            <span>Dokumente</span>
            <strong>{len(documents)} gefunden</strong>
        </div>
        <div class="status-card">
            <span>Trial usage</span>
            <strong>{trial_used} / {TRIAL_DOCUMENT_LIMIT} documents</strong>
        </div>
    </div>
    """,
    unsafe_allow_html=True,
)

if not ollama_status["running"]:
    st.markdown(
        '<div class="notice-box">Ollama ist nicht gestartet. Bitte starten Sie Ollama und laden Sie qwen2.5:3b.</div>',
        unsafe_allow_html=True,
    )
elif DEFAULT_MODEL not in ollama_status["models"] and FALLBACK_MODEL in ollama_status["models"]:
    st.warning("qwen2.5:3b ist nicht installiert. LIVA CORE nutzt voruebergehend mistral als Fallback.")
elif DEFAULT_MODEL not in ollama_status["models"]:
    st.markdown(
        '<div class="notice-box">Ollama laeuft, aber qwen2.5:3b ist nicht installiert. Bitte laden Sie qwen2.5:3b.</div>',
        unsafe_allow_html=True,
    )

with st.sidebar:
    st.subheader("LIVA CORE Trial")
    st.caption("Lokaler Dokumentenassistent")
    st.write("Local AI Engine: Ollama")
    st.write(f"Model: {model_label}")
    st.write(f"Trial usage: {trial_used} / {TRIAL_DOCUMENT_LIMIT} documents")
    st.write("Dokumentenordner:")
    st.code(str(DOCS_PATH))
    if st.button("documents Ordner oeffnen"):
        DOCS_PATH.mkdir(parents=True, exist_ok=True)
        open_local_path(DOCS_PATH)

    with st.expander("Advanced: optional OpenAI API"):
        use_openai = st.checkbox("OpenAI statt Ollama verwenden", value=False)
        api_key = st.text_input("OpenAI API key", type="password")

if "entries" not in st.session_state:
    st.session_state.entries = []
if "indexed" not in st.session_state:
    st.session_state.indexed = False
if "document_total" not in st.session_state:
    st.session_state.document_total = len(documents)

col_a, col_b = st.columns([1, 2])
with col_a:
    if st.button("Dokumente einlesen", use_container_width=True):
        with st.spinner("Dokumente werden lokal eingelesen..."):
            entries, errors, total_documents = load_documents()
        st.session_state.entries = entries
        st.session_state.errors = errors
        st.session_state.indexed = True
        st.session_state.document_total = total_documents
        st.success(f"Fertig. {len(entries)} Textabschnitte aus {min(total_documents, TRIAL_DOCUMENT_LIMIT)} Dokument(en) eingelesen.")
        if errors:
            st.warning(f"{len(errors)} Hinweis(e) beim Einlesen.")

with col_b:
    st.caption("Legen Sie PDF, DOCX oder TXT Dateien in den documents Ordner. Die Trial verarbeitet maximal 10 Dokumente.")

if st.session_state.get("errors"):
    with st.expander("Hinweise zum Einlesen"):
        for entry in st.session_state.errors:
            st.write(f"{entry['source']}: {entry['message']}")

st.markdown('<div class="question-panel">', unsafe_allow_html=True)
question = st.text_area("Frage an Ihre lokalen Dokumente", height=96, placeholder="Zum Beispiel: Welche Risiken oder naechsten Schritte stehen in diesen Dokumenten?")
st.markdown("</div>", unsafe_allow_html=True)

if question:
    if not st.session_state.indexed:
        st.error("Bitte zuerst auf 'Dokumente einlesen' klicken.")
        st.stop()

    results = search(question, st.session_state.entries)

    if not results:
        st.warning("Keine passenden Informationen in den Dokumenten gefunden.")
        st.stop()

    sources = sorted({result["source"] for result in results})

    st.subheader("Antwort")
    if use_openai and api_key:
        with st.spinner("LIVA CORE nutzt optional OpenAI..."):
            try:
                answer = ask_openai(api_key, question, results)
                show_box(answer, height=320)
            except Exception as exc:
                st.error(f"OpenAI Anfrage fehlgeschlagen: {exc}")
    elif not ollama_status["running"]:
        st.error("Ollama ist nicht gestartet. Bitte starten Sie Ollama und laden Sie qwen2.5:3b.")
        st.caption("Bis Ollama laeuft, zeigt LIVA CORE die lokal gefundenen Dokumentauszuege mit Quellen.")
        for result in results[:3]:
            preview = f"QUELLE: {result['source']}\n\n{result['chunk'][:900]}"
            show_box(preview, height=220)
    elif ollama_status["model"] not in ollama_status["models"]:
        st.error("qwen2.5:3b ist nicht installiert. Bitte laden Sie qwen2.5:3b.")
        st.caption("Gefundene Quellen werden unten angezeigt.")
    else:
        with st.spinner(f"LIVA CORE fragt das lokale Modell {ollama_status['model']}..."):
            try:
                answer = ask_ollama(ollama_status["model"], question, results)
                show_box(answer, height=320)
            except Exception as exc:
                st.error(f"Lokale Ollama Anfrage fehlgeschlagen: {exc}")

    st.subheader("Quellen")
    for source in sources:
        matching = next(result for result in results if result["source"] == source)
        matching_path = matching["path"]
        col1, col2 = st.columns([4, 1])
        with col1:
            st.markdown(
                f"""
                <div class="source-card">
                    <strong>{html.escape(source)}</strong>
                    <div class="source-meta">Relevanter Dokumentauszug: {html.escape(matching["chunk"][:220])}...</div>
                </div>
                """,
                unsafe_allow_html=True,
            )
        with col2:
            if st.button("Oeffnen", key=source):
                open_local_path(matching_path)

st.markdown("</div>", unsafe_allow_html=True)
