"""Werkstatt: der Arbeitsordner eines Projekts - Dateien ansehen, bearbeiten,
durchsuchen; in der Desktop-App auch Befehle darin ausführen (Konsole).

Wo der Ordner liegt, entscheidet der Betrieb, nicht die Person: auf dem Server
immer ARBEIT_DIR/<projekt-id> (das Feld Project.arbeitsordner wird ignoriert -
sonst könnte jede Leitung jeden Ordner des Servers freigeben); in der
Desktop-App (MINERVA_EINZELNUTZER) der gewählte Pfad auf dem eigenen Rechner.

Befehle laufen nur, wenn MINERVA_BEFEHLE=1 gesetzt ist - das tut allein
desktop.py. Der Container setzt es nie: sonst hätte jedes Projektmitglied eine
Shell auf dem Server. ponytail: einmalige Befehle ohne Terminal (kein pty);
das Protokoll endet bei LOG_MAX, danach läuft der Befehl still weiter.
"""
from __future__ import annotations

import asyncio
import hashlib
import mimetypes
import os
import shutil
import signal
from datetime import datetime, timezone
from pathlib import Path

from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy import func, select

from web import DATEN, ablage, agenten
from web.app import einzelnutzer, job_json, modul_noetig, senden, sitzung, zugriff
from web.db import Job, Project, User

ARBEIT = Path(os.environ.get("ARBEIT_DIR", DATEN / "data" / "arbeit")).resolve()
IGNORIERT = {".git", "node_modules", ".venv", "__pycache__", ".DS_Store"}
TEXT_MAX = 2 * 1024 * 1024                # größere Dateien nur zum Herunterladen
SUCHE_MAX, SUCHE_DATEIEN = 200, 5000
BEFEHLE_MAX = 3
router = APIRouter()


def befehle_erlaubt() -> bool:
    return os.environ.get("MINERVA_BEFEHLE") == "1"


def arbeitsordner(p: Project) -> Path:
    if einzelnutzer() and p.arbeitsordner:
        return Path(p.arbeitsordner)
    ordner = ARBEIT / str(p.id)
    ordner.mkdir(parents=True, exist_ok=True)
    return ordner


def pfad_sicher(basis: Path, rel: str) -> Path:
    """Ein Pfad aus der Oberfläche, der im Arbeitsordner bleibt: erst die Form
    (kein .., kein \\, kein Laufwerk, kein führender /), dann der aufgelöste
    Ort - resolve() folgt Verweisen, was danach außerhalb liegt, gibt es nicht.
    ponytail: zwischen Prüfung und Öffnen kann ein Verweis entstehen (TOCTOU);
    O_NOFOLLOW je Komponente, falls das je zählt."""
    rel = rel or ""
    if ("\\" in rel or "\0" in rel or rel.startswith(("/", "~")) or ":" in rel
            or ".." in Path(rel).parts):
        raise HTTPException(status_code=422, detail="Ungültiger Pfad")
    ziel = basis / rel
    try:
        drin = ziel.resolve().is_relative_to(basis.resolve())
    except OSError:
        drin = False
    if not drin:
        raise HTTPException(status_code=404, detail="Nicht gefunden")
    return ziel


def _iso(zeit: float) -> str:
    return datetime.fromtimestamp(zeit, timezone.utc).isoformat()


def _eintrag(p: Path) -> dict:
    st = p.stat()
    return {"name": p.name, "typ": "ordner" if p.is_dir() else "datei",
            "groesse": 0 if p.is_dir() else st.st_size, "geaendert": _iso(st.st_mtime)}


def _sichtbar(p: Path, versteckt: bool) -> bool:
    return (p.name not in IGNORIERT and not p.is_symlink()
            and (versteckt or not p.name.startswith(".")))


def _sha(roh: bytes) -> str:
    return hashlib.sha256(roh).hexdigest()


def _text(roh: bytes) -> str | None:
    if b"\0" in roh:
        return None
    try:
        return roh.decode("utf-8")
    except UnicodeDecodeError:
        return None


