import asyncio
import base64
import fnmatch
import json

import aiofiles
import os
import platform
import re
import signal
import socket
import sys
import time
import uuid
from dataclasses import dataclass, field
from typing import Optional

from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field

from open_terminal.env import API_KEY, LOG_DIR


def get_system_info() -> str:
    """Gather runtime system metadata for the OpenAPI description."""
    shell = os.environ.get("SHELL", "/bin/sh")
    return (
        f"This system is running {platform.system()} {platform.release()} ({platform.machine()}) "
        f"on {socket.gethostname()} as user '{os.getenv('USER', 'unknown')}' with {shell}. "
        f"Python {sys.version.split()[0]} is available. "
        f"The working directory is {os.getcwd()}."
    )


_EXECUTE_DESCRIPTION = (
    "Run a shell command in the background and return a command ID.\n\n"
    + get_system_info()
)

bearer_scheme = HTTPBearer(auto_error=False)


async def verify_api_key(
    credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
):
    if not API_KEY:
        return
    if not credentials or credentials.credentials != API_KEY:
        raise HTTPException(status_code=401, detail="Invalid API key")


app = FastAPI(
    title="Open Terminal",
    description="A remote terminal API.",
    version="0.2.3",
)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.middleware("http")
async def normalize_null_query_params(request: Request, call_next):
    """Strip query parameters whose value is the literal string 'null'."""
    from urllib.parse import urlencode

    raw_params = request.query_params.multi_items()
    cleaned = [(k, v) for k, v in raw_params if v.lower() != "null"]
    if len(cleaned) != len(raw_params):
        request.scope["query_string"] = urlencode(cleaned).encode("utf-8")
    return await call_next(request)


# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------

class ExecRequest(BaseModel):
    command: str = Field(
        ...,
        description="Shell command to execute. Supports chaining (&&, ||, ;), pipes (|), and redirections.",
        json_schema_extra={"examples": ["echo hello", "ls -la && whoami"]},
    )
    cwd: Optional[str] = Field(
        None,
        description="Working directory for the command. Defaults to the server's current directory if not set.",
    )
    env: Optional[dict[str, str]] = Field(
        None,
        description="Extra environment variables merged into the subprocess environment.",
    )


class InputRequest(BaseModel):
    input: str = Field(
        ...,
        description="Text to send to the process's stdin. Include newline characters as needed.",
    )


class WriteRequest(BaseModel):
    path: str = Field(
        ...,
        description="Absolute or relative path to write to. Parent directories are created automatically.",
    )
    content: str = Field(
        ...,
        description="Text content to write to the file.",
    )


class ReplacementChunk(BaseModel):
    target: str = Field(
        ...,
        description="Exact string to find. Must match precisely, including whitespace.",
    )
    replacement: str = Field(
        ...,
        description="Content to replace the target with.",
    )
    start_line: Optional[int] = Field(
        None,
        description="Narrow the search to lines at or after this (1-indexed).",
        ge=1,
    )
    end_line: Optional[int] = Field(
        None,
        description="Narrow the search to lines at or before this (1-indexed).",
        ge=1,
    )
    allow_multiple: bool = Field(
        False,
        description="If true, replaces all occurrences. If false, errors when multiple matches are found.",
    )


class ReplaceRequest(BaseModel):
    path: str = Field(
        ...,
        description="Path to the file to modify.",
    )
    replacements: list[ReplacementChunk] = Field(
        ...,
        description="List of find-and-replace operations to apply sequentially.",
    )


# ---------------------------------------------------------------------------
# Background process management
# ---------------------------------------------------------------------------

@dataclass
class BackgroundProcess:
    id: str
    command: str
    process: asyncio.subprocess.Process
    status: str = "running"
    exit_code: Optional[int] = None
    log_task: Optional[asyncio.Task] = field(default=None, repr=False)
    finished_at: Optional[float] = field(default=None, repr=False)
    log_path: Optional[str] = field(default=None, repr=False)


_processes: dict[str, BackgroundProcess] = {}
_EXPIRY_SECONDS = 300  # auto-clean finished processes after 5 min


