import os
import shutil
import subprocess
import time
import select
import pty
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional

from pathspec import PathSpec
from .logger import Logger


class ToolError(Exception):
    pass


@dataclass
class ExecResult:
    stdout: str
    stderr: str
    exit_code: int


@dataclass
class ExecSession:
    proc: subprocess.Popen
    master_fd: int
    timeout: int
    last_activity: float
    started: float


_EXEC_SESSIONS: Dict[str, ExecSession] = {}
_SESSION_IDLE_TIMEOUT = 300  # seconds


def _ensure_within(base: Path, target: Path) -> Path:
    base = base.resolve()
    target = target.resolve()
    try:
        target.relative_to(base)
    except ValueError as err:
        raise ToolError(f"Path {target} escapes workspace {base}") from err
    return target


def apply_patch(patch_text: str, *, base_dir: Optional[Path] = None, logger: Optional[Logger] = None) -> List[Path]:
    """
    Minimal apply_patch implementation supporting Add/Delete/Update sections.
    """
    log = logger or Logger()
    base_dir = base_dir or Path.cwd()
    lines = patch_text.splitlines()
    if not lines or lines[0].strip() != "*** Begin Patch":
        raise ToolError("Patch must start with *** Begin Patch")
    if not lines[-1].strip() == "*** End Patch":
        raise ToolError("Patch must end with *** End Patch")

    changed: List[Path] = []
    i = 1
    while i < len(lines):
        line = lines[i].rstrip("\n")
        if line.startswith("*** End Patch"):
            break
        if not line.startswith("*** "):
            i += 1
            continue

        if line.startswith("*** Add File: "):
            path = line[len("*** Add File: ") :].strip()
            body: List[str] = []
            i += 1
            while i < len(lines) and not lines[i].startswith("*** "):
                if lines[i].startswith("+"):
                    body.append(lines[i][1:])
                i += 1
            target = _ensure_within(base_dir, base_dir / path)
            target.parent.mkdir(parents=True, exist_ok=True)
            target.write_text("\n".join(body) + ("\n" if body else ""), encoding="utf-8")
            changed.append(target)
            log.info(f"Added file {target}")
            continue

        if line.startswith("*** Delete File: "):
            path = line[len("*** Delete File: ") :].strip()
            target = _ensure_within(base_dir, base_dir / path)
            if target.exists():
                if target.is_dir():
                    shutil.rmtree(target)
                else:
                    target.unlink()
                log.info(f"Deleted {target}")
                changed.append(target)
            i += 1
            continue

        if line.startswith("*** Update File: "):
            path = line[len("*** Update File: ") :].strip()
            i += 1
            # Optional move header is ignored in Phase 0.
            if i < len(lines) and lines[i].startswith("*** Move to: "):
                raise ToolError("File moves are not supported in Phase 0.")

            patch_body: List[str] = []
            while i < len(lines) and not lines[i].startswith("*** "):
                patch_body.append(lines[i])
                i += 1

            target = _ensure_within(base_dir, base_dir / path)
            if not target.exists():
                raise ToolError(f"Cannot update missing file: {target}")

            original_lines = target.read_text(encoding="utf-8").splitlines()
            updated_lines = _apply_hunks(original_lines, patch_body)
            target.write_text("\n".join(updated_lines) + ("\n" if updated_lines else ""), encoding="utf-8")
            changed.append(target)
            log.info(f"Updated file {target}")
            continue

        i += 1

    return changed