# -- Ordner und Dateien ----------------------------------------------------
class OrdnerwahlEingabe(BaseModel):
    pfad: str | None = Field(default=None, max_length=1000)


class DateiEingabe(BaseModel):
    pfad: str = Field(min_length=1, max_length=1000)
    inhalt: str = Field(max_length=TEXT_MAX)
    basis_sha: str | None = None              # None = die Datei ist neu


class PfadEingabe(BaseModel):
    pfad: str = Field(min_length=1, max_length=1000)


class VerschiebenEingabe(BaseModel):
    von: str = Field(min_length=1, max_length=1000)
    nach: str = Field(min_length=1, max_length=1000)


@router.put("/api/projects/{pid}/arbeitsordner")
async def arbeitsordner_setzen(pid: int, daten: OrdnerwahlEingabe,
                               nutzer: User = Depends(modul_noetig("werkstatt")), s=Depends(sitzung)):
    p = zugriff(s, nutzer, "project", pid, "manager")[0]
    if not einzelnutzer():
        raise HTTPException(status_code=403, detail="Den Arbeitsordner wählt nur die Desktop-App; "
                            "auf dem Server liegt er fest")
    pfad = (daten.pfad or "").strip()
    if pfad:
        ordner = Path(pfad).expanduser()
        if not ordner.is_absolute() or not ordner.is_dir():
            raise HTTPException(status_code=422, detail="Bitte einen vorhandenen Ordner mit vollem Pfad")
        p.arbeitsordner = str(ordner.resolve())
    else:
        p.arbeitsordner = None
    s.commit()
    await senden(f"projekt:{pid}", {"projekt": pid, "art": "arbeitsordner"})
    return {"arbeitsordner": p.arbeitsordner}


@router.get("/api/projects/{pid}/dateien")
def dateien(pid: int, pfad: str = "", versteckt: bool = False,
            nutzer: User = Depends(modul_noetig("werkstatt")), s=Depends(sitzung)):
    p = zugriff(s, nutzer, "project", pid)[0]
    basis = arbeitsordner(p)
    ordner = pfad_sicher(basis, pfad)
    if not ordner.is_dir():
        raise HTTPException(status_code=404, detail="Ordner nicht gefunden")
    eintraege = [_eintrag(k) for k in ordner.iterdir() if _sichtbar(k, versteckt)]
    eintraege.sort(key=lambda e: (e["typ"] != "ordner", e["name"].casefold()))
    return {"ordner": str(basis) if einzelnutzer() else None, "pfad": pfad, "eintraege": eintraege}


@router.get("/api/projects/{pid}/dateien/inhalt")
def datei_lesen(pid: int, pfad: str, nutzer: User = Depends(modul_noetig("werkstatt")),
                s=Depends(sitzung)):
    """Text als JSON mit sha (für den Konflikt beim Speichern); alles andere
    als Datei zum Herunterladen - HTML nie als Seite (ablage.ausliefertyp)."""
    p = zugriff(s, nutzer, "project", pid)[0]
    ziel = pfad_sicher(arbeitsordner(p), pfad)
    if not ziel.is_file() or ziel.is_symlink():
        raise HTTPException(status_code=404, detail="Datei nicht gefunden")
    st = ziel.stat()
    if st.st_size <= TEXT_MAX:
        roh = ziel.read_bytes()
        text = _text(roh)
        if text is not None:
            return {"pfad": pfad, "inhalt": text, "sha": _sha(roh), "groesse": st.st_size,
                    "geaendert": _iso(st.st_mtime)}
    typ = mimetypes.guess_type(ziel.name)[0] or "application/octet-stream"
    return FileResponse(ziel, media_type=ablage.ausliefertyp(typ), filename=ziel.name)


