from __future__ import annotations

import asyncio
import logging
import os
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any

from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel

from epochler.app_settings import get_app_settings, save_app_settings
from epochler.auth import create_token, require_token, validate_token, verify_password
from epochler.config import get_settings
from epochler.contract import models as m
from epochler.decisioning import refresh_conversation_state
from epochler.orchestrator.orchestrator import Orchestrator
from epochler.runner.hardware import detect_hardware
from epochler.storage import store

# ---------------------------------------------------------------------------
# Logging — controlled by LOG_LEVEL env var (default: INFO, set DEBUG for full)
# ---------------------------------------------------------------------------

_LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()

logging.basicConfig(
    level=getattr(logging, _LOG_LEVEL, logging.INFO),
    format="%(asctime)s %(levelname)-8s [%(name)s] %(message)s",
    datefmt="%H:%M:%S",
    stream=sys.stderr,
    force=True,
)

# Quiet down noisy third-party loggers unless we're in DEBUG
if _LOG_LEVEL != "DEBUG":
    for _noisy in ("httpx", "httpcore", "anthropic", "watchfiles", "google"):
        logging.getLogger(_noisy).setLevel(logging.WARNING)

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# WebSocket connection manager
# ---------------------------------------------------------------------------

class ConnectionManager:
    def __init__(self) -> None:
        self.active: list[WebSocket] = []

    async def connect(self, ws: WebSocket) -> None:
        await ws.accept()
        self.active.append(ws)

    def disconnect(self, ws: WebSocket) -> None:
        if ws in self.active:
            self.active.remove(ws)

    async def broadcast(self, message: dict[str, Any]) -> None:
        dead: list[WebSocket] = []
        for ws in self.active:
            try:
                await ws.send_json(message)
            except Exception:
                dead.append(ws)
        for ws in dead:
            self.disconnect(ws)

    async def send(self, ws: WebSocket, message: dict[str, Any]) -> None:
        await ws.send_json(message)


manager = ConnectionManager()


# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------

orchestrator: Orchestrator | None = None


@asynccontextmanager
async def lifespan(app: FastAPI):
    global orchestrator
    settings = get_settings()
    settings.data_dir.mkdir(parents=True, exist_ok=True)
    orchestrator = Orchestrator(broadcast=manager.broadcast)
    hw = detect_hardware()
    orchestrator.set_hardware(hw)
    heartbeat_task = asyncio.create_task(orchestrator.start_heartbeat())
    yield
    heartbeat_task.cancel()


app = FastAPI(title="Epochler", version="0.1.0", lifespan=lifespan)