async def _log_process(background_process: BackgroundProcess):
    """Read stdout and stderr and persist to a log file."""
    log_file = None
    if background_process.log_path:
        os.makedirs(os.path.dirname(background_process.log_path), exist_ok=True)
        log_file = await aiofiles.open(background_process.log_path, "a")
        await log_file.write(
            json.dumps(
                {
                    "type": "start",
                    "command": background_process.command,
                    "pid": background_process.process.pid,
                    "ts": time.time(),
                }
            )
            + "\n"
        )
        await log_file.flush()

    async def read_stream(stream, label):
        async for line in stream:
            if log_file:
                await log_file.write(
                    json.dumps(
                        {"type": label, "data": line.decode(errors="replace"), "ts": time.time()}
                    )
                    + "\n"
                )
                await log_file.flush()

    try:
        await asyncio.gather(
            read_stream(background_process.process.stdout, "stdout"),
            read_stream(background_process.process.stderr, "stderr"),
        )
    finally:
        await background_process.process.wait()
        background_process.exit_code = background_process.process.returncode
        background_process.status = "done"
        background_process.finished_at = time.time()
        if log_file:
            await log_file.write(
                json.dumps(
                    {
                        "type": "end",
                        "exit_code": background_process.exit_code,
                        "ts": time.time(),
                    }
                )
                + "\n"
            )
            await log_file.close()


async def _read_log(
    log_path: Optional[str],
    offset: int = 0,
    tail: Optional[int] = None,
) -> tuple[list[dict], int, bool]:
    """Read output entries from a JSONL log file.

    Returns (entries, next_offset, truncated).
    """
    entries: list[dict] = []
    if not log_path or not os.path.isfile(log_path):
        return entries, 0, False

    async with aiofiles.open(log_path) as f:
        lines = await f.readlines()

    for line in lines:
        line = line.strip()
        if not line:
            continue
        try:
            record = json.loads(line)
        except json.JSONDecodeError:
            continue
        if record.get("type") in ("stdout", "stderr"):
            entries.append({"type": record["type"], "data": record["data"]})

    total = len(entries)
    entries = entries[offset:]

    truncated = False
    if tail is not None and len(entries) > tail:
        entries = entries[-tail:]
        truncated = True

    return entries, total, truncated


def _cleanup_expired():
    """Remove finished processes that have expired."""
    now = time.time()
    expired = [
        process_id
        for process_id, background_process in _processes.items()
        if background_process.finished_at
        and now - background_process.finished_at > _EXPIRY_SECONDS
    ]
    for process_id in expired:
        del _processes[process_id]


def _get_process(process_id: str) -> BackgroundProcess:
    _cleanup_expired()
    background_process = _processes.get(process_id)
    if not background_process:
        raise HTTPException(status_code=404, detail="Process not found")
    return background_process


# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------

@app.get(
    "/health",
    operation_id="health_check",
    summary="Health check",
    description="Returns service status. No authentication required.",
)
async def health():
    return {"status": "ok"}


# ---------------------------------------------------------------------------
# Files
# ---------------------------------------------------------------------------

@app.get(
    "/files/list",
    operation_id="list_files",
    summary="List directory contents",
    description="Return a structured listing of files and directories at the given path.",
    dependencies=[Depends(verify_api_key)],
    responses={
        404: {"description": "Directory not found."},
        401: {"description": "Invalid or missing API key."},
    },
)
async def list_files(
    directory: str = Query(".", description="Directory path to list."),
):
    target = os.path.abspath(directory)
    if not os.path.isdir(target):
        raise HTTPException(status_code=404, detail="Directory not found")

    entries = []
    for name in sorted(os.listdir(target)):
        full_path = os.path.join(target, name)
        try:
            file_stat = os.stat(full_path)
            entries.append(
                {
                    "name": name,
                    "type": "directory" if os.path.isdir(full_path) else "file",
                    "size": file_stat.st_size,
                    "modified": file_stat.st_mtime,
                }
            )
        except OSError:
            continue

    return {"dir": target, "entries": entries}