def _apply_hunks(original: List[str], patch_lines: List[str]) -> List[str]:
    result: List[str] = []
    idx = 0
    for line in patch_lines:
        if line.startswith("@@"):
            # metadata line, ignore
            continue
        if line.startswith(" "):
            expected = line[1:]
            if idx >= len(original) or original[idx] != expected:
                raise ToolError(f"Context mismatch at line {idx+1}: expected '{expected}'")
            result.append(expected)
            idx += 1
        elif line.startswith("-"):
            expected = line[1:]
            if idx >= len(original) or original[idx] != expected:
                raise ToolError(f"Delete mismatch at line {idx+1}: expected '{expected}'")
            idx += 1
        elif line.startswith("+"):
            result.append(line[1:])
        elif line.strip() == "":
            # blank lines in patch body are treated as context requiring blank line
            if idx >= len(original) or original[idx] != "":
                raise ToolError(f"Context mismatch at line {idx+1}: expected blank line")
            result.append("")
            idx += 1
        else:
            # Treat as context
            expected = line
            if idx >= len(original) or original[idx] != expected:
                raise ToolError(f"Context mismatch at line {idx+1}: expected '{expected}'")
            result.append(expected)
            idx += 1

    # append remaining untouched original lines
    result.extend(original[idx:])
    return result


def run_shell_command(
    command: str,
    workdir: Path,
    *,
    timeout: int = 60,
) -> ExecResult:
    if not workdir.is_absolute():
        raise ToolError("workdir must be an absolute path")
    if not workdir.exists():
        raise ToolError(f"workdir does not exist: {workdir}")

    proc = subprocess.run(
        ["/bin/bash", "-lc", command],
        cwd=str(workdir),
        capture_output=True,
        text=True,
        timeout=timeout,
        input=None,
    )
    return ExecResult(stdout=proc.stdout, stderr=proc.stderr, exit_code=proc.returncode)


def read_file_slice(path: Path, *, offset: int, limit: int, indent: bool = False) -> str:
    if not path.is_absolute():
        raise ToolError("file_path must be an absolute path")
    if offset < 1:
        raise ToolError("offset must be >= 1")
    if limit < 1:
        raise ToolError("limit must be >= 1")
    limit = min(limit, 2000)
    if not path.exists():
        raise ToolError(f"file does not exist: {path}")

    with path.open("r", encoding="utf-8") as handle:
        lines = handle.readlines()
    start = offset - 1
    end = start + limit
    selected = lines[start:end]
    if indent:
        return "".join(selected)
    output = []
    for idx, line in enumerate(selected, start=offset):
        output.append(f"{idx}: {line.rstrip()}")
    return "\n".join(output)


def list_dir(path: Path, *, offset: int, limit: int, depth: int = 2) -> str:
    if not path.is_absolute():
        path = (Path.cwd() / path).resolve()
    if offset < 1 or limit < 1 or depth < 1:
        raise ToolError("offset, limit, and depth must be >= 1")
    max_depth_env = int(os.environ.get("LIST_DIR_MAX_DEPTH", "3"))
    max_depth_env = max(1, max_depth_env)
    if depth > max_depth_env:
        raise ToolError(f"depth exceeds max allowed ({max_depth_env})")
    if not path.exists():
        raise ToolError(f"dir does not exist: {path}")

    entries: List[str] = []
    ignore_spec: Optional[PathSpec] = None
    gitignore_file = path / ".gitignore"
    if gitignore_file.exists() and gitignore_file.is_file():
        gitignore = gitignore_file.read_text(encoding="utf-8").splitlines()
        ignore_spec = PathSpec.from_lines("gitwildmatch", gitignore)

    def walk(current: Path, current_depth: int) -> None:
        for child in sorted(current.iterdir()):
            name = child.name
            if name == ".git" or name.endswith(".git") or name == ".gitignore":
                continue
            if ignore_spec and ignore_spec.match_file(str(child.relative_to(path))):
                continue
            rel = child.relative_to(path)
            prefix = "  " * current_depth
            label = "[D]" if child.is_dir() else "[F]"
            entries.append(f"{prefix}{label} {rel}")
            if child.is_dir() and current_depth < depth:
                walk(child, current_depth + 1)

    walk(path, 0)

    start = offset - 1
    end = start + limit
    sliced = entries[start:end]
    if end < len(entries):
        sliced.append(f"... {len(entries) - end} more entries not shown")
    return f"Absolute path: {path}\n" + "\n".join(sliced)