_settings_cors = get_settings()
app.add_middleware(
    CORSMiddleware,
    allow_origins=[o.strip() for o in _settings_cors.cors_origins.split(",") if o.strip()],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


# ---------------------------------------------------------------------------
# Auth routes
# ---------------------------------------------------------------------------

class LoginRequest(BaseModel):
    password: str


class TokenResponse(BaseModel):
    token: str


@app.post("/api/auth/login", response_model=TokenResponse)
async def login(req: LoginRequest):
    if not verify_password(req.password):
        raise HTTPException(status_code=401, detail="Invalid password")
    return TokenResponse(token=create_token())


# ---------------------------------------------------------------------------
# Conversation routes
# ---------------------------------------------------------------------------

@app.get("/api/conversations")
async def list_conversations(token: str = Query(...)):
    require_token(token)
    convs = store.list_conversations()
    return [
        {
            "id": c.id,
            "title": c.title,
            "subtitle": c.subtitle,
            "state": c.state.value,
            "created_at": c.created_at.isoformat(),
            "updated_at": c.updated_at.isoformat(),
            "message_count": len(c.messages),
        }
        for c in convs
    ]


class CreateConversationRequest(BaseModel):
    title: str
    workspace_root: str
    autonomy_level: str


@app.post("/api/conversations")
async def create_conversation(req: CreateConversationRequest, token: str = Query(...)):
    require_token(token)
    if not req.title.strip():
        raise HTTPException(status_code=422, detail="Title is required")
    autonomy = req.autonomy_level if req.autonomy_level in _VALID_AUTONOMY_LEVELS else "guided"
    conv = m.Conversation(
        title=req.title.strip(),
        workspace_root=req.workspace_root.strip(),
        autonomy_level=autonomy,
    )
    store.save_conversation(conv)
    return {"id": conv.id}


@app.get("/api/conversations/{conv_id}")
async def get_conversation(conv_id: str, token: str = Query(...)):
    require_token(token)
    conv = store.load_conversation(conv_id)
    if not conv:
        raise HTTPException(status_code=404, detail="Conversation not found")
    processing = orchestrator.is_processing(conv_id) if orchestrator else False
    refresh_conversation_state(conv, is_processing=processing)
    store.save_conversation(conv)
    if orchestrator:
        orchestrator.track_conversation(conv_id)
    result = conv.model_dump(mode="json")
    result["is_processing"] = processing
    result["stream_buffer"] = orchestrator.get_stream_buffer(conv_id) if orchestrator else ""
    return result


@app.delete("/api/conversations/{conv_id}")
async def delete_conversation(
    conv_id: str,
    token: str = Query(...),
    delete_files: bool = Query(False),
):
    require_token(token)
    if not store.delete_conversation(conv_id, delete_files=delete_files):
        raise HTTPException(status_code=404, detail="Conversation not found")
    return {"ok": True}


class ProjectSettingsUpdate(BaseModel):
    title: str | None = None
    autonomy_level: str | None = None


@app.put("/api/conversations/{conv_id}/settings")
async def update_project_settings(conv_id: str, req: ProjectSettingsUpdate, token: str = Query(...)):
    require_token(token)
    conv = store.load_conversation(conv_id)
    if not conv:
        raise HTTPException(status_code=404, detail="Conversation not found")
    if req.title is not None and req.title.strip():
        conv.title = req.title.strip()
    if req.autonomy_level is not None and req.autonomy_level in _VALID_AUTONOMY_LEVELS:
        conv.autonomy_level = req.autonomy_level
    store.save_conversation(conv)
    return {
        "title": conv.title,
        "workspace_root": conv.workspace_root,
        "autonomy_level": conv.autonomy_level,
    }


# ---------------------------------------------------------------------------
# Contract routes
# ---------------------------------------------------------------------------

@app.get("/api/conversations/{conv_id}/contract")
async def get_contract(conv_id: str, token: str = Query(...)):
    require_token(token)
    conv = store.load_conversation(conv_id)
    if not conv:
        raise HTTPException(status_code=404, detail="Conversation not found")
    processing = orchestrator.is_processing(conv_id) if orchestrator else False
    refresh_conversation_state(conv, is_processing=processing)
    store.save_conversation(conv)
    return {
        "contract": conv.contract.model_dump(mode="json"),
        "readiness": conv.readiness.model_dump(mode="json"),
        "decisions": [d.model_dump(mode="json") for d in conv.decisions],
    }



# ---------------------------------------------------------------------------
# Experiment routes
# ---------------------------------------------------------------------------

@app.get("/api/experiments/{contract_id}")
async def list_experiments(contract_id: str, token: str = Query(...)):
    require_token(token)
    exps = store.list_experiments(contract_id)
    return [e.model_dump(mode="json") for e in exps]


@app.get("/api/experiments/{contract_id}/{experiment_id}/code")
async def get_experiment_code(contract_id: str, experiment_id: str, token: str = Query(...)):
    require_token(token)
    code = store.load_experiment_code(contract_id, experiment_id)
    if code is None:
        raise HTTPException(status_code=404, detail="Code not found")
    return {"code": code}


@app.get("/api/experiments/{contract_id}/journal")
async def get_journal(contract_id: str, token: str = Query(...)):
    require_token(token)
    journal = store.load_journal(contract_id)
    if not journal:
        return {"contract_id": contract_id, "experiments": [], "agent_commentary": ""}
    return journal.model_dump(mode="json")


@app.get("/api/experiments/{contract_id}/activity")
async def get_activity_log(contract_id: str, token: str = Query(...)):
    require_token(token)
    activities = store.load_activity_log(contract_id)
    return [a.model_dump(mode="json") for a in activities]


@app.get("/api/experiments/{contract_id}/{experiment_id}")
async def get_experiment(contract_id: str, experiment_id: str, token: str = Query(...)):
    require_token(token)
    exp = store.load_experiment(contract_id, experiment_id)
    if not exp:
        raise HTTPException(status_code=404, detail="Experiment not found")
    return exp.model_dump(mode="json")


# ---------------------------------------------------------------------------
# Settings (UI-editable)
# ---------------------------------------------------------------------------

_VALID_AUTONOMY_LEVELS = {"guided", "auto_minor", "auto_all"}


class SettingsUpdate(BaseModel):
    workspace_root: str | None = None
    autonomy_level: str | None = None


@app.get("/api/settings")
async def get_settings_api(token: str = Query(...)):
    require_token(token)
    s = get_app_settings()
    return {
        "workspace_root": s.get("workspace_root") or "",
        "autonomy_level": s.get("autonomy_level") or "guided",
    }


@app.put("/api/settings")
async def update_settings(req: SettingsUpdate, token: str = Query(...)):
    require_token(token)
    updates = {}
    if req.workspace_root is not None:
        updates["workspace_root"] = req.workspace_root.strip() if req.workspace_root else ""
    if req.autonomy_level is not None and req.autonomy_level in _VALID_AUTONOMY_LEVELS:
        updates["autonomy_level"] = req.autonomy_level
    save_app_settings(updates)
    s = get_app_settings()
    return {
        "workspace_root": s.get("workspace_root") or "",
        "autonomy_level": s.get("autonomy_level") or "guided",
    }


# ---------------------------------------------------------------------------
# Hardware
# ---------------------------------------------------------------------------

@app.get("/api/hardware")
async def get_hardware(token: str = Query(...)):
    require_token(token)
    hw = detect_hardware()
    return hw.model_dump(mode="json")


# ---------------------------------------------------------------------------
# Browse filesystem
# ---------------------------------------------------------------------------

@app.get("/api/browse")
async def browse_path(token: str = Query(...), path: str = Query("~")):
    require_token(token)
    target = Path(path).expanduser().resolve()
    home = Path.home().resolve()
    if not str(target).startswith(str(home)):
        raise HTTPException(status_code=403, detail="Access restricted to home directory")
    if not target.exists():
        raise HTTPException(status_code=404, detail="Path not found")
    if target.is_file():
        return {
            "path": str(target),
            "parent": str(target.parent),
            "type": "file",
            "entries": [],
        }
    entries = []
    try:
        for child in sorted(target.iterdir()):
            if child.name.startswith('.'):
                continue
            entries.append({
                "name": child.name,
                "type": "dir" if child.is_dir() else "file",
                "size": child.stat().st_size if child.is_file() else None,
            })
    except PermissionError:
        raise HTTPException(status_code=403, detail="Permission denied")
    return {
        "path": str(target),
        "parent": str(target.parent),
        "type": "dir",
        "entries": entries,
    }


# ---------------------------------------------------------------------------
# Workspace file serving (per-project)
# ---------------------------------------------------------------------------

_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".svg", ".gif"}
_TEXT_EXTS = {".py", ".csv", ".json", ".txt", ".md", ".log", ".yaml", ".yml", ".toml", ".cfg", ".ini"}
_EXT_LANG = {".py": "python", ".json": "json", ".yaml": "yaml", ".yml": "yaml", ".md": "markdown", ".toml": "toml", ".csv": "csv", ".txt": "text", ".log": "text"}
_MAX_FILE_SIZE = 10 * 1024 * 1024