@router.put("/api/projects/{pid}/dateien/inhalt")
async def datei_schreiben(pid: int, daten: DateiEingabe,
                          nutzer: User = Depends(modul_noetig("werkstatt")), s=Depends(sitzung)):
    p = zugriff(s, nutzer, "project", pid)[0]
    ziel = pfad_sicher(arbeitsordner(p), daten.pfad)
    if ziel.is_dir():
        raise HTTPException(status_code=422, detail="Das ist ein Ordner")
    if ziel.exists():
        if ziel.is_symlink():
            raise HTTPException(status_code=404, detail="Datei nicht gefunden")
        roh_alt = ziel.read_bytes()
        aktuell = _sha(roh_alt)
        if daten.basis_sha != aktuell:
            # Jemand anders war schneller: der Stand vom Server kommt mit, die
            # Oberfläche lässt wählen - überschreiben oder neu laden.
            return JSONResponse(status_code=409, content={
                "detail": "Die Datei wurde inzwischen geändert", "sha": aktuell,
                "inhalt": _text(roh_alt)})
    elif daten.basis_sha:
        raise HTTPException(status_code=409, detail="Die Datei gibt es nicht mehr")
    if not ziel.parent.is_dir():
        raise HTTPException(status_code=404, detail="Der Ordner dazu fehlt")
    roh = daten.inhalt.encode("utf-8")
    teil = ziel.with_name(ziel.name + ".teil")
    teil.write_bytes(roh)
    teil.replace(ziel)                        # ganz oder gar nicht, wie in der Ablage
    await senden(f"projekt:{pid}", {"projekt": pid, "art": "datei", "pfad": daten.pfad})
    return {"sha": _sha(roh), "geaendert": _iso(ziel.stat().st_mtime)}


@router.post("/api/projects/{pid}/dateien/ordner", status_code=201)
async def ordner_anlegen(pid: int, daten: PfadEingabe,
                         nutzer: User = Depends(modul_noetig("werkstatt")), s=Depends(sitzung)):
    p = zugriff(s, nutzer, "project", pid)[0]
    ziel = pfad_sicher(arbeitsordner(p), daten.pfad)
    if ziel.exists():
        raise HTTPException(status_code=409, detail="Gibt es schon")
    if not ziel.parent.is_dir():
        raise HTTPException(status_code=404, detail="Der Ordner dazu fehlt")
    ziel.mkdir()
    await senden(f"projekt:{pid}", {"projekt": pid, "art": "datei", "pfad": daten.pfad})
    return _eintrag(ziel)


@router.post("/api/projects/{pid}/dateien/verschieben")
async def verschieben(pid: int, daten: VerschiebenEingabe,
                      nutzer: User = Depends(modul_noetig("werkstatt")), s=Depends(sitzung)):
    p = zugriff(s, nutzer, "project", pid)[0]
    basis = arbeitsordner(p)
    von, nach = pfad_sicher(basis, daten.von), pfad_sicher(basis, daten.nach)
    if not von.exists() or von.is_symlink():
        raise HTTPException(status_code=404, detail="Nicht gefunden")
    if nach.exists():
        raise HTTPException(status_code=409, detail="Ziel gibt es schon")
    if not nach.parent.is_dir():
        raise HTTPException(status_code=404, detail="Der Zielordner fehlt")
    von.rename(nach)
    await senden(f"projekt:{pid}", {"projekt": pid, "art": "datei", "pfad": daten.nach})
    return _eintrag(nach)


@router.delete("/api/projects/{pid}/dateien")
async def loeschen(pid: int, pfad: str, nutzer: User = Depends(modul_noetig("werkstatt")),
                   s=Depends(sitzung)):
    p = zugriff(s, nutzer, "project", pid)[0]
    if not pfad.strip("/"):
        raise HTTPException(status_code=422, detail="Der Arbeitsordner selbst bleibt")
    ziel = pfad_sicher(arbeitsordner(p), pfad)
    if not ziel.exists() and not ziel.is_symlink():
        raise HTTPException(status_code=404, detail="Nicht gefunden")
    if ziel.is_dir() and not ziel.is_symlink():
        await asyncio.to_thread(shutil.rmtree, ziel)
    else:
        ziel.unlink()
    await senden(f"projekt:{pid}", {"projekt": pid, "art": "datei", "pfad": pfad})
    return {"ok": True}