@app.get(
    "/files/read",
    operation_id="read_file",
    summary="Read a file",
    description="Return the contents of a file as JSON. Text files return a content string; binary files return base64-encoded content. Optionally specify a line range for text files.",
    dependencies=[Depends(verify_api_key)],
    responses={
        404: {"description": "File not found."},
        401: {"description": "Invalid or missing API key."},
    },
)
async def read_file(
    path: str = Query(..., description="Path to the file to read."),
    start_line: Optional[int] = Query(None, description="First line to return (1-indexed, inclusive).", ge=1),
    end_line: Optional[int] = Query(None, description="Last line to return (1-indexed, inclusive).", ge=1),
):
    target = os.path.abspath(path)
    if not os.path.isfile(target):
        raise HTTPException(status_code=404, detail="File not found")

    try:
        with open(target, "r", errors="strict") as text_file:
            lines = text_file.readlines()
    except (UnicodeDecodeError, ValueError):
        # Binary file — return base64
        with open(target, "rb") as binary_file:
            raw = binary_file.read()
        return {
            "path": target,
            "encoding": "base64",
            "size": len(raw),
            "content": base64.b64encode(raw).decode("ascii"),
        }

    start = (start_line or 1) - 1
    end = end_line or len(lines)
    return {
        "path": target,
        "total_lines": len(lines),
        "content": "".join(lines[start:end]),
    }


@app.post(
    "/files/write",
    operation_id="write_file",
    summary="Write a file",
    description="Write text content to a file. Creates parent directories automatically. Overwrites if the file already exists.",
    dependencies=[Depends(verify_api_key)],
    responses={
        401: {"description": "Invalid or missing API key."},
    },
)
async def write_file(request: WriteRequest):
    target = os.path.abspath(request.path)
    os.makedirs(os.path.dirname(target), exist_ok=True)
    with open(target, "w") as output_file:
        output_file.write(request.content)
    return {"path": target, "size": len(request.content.encode())}


@app.post(
    "/files/replace",
    operation_id="replace_file_content",
    summary="Replace content in a file",
    description="Find and replace exact strings in a file. Supports multiple replacements in one call with optional line range narrowing.",
    dependencies=[Depends(verify_api_key)],
    responses={
        404: {"description": "File not found."},
        400: {"description": "Target string not found or ambiguous match."},
        401: {"description": "Invalid or missing API key."},
    },
)
async def replace_file_content(request: ReplaceRequest):
    target = os.path.abspath(request.path)
    if not os.path.isfile(target):
        raise HTTPException(status_code=404, detail="File not found")

    with open(target, "r", errors="replace") as text_file:
        content = text_file.read()

    for chunk in request.replacements:
        if chunk.start_line or chunk.end_line:
            lines = content.splitlines(keepends=True)
            start = (chunk.start_line or 1) - 1
            end = chunk.end_line or len(lines)
            search_region = "".join(lines[start:end])
        else:
            search_region = content

        count = search_region.count(chunk.target)
        if count == 0:
            raise HTTPException(
                status_code=400,
                detail=f"Target string not found: {chunk.target[:100]!r}",
            )
        if count > 1 and not chunk.allow_multiple:
            raise HTTPException(
                status_code=400,
                detail=f"Found {count} occurrences of target string but allow_multiple is false",
            )

        if chunk.start_line or chunk.end_line:
            new_region = search_region.replace(chunk.target, chunk.replacement)
            lines[start:end] = [new_region]
            content = "".join(lines)
        else:
            content = content.replace(chunk.target, chunk.replacement)

    with open(target, "w") as output_file:
        output_file.write(content)

    return {"path": target, "size": len(content.encode())}