@app.get("/api/workspace/{conv_id}/files")
async def get_workspace_file(conv_id: str, token: str = Query(...), path: str = Query("")):
    require_token(token)
    conv = store.load_conversation(conv_id)
    if not conv:
        raise HTTPException(status_code=404, detail="Conversation not found")

    ws_root = conv.workspace_root or ""
    if not ws_root:
        from epochler.app_settings import get_workspace_root
        root = get_workspace_root()
        ws_root = str(root) if root else ""
    if not ws_root:
        raise HTTPException(status_code=400, detail="No workspace root configured")

    workspace_dir = Path(ws_root)
    workspace_resolved = workspace_dir.resolve()

    if path:
        resolved = (workspace_dir / path).resolve()
    else:
        resolved = workspace_resolved

    if not str(resolved).startswith(str(workspace_resolved)):
        raise HTTPException(status_code=403, detail="Path outside workspace")

    if not resolved.exists():
        if not path and resolved == workspace_resolved:
            return {"path": ".", "type": "dir", "entries": []}
        raise HTTPException(status_code=404, detail="Path not found")

    if resolved.is_dir():
        entries = []
        try:
            for child in sorted(resolved.iterdir()):
                if child.name.startswith('.') or child.name == '__pycache__':
                    continue
                stat = child.stat()
                entries.append({
                    "name": child.name,
                    "type": "dir" if child.is_dir() else "file",
                    "size": stat.st_size if child.is_file() else None,
                    "modified": stat.st_mtime,
                })
        except PermissionError:
            raise HTTPException(status_code=403, detail="Permission denied")
        return {
            "path": str(resolved.relative_to(workspace_resolved)),
            "type": "dir",
            "entries": entries,
        }

    stat = resolved.stat()
    if stat.st_size > _MAX_FILE_SIZE:
        raise HTTPException(status_code=413, detail="File too large (>10MB)")

    ext = resolved.suffix.lower()
    rel_path = str(resolved.relative_to(workspace_resolved))

    if ext in _IMAGE_EXTS:
        import base64
        import mimetypes
        mime = mimetypes.guess_type(str(resolved))[0] or "image/png"
        data = base64.b64encode(resolved.read_bytes()).decode("ascii")
        return {"path": rel_path, "type": "image", "mime": mime, "data": f"data:{mime};base64,{data}"}

    if ext in _TEXT_EXTS:
        content = resolved.read_text(encoding="utf-8", errors="replace")
        return {"path": rel_path, "type": "text", "language": _EXT_LANG.get(ext, "text"), "content": content}

    return {"path": rel_path, "type": "binary", "name": resolved.name, "size": stat.st_size, "modified": stat.st_mtime}