@router.post("/api/projects/{pid}/dateien/hochladen", status_code=201)
async def hochladen(pid: int, pfad: str = Form(""), datei: UploadFile = File(...),
                    nutzer: User = Depends(modul_noetig("werkstatt")), s=Depends(sitzung)):
    """In einen Ordner laden; ein gleichnamiger Stand wird ersetzt - das ist
    eine Absicht, kein Bearbeitungskonflikt."""
    p = zugriff(s, nutzer, "project", pid)[0]
    ordner = pfad_sicher(arbeitsordner(p), pfad)
    if not ordner.is_dir():
        raise HTTPException(status_code=404, detail="Ordner nicht gefunden")
    inhalt = datei.file.read(ablage.MAX_BYTES + 1)
    if len(inhalt) > ablage.MAX_BYTES:
        raise HTTPException(status_code=413, detail=f"Die Datei ist größer als "
                            f"{ablage.MAX_BYTES // (1024 * 1024)} MB")
    name = ablage.sauberer_name(datei.filename or "datei")
    ziel = ordner / name
    ziel.write_bytes(inhalt)
    rel = (Path(pfad) / name).as_posix() if pfad else name
    await senden(f"projekt:{pid}", {"projekt": pid, "art": "datei", "pfad": rel})
    return {**_eintrag(ziel), "pfad": rel}


@router.get("/api/projects/{pid}/dateien/suche")
def suche(pid: int, q: str = "", versteckt: bool = False,
          nutzer: User = Depends(modul_noetig("werkstatt")), s=Depends(sitzung)):
    """Zeilen, die den Text enthalten - Groß/Klein egal, gedeckelt nach
    Treffern und Dateien, damit ein Riesenordner den Dienst nicht festhält."""
    q = q.strip()
    if len(q) < 2:
        raise HTTPException(status_code=422, detail="Mindestens zwei Zeichen")
    p = zugriff(s, nutzer, "project", pid)[0]
    basis = arbeitsordner(p)
    nadel, treffer, gesehen = q.lower(), [], 0
    for wurzel, ordner, namen in os.walk(basis):
        ordner[:] = [o for o in ordner if _sichtbar(Path(wurzel) / o, versteckt)]
        for name in sorted(namen):
            datei = Path(wurzel) / name
            if not _sichtbar(datei, versteckt):
                continue
            gesehen += 1
            if gesehen > SUCHE_DATEIEN:
                return {"treffer": treffer, "abgebrochen": True}
            try:
                if datei.stat().st_size > TEXT_MAX:
                    continue
                text = _text(datei.read_bytes())
            except OSError:
                continue
            if text is None:
                continue
            for nr, zeile in enumerate(text.splitlines(), 1):
                if nadel in zeile.lower():
                    treffer.append({"pfad": datei.relative_to(basis).as_posix(), "zeile": nr,
                                    "text": zeile.strip()[:200]})
                    if len(treffer) >= SUCHE_MAX:
                        return {"treffer": treffer, "abgebrochen": True}
    return {"treffer": treffer, "abgebrochen": False}


# -- Befehle (nur Desktop) -------------------------------------------------
class BefehlEingabe(BaseModel):
    cmd: str = Field(min_length=1, max_length=2000)


def _toeten(proc, hart: bool = False) -> None:
    try:
        if hasattr(os, "killpg"):                 # die ganze Gruppe, nicht nur die Shell
            os.killpg(proc.pid, signal.SIGKILL if hart else signal.SIGTERM)
        else:
            proc.kill()                           # ponytail: Windows trifft nur die Shell
    except ProcessLookupError:
        pass