@app.get(
    "/files/search",
    operation_id="search_files",
    summary="Search file contents",
    description="Search for a text pattern across files in a directory. Returns structured matches with file paths, line numbers, and matching lines. Skips binary files.",
    dependencies=[Depends(verify_api_key)],
    responses={
        404: {"description": "Search path not found."},
        400: {"description": "Invalid regex pattern."},
        401: {"description": "Invalid or missing API key."},
    },
)
async def search_files(
    query: str = Query(..., description="Text or regex pattern to search for."),
    path: str = Query(".", description="Directory or file to search in."),
    regex: bool = Query(False, description="Treat query as a regex pattern."),
    case_insensitive: bool = Query(False, description="Perform case-insensitive matching."),
    include: Optional[list[str]] = Query(None, description="Glob patterns to filter files (e.g. '*.py'). Files must match at least one pattern."),
    match_per_line: bool = Query(True, description="If true, return each matching line with line numbers. If false, return only the names of matching files."),
    max_results: int = Query(50, description="Maximum number of matches to return.", ge=1, le=500),
):
    target = os.path.abspath(path)
    if not os.path.exists(target):
        raise HTTPException(status_code=404, detail="Search path not found")

    flags = re.IGNORECASE if case_insensitive else 0
    if regex:
        try:
            pattern = re.compile(query, flags)
        except re.error as exc:
            raise HTTPException(status_code=400, detail=f"Invalid regex: {exc}")
    else:
        pattern = re.compile(re.escape(query), flags)

    def _matches_include(filename: str) -> bool:
        if not include:
            return True
        return any(fnmatch.fnmatch(filename, glob) for glob in include)

    matches = []
    truncated = False

    def _search_file(file_path: str):
        nonlocal truncated
        if truncated:
            return
        try:
            with open(file_path, "r", errors="strict") as f:
                for line_number, line in enumerate(f, 1):
                    if pattern.search(line):
                        if match_per_line:
                            matches.append({
                                "file": file_path,
                                "line": line_number,
                                "content": line.rstrip("\n\r"),
                            })
                            if len(matches) >= max_results:
                                truncated = True
                                return
                        else:
                            matches.append({"file": file_path})
                            if len(matches) >= max_results:
                                truncated = True
                            return  # one match per file is enough
        except (UnicodeDecodeError, ValueError, OSError):
            pass  # skip binary or unreadable files

    if os.path.isfile(target):
        _search_file(target)
    else:
        for dirpath, _, filenames in os.walk(target):
            if truncated:
                break
            for filename in sorted(filenames):
                if not _matches_include(filename):
                    continue
                _search_file(os.path.join(dirpath, filename))

    return {
        "query": query,
        "path": target,
        "matches": matches,
        "truncated": truncated,
    }


# Temporary links: {token: (path, expiry_timestamp)}
_download_links: dict[str, tuple[str, float]] = {}
_upload_links: dict[str, tuple[str, float]] = {}


@app.get(
    "/files/download/link",
    operation_id="create_download_link",
    summary="Get a file download link",
    description="Returns a temporary download URL for a file. Link expires after 5 minutes and requires no authentication to use.",
    dependencies=[Depends(verify_api_key)],
    responses={
        404: {"description": "File not found."},
        401: {"description": "Invalid or missing API key."},
    },
)
async def get_file_link(
    path: str = Query(..., description="Absolute path to the file."),
    request: Request = None,
):
    if not os.path.isfile(path):
        raise HTTPException(status_code=404, detail="File not found")

    token = uuid.uuid4().hex
    _download_links[token] = (path, time.time() + 300)

    base_url = str(request.base_url).rstrip("/")
    return {"url": f"{base_url}/files/download/{token}"}


@app.get(
    "/files/download/{token}",
    include_in_schema=False,
)
async def download_file(token: str):
    entry = _download_links.pop(token, None)
    if not entry:
        raise HTTPException(status_code=404, detail="Invalid or expired download link")

    path, expiry = entry
    if time.time() > expiry:
        raise HTTPException(status_code=404, detail="Download link expired")

    if not os.path.isfile(path):
        raise HTTPException(status_code=404, detail="File not found")

    return FileResponse(path)