def _cleanup_sessions() -> None:
    now = time.time()
    for sid, sess in list(_EXEC_SESSIONS.items()):
        if now - sess.started > sess.timeout or now - sess.last_activity > _SESSION_IDLE_TIMEOUT:
            if sess.proc.poll() is None:
                try:
                    sess.proc.kill()
                except Exception:
                    pass
            try:
                os.close(sess.master_fd)
            except Exception:
                pass
            _EXEC_SESSIONS.pop(sid, None)


def _read_pty_output(sess: ExecSession, read_timeout: float = 1.0) -> str:
    chunks: List[str] = []
    end_time = time.time() + read_timeout
    while True:
        remaining = end_time - time.time()
        if remaining <= 0:
            break
        rlist, _, _ = select.select([sess.master_fd], [], [], remaining)
        if not rlist:
            break
        try:
            data = os.read(sess.master_fd, 4096)
        except OSError:
            break
        if not data:
            break
        chunks.append(data.decode(errors="ignore"))
        if len(data) < 4096:
            break
    return "".join(chunks)


def close_exec_session(session: str) -> None:
    sess = _EXEC_SESSIONS.pop(session, None)
    if not sess:
        return
    try:
        if sess.proc.poll() is None:
            sess.proc.kill()
    finally:
        try:
            os.close(sess.master_fd)
        except Exception:
            pass


def exec_command(
    command: str,
    session: str,
    workdir: Path,
    *,
    timeout: int = 600,
) -> str:
    _cleanup_sessions()
    if not session or len(session) > 64:
        raise ToolError("session must be non-empty and <=64 chars")
    if session in _EXEC_SESSIONS:
        raise ToolError("session already exists")
    if len(_EXEC_SESSIONS) >= 1:
        raise ToolError("only one active session allowed")
    if timeout < 1 or timeout > 1200:
        raise ToolError("timeout must be between 1 and 1200 seconds")
    if not workdir.is_absolute():
        workdir = (Path.cwd() / workdir).resolve()
    if not workdir.exists():
        raise ToolError(f"workdir does not exist: {workdir}")

    master_fd, slave_fd = pty.openpty()
    try:
        proc = subprocess.Popen(
            ["/bin/bash", "-lc", command],
            cwd=str(workdir),
            stdin=slave_fd,
            stdout=slave_fd,
            stderr=slave_fd,
            text=False,
        )
    except Exception as err:
        os.close(master_fd)
        os.close(slave_fd)
        raise ToolError(f"exec_command failed: {err}") from err
    finally:
        try:
            os.close(slave_fd)
        except Exception:
            pass

    sess = ExecSession(proc=proc, master_fd=master_fd, timeout=timeout, last_activity=time.time(), started=time.time())
    _EXEC_SESSIONS[session] = sess
    output = _read_pty_output(sess, read_timeout=1.0)
    if proc.poll() is None:
        try:
            proc.wait(timeout=0.05)
        except subprocess.TimeoutExpired:
            pass
    if proc.poll() is not None:
        close_exec_session(session)
    return output


def write_stdin(
    session: str,
    input: str,
) -> str:
    _cleanup_sessions()
    sess = _EXEC_SESSIONS.get(session)
    if not sess:
        raise ToolError("session is missing or expired")
    now = time.time()
    if now - sess.started > sess.timeout:
        close_exec_session(session)
        raise ToolError("session timed out")
    try:
        os.write(sess.master_fd, input.encode())
    except Exception as err:
        close_exec_session(session)
        raise ToolError(f"failed to write stdin: {err}") from err
    sess.last_activity = now
    output = _read_pty_output(sess, read_timeout=1.0)
    if sess.proc.poll() is not None:
        close_exec_session(session)
    return output