async def _befehl(s, nutzer: User, job: Job):
    """Ausführer der Auftragsart „befehl“: Zeilen gebündelt ins Protokoll, Ende
    mit Rückgabewert; Abbruch beendet die Prozessgruppe."""
    if not befehle_erlaubt():
        raise HTTPException(status_code=403, detail="Befehle führt nur die Desktop-App aus")
    p = zugriff(s, nutzer, "project", job.project_id)[0]
    ordner, cmd = arbeitsordner(p), str(job.params.get("cmd", ""))
    s.commit()                                    # Verbindung frei - der Befehl läuft lange
    umgebung = {k: v for k, v in os.environ.items()
                if not k.startswith("MINERVA_") and k != "SESSION_SECRET"}
    umgebung.update({"PYTHONUNBUFFERED": "1", "NO_COLOR": "1", "TERM": "dumb"})
    proc = await asyncio.create_subprocess_shell(
        cmd, cwd=str(ordner), env=umgebung, stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.STDOUT, start_new_session=True)
    await agenten._log(job.id, f"$ {cmd}")
    puffer: list[str] = []
    voll = False

    async def lesen():
        # Stückweise, nicht zeilenweise: eine Riesenzeile würde den Zeilenleser
        # sprengen, und gelesen wird auch nach dem Deckel weiter - sonst
        # blockiert das Kind an der vollen Leitung.
        while True:
            stueck = await proc.stdout.read(65536)
            if not stueck:
                break
            puffer.append(stueck.decode("utf-8", "replace"))

    async def schreiben(text: str) -> None:
        nonlocal voll
        for i in range(0, len(text), 3500):       # _log kappt bei 4000 Zeichen
            if voll:
                return
            voll = not await agenten._log(job.id, text[i:i + 3500].rstrip("\n"))

    leser = asyncio.create_task(lesen())
    rest = ""
    try:
        while True:
            if puffer:
                text = rest + "".join(puffer)
                puffer.clear()
                schnitt = text.rfind("\n")        # an ganzen Zeilen schneiden
                if schnitt == -1 and len(text) < 3500:
                    rest = text
                else:
                    block, rest = (text, "") if schnitt == -1 else (text[:schnitt + 1], text[schnitt + 1:])
                    await schreiben(block)
                continue
            if leser.done():
                break
            await asyncio.wait({leser}, timeout=0.2)
        if rest:
            await schreiben(rest)
        rc = await proc.wait()
        return ("done" if rc == 0 else "failed"), {"exit": rc, "gekuerzt": voll}
    except asyncio.CancelledError:
        _toeten(proc)
        try:
            await asyncio.wait_for(proc.wait(), 2)
        except (TimeoutError, asyncio.TimeoutError):
            _toeten(proc, hart=True)
        raise
    finally:
        leser.cancel()


@router.post("/api/projects/{pid}/befehle", status_code=201)
async def befehl_starten(pid: int, daten: BefehlEingabe,
                         nutzer: User = Depends(modul_noetig("werkstatt")), s=Depends(sitzung)):
    """Läuft sofort und neben der Warteschlange der Agenten - ein Entwicklungs-
    server darf die Aufträge nicht aufhalten. Höchstens BEFEHLE_MAX zugleich."""
    if not befehle_erlaubt():
        raise HTTPException(status_code=403, detail="Befehle führt nur die Desktop-App aus")
    zugriff(s, nutzer, "project", pid)
    laufend = s.scalar(select(func.count()).select_from(Job)
                       .where(Job.kind == "befehl", Job.status == "running"))
    if laufend >= BEFEHLE_MAX:
        raise HTTPException(status_code=429, detail=f"Höchstens {BEFEHLE_MAX} Befehle zugleich")
    cmd = daten.cmd.strip()
    job = agenten.einreihen(s, "befehl", nutzer.id, cmd, {"cmd": cmd}, pid, status="running")
    agenten.sofort(job.id)
    s.refresh(job)
    return job_json(job)


def _projekt_geloescht(pid: int) -> None:
    """Nur der Ordner des Servers - nie ein gewählter Pfad auf dem Desktop."""
    shutil.rmtree(ARBEIT / str(pid), ignore_errors=True)


AUSFUEHRER = {"befehl": _befehl}
EREIGNISSE = {"projekt_geloescht": _projekt_geloescht}