@app.post(
    "/files/upload",
    operation_id="upload_file",
    summary="Upload a file",
    description="Save a file to the specified path. Provide a `url` to fetch remotely, or send the file directly via multipart form data.",
    dependencies=[Depends(verify_api_key)],
    responses={
        401: {"description": "Invalid or missing API key."},
    },
)
async def upload_file(
    directory: str = Query(..., description="Destination directory for the file."),
    url: Optional[str] = Query(None, description="URL to download the file from. If omitted, expects a multipart file upload."),
    file: Optional[UploadFile] = File(None, description="The file to upload (if no URL provided)."),
):
    if url:
        import httpx
        from urllib.parse import urlparse

        async with httpx.AsyncClient(follow_redirects=True) as client:
            response = await client.get(url)
            response.raise_for_status()
        content = response.content
        filename = os.path.basename(urlparse(url).path) or "download"
    elif file:
        content = await file.read()
        filename = file.filename or "upload"
    else:
        raise HTTPException(status_code=400, detail="Provide either 'url' or a file upload.")

    os.makedirs(directory, exist_ok=True)
    path = os.path.join(directory, filename)
    with open(path, "wb") as output_file:
        output_file.write(content)
    return {"path": path, "size": len(content)}


@app.post(
    "/files/upload/link",
    operation_id="create_upload_link",
    summary="Create an upload link",
    description="Generate a temporary, unauthenticated upload URL. Link expires after 5 minutes.",
    dependencies=[Depends(verify_api_key)],
    responses={
        401: {"description": "Invalid or missing API key."},
    },
)
async def create_upload_link(
    directory: str = Query(..., description="Destination directory for the uploaded file."),
    request: Request = None,
):
    token = uuid.uuid4().hex
    _upload_links[token] = (directory, time.time() + 300)

    base_url = str(request.base_url).rstrip("/")
    return {"url": f"{base_url}/files/upload/{token}"}


@app.get(
    "/files/upload/{token}",
    response_class=HTMLResponse,
    include_in_schema=False,
)
async def upload_page(token: str):
    entry = _upload_links.get(token)
    if not entry or time.time() > entry[1]:
        return HTMLResponse("Link expired.", status_code=404)

    return HTMLResponse(
        '<form method="post" enctype="multipart/form-data">'
        '<input type="file" name="file" required> '
        '<button type="submit">Upload</button>'
        '</form>'
    )


@app.post(
    "/files/upload/{token}",
    include_in_schema=False,
)
async def upload_file_via_link(
    token: str,
    file: UploadFile = File(..., description="The file to upload."),
):
    entry = _upload_links.pop(token, None)
    if not entry:
        raise HTTPException(status_code=404, detail="Invalid or expired upload link")

    directory, expiry = entry
    if time.time() > expiry:
        raise HTTPException(status_code=404, detail="Upload link expired")

    filename = file.filename or "upload"
    os.makedirs(directory, exist_ok=True)
    path = os.path.join(directory, filename)
    content = await file.read()
    with open(path, "wb") as output_file:
        output_file.write(content)
    return {"path": path, "size": len(content)}


# ---------------------------------------------------------------------------
# Execute
# ---------------------------------------------------------------------------

@app.get(
    "/execute",
    operation_id="list_processes",
    summary="List running commands",
    description="Returns a list of all tracked background processes, including running, done, and killed.",
    dependencies=[Depends(verify_api_key)],
    responses={
        401: {"description": "Invalid or missing API key."},
    },
)
async def list_processes():
    _cleanup_expired()
    return [
        {
            "id": background_process.id,
            "command": background_process.command,
            "status": background_process.status,
            "exit_code": background_process.exit_code,
            "log_path": background_process.log_path,
        }
        for background_process in _processes.values()
    ]


@app.post(
    "/execute",
    operation_id="run_command",
    summary="Execute a command",
    description=_EXECUTE_DESCRIPTION,
    dependencies=[Depends(verify_api_key)],
    responses={
        401: {"description": "Invalid or missing API key."},
    },
)
async def execute(
    request: ExecRequest,
    wait: Optional[float] = Query(
        None,
        description="Seconds to wait for the command to finish before returning. If the command completes in time, output is included inline. Null to return immediately.",
        ge=0,
        le=300,
    ),
    tail: Optional[int] = Query(
        None,
        description="Return only the last N output entries. Useful to limit response size when only recent output matters.",
        ge=1,
    ),
):
    subprocess_env = {**os.environ, **request.env} if request.env else None
    process = await asyncio.create_subprocess_shell(
        request.command,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        stdin=asyncio.subprocess.PIPE,
        cwd=request.cwd,
        env=subprocess_env,
    )

    process_id = uuid.uuid4().hex[:12]
    log_path = os.path.join(LOG_DIR, "processes", f"{process_id}.jsonl")
    background_process = BackgroundProcess(
        id=process_id, command=request.command, process=process, log_path=log_path
    )
    background_process.log_task = asyncio.create_task(
        _log_process(background_process)
    )
    _processes[process_id] = background_process

    if wait is not None:
        try:
            await asyncio.wait_for(
                asyncio.shield(background_process.log_task), timeout=wait
            )
        except asyncio.TimeoutError:
            pass

    output, next_offset, truncated = await _read_log(
        background_process.log_path, offset=0, tail=tail
    )

    return {
        "id": process_id,
        "command": request.command,
        "status": background_process.status,
        "exit_code": background_process.exit_code,
        "output": output,
        "truncated": truncated,
        "next_offset": next_offset,
        "log_path": background_process.log_path,
    }