# ---------------------------------------------------------------------------
# WebSocket
# ---------------------------------------------------------------------------

@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket, token: str = Query("")):
    if not validate_token(token):
        await ws.close(code=4001, reason="Unauthorized")
        return

    await manager.connect(ws)
    try:
        while True:
            data = await ws.receive_json()
            msg_type = data.get("type", "")

            if msg_type == "ping":
                await manager.send(ws, {"type": "pong"})

            elif msg_type == "chat_message":
                conv_id = data.get("conversation_id", "")
                content = data.get("content", "")
                if not conv_id or not content:
                    await manager.send(ws, {"type": "error", "detail": "Missing conversation_id or content"})
                    continue
                orchestrator.track_conversation(conv_id)
                orchestrator.start_chat_task(conv_id, content)

            elif msg_type == "stop_generation":
                conv_id = data.get("conversation_id", "")
                orchestrator.stop_generation(conv_id)

            elif msg_type == "start_discovery":
                conv_id = data.get("conversation_id", "")
                if not conv_id:
                    await manager.send(ws, {"type": "error", "detail": "Missing conversation_id"})
                    continue
                orchestrator.track_conversation(conv_id)
                orchestrator.start_discovery_task(conv_id)

            elif msg_type == "decision_action":
                conv_id = data.get("conversation_id", "")
                decision_id = data.get("decision_id", "")
                action = data.get("action", "")
                note = data.get("note", "")
                result = await orchestrator.handle_decision_action(conv_id, decision_id, action, note)
                await manager.send(ws, {"type": "decision_result", "conversation_id": conv_id, **result})

            elif msg_type == "run_control":
                conv_id = data.get("conversation_id", "")
                action = data.get("action", "")
                result = await orchestrator.handle_run_control(conv_id, action)
                await manager.send(ws, {"type": "run_control_result", **result})

    except WebSocketDisconnect:
        manager.disconnect(ws)
    except Exception:
        logger.exception("WebSocket error")
        manager.disconnect(ws)


# ---------------------------------------------------------------------------
# Serve frontend static files (production)
# ---------------------------------------------------------------------------

_PKG_STATIC = Path(__file__).resolve().parent / "static"
_DEV_STATIC = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
_FRONTEND_DIST = _PKG_STATIC if _PKG_STATIC.exists() else _DEV_STATIC

if _FRONTEND_DIST.exists():
    app.mount("/assets", StaticFiles(directory=str(_FRONTEND_DIST / "assets")), name="static")

    @app.get("/{full_path:path}")
    async def serve_frontend(full_path: str):
        file_path = _FRONTEND_DIST / full_path
        if file_path.is_file():
            return FileResponse(str(file_path))
        return FileResponse(str(_FRONTEND_DIST / "index.html"))