def grep_files(pattern: str, *, workdir: Optional[Path] = None, limit: int = 100, timeout: int = 60) -> str:
    if not pattern:
        raise ToolError("pattern is required")
    if limit < 1 or limit > 500:
        raise ToolError("limit must be between 1 and 500")
    workdir = (workdir or Path.cwd()).resolve()
    if not workdir.is_absolute():
        raise ToolError("workdir must be absolute")
    if not workdir.exists():
        raise ToolError(f"workdir does not exist: {workdir}")

    cmd = ["rg", "--no-heading", "--line-number", "--color", "never", pattern, "."]
    try:
        proc = subprocess.run(
            cmd,
            cwd=str(workdir),
            capture_output=True,
            text=True,
            timeout=timeout,
        )
    except FileNotFoundError as err:
        raise ToolError("rg (ripgrep) is not installed") from err
    except subprocess.TimeoutExpired as err:
        raise ToolError(f"grep_files timed out after {timeout}s") from err

    if proc.returncode not in (0, 1):  # 1 == no matches
        raise ToolError(f"rg failed: {proc.stderr.strip()}")

    lines = proc.stdout.splitlines()
    sliced = lines[:limit]
    if len(lines) > limit:
        sliced.append(f"... {len(lines) - limit} more matches not shown")
    return "\n".join(sliced)


def web_search(
    query: str,
    *,
    limit: int = 5,
    timeout: int = 30,
    provider: Optional[str] = None,
    base_url: Optional[str] = None,
    api_key: Optional[str] = None,
) -> str:
    if not query:
        raise ToolError("query is required")
    if limit < 1 or limit > 10:
        raise ToolError("limit must be between 1 and 10")
    if timeout < 1 or timeout > 60:
        raise ToolError("timeout must be between 1 and 60")

    prov = provider or os.environ.get("SEARCH_PROVIDER", "tavily")
    base_url = base_url or os.environ.get("SEARCH_BASE_URL")
    api_key = api_key or os.environ.get("SEARCH_API_KEY")

    if prov == "mock":
        return f"mock web_search results for '{query}' (limit {limit})"

    if prov == "tavily":
        if not api_key:
            raise ToolError("SEARCH_API_KEY is required for tavily")
        url = base_url or "https://api.tavily.com/search"
        import requests

        try:
            resp = requests.post(
                url,
                json={"api_key": api_key, "query": query, "max_results": limit},
                timeout=timeout,
            )
        except requests.Timeout as err:
            raise ToolError(f"web_search timed out after {timeout}s") from err
        except Exception as err:  # pragma: no cover
            raise ToolError(f"web_search failed: {err}") from err

        if resp.status_code != 200:
            raise ToolError(f"web_search failed: HTTP {resp.status_code} {resp.text}")
        data = resp.json()
        results = data.get("results", [])
        lines = []
        for item in results[:limit]:
            title = item.get("title", "")
            url_item = item.get("url", "")
            snippet = item.get("content", "") or item.get("snippet", "")
            lines.append(f"{title}\n{url_item}\n{snippet}")
        if not lines:
            return "no results"
        return "\n\n".join(lines)

    raise ToolError(f"Unsupported search provider: {prov}")


def update_plan(plan_text: str) -> str:
    import json

    try:
        data = json.loads(plan_text)
    except json.JSONDecodeError as err:
        raise ToolError(f"Invalid plan JSON: {err}") from err

    if not isinstance(data, dict) or "plan" not in data:
        raise ToolError("Plan JSON must be an object with a 'plan' field")
    if not isinstance(data["plan"], list):
        raise ToolError("Plan JSON 'plan' must be a list")

    for idx, item in enumerate(data["plan"], start=1):
        if not isinstance(item, dict):
            raise ToolError(f"Plan item {idx} must be an object")
        step = item.get("step")
        status = item.get("status")
        if not isinstance(step, str) or not step:
            raise ToolError(f"Plan item {idx} missing 'step'")
        if status not in {"pending", "in_progress", "completed"}:
            raise ToolError(f"Plan item {idx} has invalid status '{status}'")

    return json.dumps(data)