@app.get(
    "/execute/{process_id}/status",
    operation_id="get_process_status",
    summary="Get command status and output",
    description="Returns new output since the last poll, process status, and exit code. Output is drained on read to keep memory bounded.",
    dependencies=[Depends(verify_api_key)],
    responses={
        404: {"description": "Process not found."},
        401: {"description": "Invalid or missing API key."},
    },
)
async def get_status(
    process_id: str,
    wait: Optional[float] = Query(
        None,
        description="Seconds to wait for the process to finish before returning. Returns early if the process exits. Null to return immediately.",
        ge=0,
        le=300,
    ),
    offset: int = Query(
        0,
        description="Number of output entries to skip. Use next_offset from the previous response to get only new output.",
        ge=0,
    ),
    tail: Optional[int] = Query(
        None,
        description="Return only the last N output entries. Useful to limit response size when only recent output matters.",
        ge=1,
    ),
):
    background_process = _get_process(process_id)

    if wait and background_process.status == "running":
        try:
            await asyncio.wait_for(
                asyncio.shield(background_process.log_task), timeout=wait
            )
        except asyncio.TimeoutError:
            pass

    output, next_offset, truncated = await _read_log(
        background_process.log_path, offset=offset, tail=tail
    )

    return {
        "id": background_process.id,
        "command": background_process.command,
        "status": background_process.status,
        "exit_code": background_process.exit_code,
        "output": output,
        "truncated": truncated,
        "next_offset": next_offset,
        "log_path": background_process.log_path,
    }


@app.post(
    "/execute/{process_id}/input",
    operation_id="send_process_input",
    summary="Send input to a running command",
    description="Write text to the process's stdin. Include newline characters as needed.",
    dependencies=[Depends(verify_api_key)],
    responses={
        404: {"description": "Process not found."},
        400: {"description": "Process has already exited or stdin is closed."},
        401: {"description": "Invalid or missing API key."},
    },
)
async def send_input(process_id: str, body: InputRequest):
    background_process = _get_process(process_id)
    if background_process.status != "running":
        raise HTTPException(status_code=400, detail="Process has already exited")

    try:
        background_process.process.stdin.write(body.input.encode())
        await background_process.process.stdin.drain()
    except (BrokenPipeError, ConnectionResetError):
        raise HTTPException(status_code=400, detail="Process stdin is closed")

    return {"status": "ok"}


@app.delete(
    "/execute/{process_id}",
    operation_id="kill_process",
    summary="Kill a running command",
    description="Terminate the process. Sends SIGTERM by default for graceful shutdown. Use force=true to send SIGKILL.",
    dependencies=[Depends(verify_api_key)],
    responses={
        404: {"description": "Process not found."},
        401: {"description": "Invalid or missing API key."},
    },
)
async def kill_process(
    process_id: str,
    force: bool = Query(False, description="Send SIGKILL instead of SIGTERM."),
):
    background_process = _get_process(process_id)
    if background_process.status == "running":
        if force:
            background_process.process.kill()
        else:
            background_process.process.send_signal(signal.SIGTERM)
        await background_process.process.wait()
        background_process.status = "killed"
        background_process.exit_code = background_process.process.returncode
    del _processes[process_id]
    return {"status": "killed"}
